mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-08 08:52:12 +00:00
feat(agents): Show subagents as their own conversations (#132)
* feat(agents): Show subagents as their own conversations Agents that support ACP subagent sessions (Claude Code, Codex) now run each subagent as a child session. Its tool calls and messages no longer mix into the task's conversation: the parent shows a card per subagent with its state, tool call count, latest step, task and result, and the card opens the subagent's own conversation. - Route session/update by session id; subagent settings and usage stay out of the task. - Subagent permission requests are answered in the task's conversation, named after the subagent. - Subagent conversations are saved with the task; a running subagent is marked stopped after a restart. - A running subagent can be stopped on its own. * fix(agents): Save a subagent's state when it changes after the turn
This commit is contained in:
1 parent
5b63a2b95c
commit
ecb3134460
14 files changed
+1230
-80
No files matched your search
@@ -0,0 +1,281 @@
|
||||
//! A task's conversation and those of its subagents.
|
||||
//!
|
||||
//! An agent that supports ACP subagent sessions runs each subagent (Claude Code's Agent
|
||||
//! tool, Codex's spawned agents) as a child session: `subagent_update` on the parent
|
||||
//! announces it and reports its state, its own updates carry its session id, and
|
||||
//! `session_message`s carry what it was asked and what it answered. The parent's
|
||||
//! timeline shows a card per subagent; each subagent keeps its own timeline.
|
||||
|
||||
use crate::model::AgentEntry;
|
||||
use crate::timeline::{Timeline, blocks_text, content_text};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
/// Where an entry lives: the task's own conversation (`None`) or a subagent's session.
|
||||
pub(crate) type Session = Option<String>;
|
||||
|
||||
#[derive(Default)]
|
||||
pub(crate) struct Conversation {
|
||||
pub main: Timeline,
|
||||
/// In the order they started.
|
||||
subagents: Vec<Subagent>,
|
||||
}
|
||||
|
||||
struct Subagent {
|
||||
id: String,
|
||||
/// The session that started it.
|
||||
parent: Session,
|
||||
timeline: Timeline,
|
||||
}
|
||||
|
||||
/// A subagent's conversation, as saved with its task.
|
||||
#[derive(Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct SavedSubagent {
|
||||
pub id: String,
|
||||
pub parent: Option<String>,
|
||||
pub entries: Vec<AgentEntry>,
|
||||
}
|
||||
|
||||
impl Conversation {
|
||||
pub fn restore(main: Vec<AgentEntry>, subagents: Vec<SavedSubagent>) -> Self {
|
||||
Self {
|
||||
main: Timeline::from_entries(main),
|
||||
subagents: subagents
|
||||
.into_iter()
|
||||
.map(|s| Subagent { id: s.id, parent: s.parent, timeline: Timeline::from_entries(s.entries) })
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn saved_subagents(&self) -> Vec<SavedSubagent> {
|
||||
self.subagents
|
||||
.iter()
|
||||
.map(|s| SavedSubagent { id: s.id.clone(), parent: s.parent.clone(), entries: s.timeline.entries.clone() })
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn timeline(&self, session: &Session) -> Option<&Timeline> {
|
||||
match session {
|
||||
None => Some(&self.main),
|
||||
Some(id) => self.subagents.iter().find(|s| &s.id == id).map(|s| &s.timeline),
|
||||
}
|
||||
}
|
||||
|
||||
fn timeline_mut(&mut self, session: &Session) -> Option<&mut Timeline> {
|
||||
match session {
|
||||
None => Some(&mut self.main),
|
||||
Some(id) => self.subagents.iter_mut().find(|s| &s.id == id).map(|s| &mut s.timeline),
|
||||
}
|
||||
}
|
||||
|
||||
fn parent_of(&self, id: &str) -> Option<&Session> {
|
||||
self.subagents.iter().find(|s| s.id == id).map(|s| &s.parent)
|
||||
}
|
||||
|
||||
/// The session an update for session id `session` belongs to: a known subagent's, else the task's.
|
||||
pub fn owner(&self, session: Option<&str>) -> Session {
|
||||
session.filter(|s| self.parent_of(s).is_some()).map(str::to_owned)
|
||||
}
|
||||
|
||||
/// The parent's title (or description) for subagent `id`.
|
||||
pub fn label(&self, id: &str) -> Option<String> {
|
||||
let parent = self.parent_of(id)?;
|
||||
self.timeline(parent)?.entries.iter().find_map(|e| match e {
|
||||
AgentEntry::Subagent { id: card, title, description, .. } if card == id => {
|
||||
title.clone().or_else(|| description.clone())
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Applies one `session/update` of session id `session` (the task's own when unknown);
|
||||
/// returns the entries it added or changed.
|
||||
pub fn apply(&mut self, session: Option<&str>, update: &Value, now: f64) -> Vec<(Session, usize)> {
|
||||
let owner = self.owner(session);
|
||||
let kind = update.get("sessionUpdate").and_then(Value::as_str).unwrap_or_default();
|
||||
let mut out = Vec::new();
|
||||
match kind {
|
||||
"subagent_update" => {
|
||||
let Some(id) = update.get("sessionId").and_then(Value::as_str) else { return out };
|
||||
if self.parent_of(id).is_none() {
|
||||
self.subagents.push(Subagent { id: id.to_owned(), parent: owner, timeline: Timeline::default() });
|
||||
}
|
||||
let parent = self.parent_of(id).cloned().flatten();
|
||||
let Some(timeline) = self.timeline_mut(&parent) else { return out };
|
||||
let (index, stopped) = timeline.subagent(id, update, now);
|
||||
out.push((parent, index));
|
||||
if stopped && let Some(child) = self.timeline_mut(&Some(id.to_owned())) {
|
||||
let id = Some(id.to_owned());
|
||||
out.extend(child.end_turn(None).into_iter().map(|i| (id.clone(), i)));
|
||||
}
|
||||
}
|
||||
"session_message" | "session_message_chunk" => {
|
||||
let append = kind == "session_message_chunk";
|
||||
let text = if append {
|
||||
content_text(update.get("content")).unwrap_or_default()
|
||||
} else {
|
||||
blocks_text(update.get("content"))
|
||||
};
|
||||
let field = |k: &str| update.get(k).and_then(Value::as_str).map(str::to_owned);
|
||||
let (sender, recipient) = (field("senderSessionId"), field("recipientSessionId"));
|
||||
let ours =
|
||||
|s: &Option<String>| s.as_deref().filter(|s| self.parent_of(s) == Some(&owner)).map(str::to_owned);
|
||||
// Between this session and a subagent of it: the subagent's task or answer, on its card.
|
||||
let card = ours(&recipient).map(|c| (c, false)).or_else(|| ours(&sender).map(|c| (c, true)));
|
||||
let Some(timeline) = self.timeline_mut(&owner) else { return out };
|
||||
let index = match card {
|
||||
Some((child, reply)) => timeline.card_message(&child, reply, &text, append),
|
||||
None => {
|
||||
let id = field("messageId").unwrap_or_default();
|
||||
Some(timeline.message(&id, sender != owner, &text, append))
|
||||
}
|
||||
};
|
||||
out.extend(index.map(|i| (owner, i)));
|
||||
}
|
||||
_ => {
|
||||
let Some(timeline) = self.timeline_mut(&owner) else { return out };
|
||||
out.extend(timeline.apply(update).changed.into_iter().map(|i| (owner.clone(), i)));
|
||||
if let Some(id) = owner.as_deref()
|
||||
&& matches!(kind, "tool_call" | "tool_call_update")
|
||||
{
|
||||
out.extend(self.refresh_card(id));
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Updates subagent `id`'s card from its timeline.
|
||||
fn refresh_card(&mut self, id: &str) -> Option<(Session, usize)> {
|
||||
let parent = self.parent_of(id)?.clone();
|
||||
let child = self.subagents.iter().position(|s| s.id == id)?;
|
||||
let timeline = std::mem::take(&mut self.subagents[child].timeline);
|
||||
let changed = self.timeline_mut(&parent).and_then(|p| p.card_activity(id, &timeline));
|
||||
self.subagents[child].timeline = timeline;
|
||||
changed.map(|i| (parent, i))
|
||||
}
|
||||
|
||||
/// Settles what an ended turn of the task left open. Subagents work on (in the background).
|
||||
pub fn end_turn(&mut self, done: Option<f64>) -> Vec<(Session, usize)> {
|
||||
self.main.end_turn(done).into_iter().map(|i| (None, i)).collect()
|
||||
}
|
||||
|
||||
/// Settles everything left open once the agent is gone, subagents included.
|
||||
pub fn settle(&mut self, now: f64) -> Vec<(Session, usize)> {
|
||||
let mut out: Vec<(Session, usize)> = self.main.end_turn(None).into_iter().map(|i| (None, i)).collect();
|
||||
out.extend(self.main.stop_subagents(now).into_iter().map(|i| (None, i)));
|
||||
for s in &mut self.subagents {
|
||||
let id = Some(s.id.clone());
|
||||
let mut changed = s.timeline.end_turn(None);
|
||||
changed.extend(s.timeline.stop_subagents(now));
|
||||
out.extend(changed.into_iter().map(|i| (id.clone(), i)));
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::model::{AgentSubagentStatus, AgentToolStatus};
|
||||
use serde_json::json;
|
||||
|
||||
fn card(c: &Conversation, session: &Session, index: usize) -> AgentEntry {
|
||||
c.timeline(session).unwrap().entries[index].clone()
|
||||
}
|
||||
|
||||
/// Claude Code's Agent tool, as `claude-agent-acp` reports it to a client with subagent sessions.
|
||||
#[test]
|
||||
fn a_subagent_gets_a_card_in_its_parent_and_its_own_timeline() {
|
||||
let mut c = Conversation::default();
|
||||
let sub = Some("child".to_owned());
|
||||
let announce = json!({"sessionUpdate": "subagent_update", "sessionId": "child", "title": "Explore",
|
||||
"description": "Find the parser", "capabilities": {"cancel": {}}, "state": {"state": "running"}});
|
||||
assert_eq!(c.apply(Some("root"), &announce, 10.0), vec![(None, 0)]);
|
||||
// The launch prompt: the parent's outgoing view, then the child's incoming one.
|
||||
let prompt = json!({"sessionUpdate": "session_message", "messageId": "toolu_1",
|
||||
"senderSessionId": "root", "recipientSessionId": "child",
|
||||
"content": [{"type": "text", "text": "Look for it"}]});
|
||||
assert_eq!(c.apply(Some("root"), &prompt, 11.0), vec![(None, 0)]);
|
||||
assert_eq!(c.apply(Some("child"), &prompt, 11.0), vec![(sub.clone(), 0)]);
|
||||
assert_eq!(card(&c, &sub, 0), AgentEntry::Message { incoming: true, text: "Look for it".into() });
|
||||
|
||||
// The child's own work stays out of the parent's timeline; its card follows it.
|
||||
c.apply(
|
||||
Some("child"),
|
||||
&json!({"sessionUpdate": "agent_message_chunk", "content": {"type": "text", "text": "hm"}}),
|
||||
12.0,
|
||||
);
|
||||
let changed = c.apply(
|
||||
Some("child"),
|
||||
&json!({"sessionUpdate": "tool_call", "toolCallId": "t1", "title": "Read lib.rs", "kind": "read", "status": "in_progress"}),
|
||||
12.0,
|
||||
);
|
||||
assert_eq!(changed, vec![(sub.clone(), 2), (None, 0)]);
|
||||
assert_eq!(c.main.entries.len(), 1);
|
||||
// Settings of the child session don't touch the task.
|
||||
assert_eq!(c.owner(Some("child")), sub);
|
||||
assert_eq!(c.owner(Some("root")), None);
|
||||
|
||||
let done = json!({"sessionUpdate": "subagent_update", "sessionId": "child", "state": {"state": "idle", "stopReason": "end_turn"}});
|
||||
let changed = c.apply(Some("root"), &done, 20.0);
|
||||
// Its tool calls left open are settled when it stops.
|
||||
assert_eq!(changed, vec![(None, 0), (sub.clone(), 2)]);
|
||||
let reply = json!({"sessionUpdate": "session_message", "messageId": "toolu_1:reply", "senderSessionId": "child",
|
||||
"recipientSessionId": "root", "content": [{"type": "text", "text": "In "}, {"type": "text", "text": "parser.rs"}]});
|
||||
c.apply(Some("root"), &reply, 20.0);
|
||||
assert_eq!(
|
||||
c.main.entries[0],
|
||||
AgentEntry::Subagent {
|
||||
id: "child".into(),
|
||||
title: Some("Explore".into()),
|
||||
description: Some("Find the parser".into()),
|
||||
status: AgentSubagentStatus::Done,
|
||||
error: None,
|
||||
prompt: Some("Look for it".into()),
|
||||
reply: Some("In \nparser.rs".into()),
|
||||
tools: 1,
|
||||
activity: Some("Read lib.rs".into()),
|
||||
started_ms: 10.0,
|
||||
ended_ms: Some(20.0),
|
||||
cancellable: true,
|
||||
}
|
||||
);
|
||||
assert_eq!(c.label("child").as_deref(), Some("Explore"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_subagents_and_failures() {
|
||||
let mut c = Conversation::default();
|
||||
c.apply(
|
||||
None,
|
||||
&json!({"sessionUpdate": "subagent_update", "sessionId": "a", "state": {"state": "running"}}),
|
||||
1.0,
|
||||
);
|
||||
c.apply(
|
||||
Some("a"),
|
||||
&json!({"sessionUpdate": "subagent_update", "sessionId": "b", "description": "deeper"}),
|
||||
2.0,
|
||||
);
|
||||
let a = Some("a".to_owned());
|
||||
assert!(matches!(card(&c, &a, 0), AgentEntry::Subagent { ref id, .. } if id == "b"));
|
||||
let failed = json!({"sessionUpdate": "subagent_update", "sessionId": "b",
|
||||
"state": {"state": "idle", "stopReason": "error", "error": {"code": -32603, "message": "boom"}}});
|
||||
assert_eq!(c.apply(Some("a"), &failed, 3.0), vec![(a.clone(), 0)]);
|
||||
assert!(matches!(card(&c, &a, 0),
|
||||
AgentEntry::Subagent { status: AgentSubagentStatus::Failed, error: Some(ref e), ended_ms: Some(3.0), .. } if e == "boom"));
|
||||
|
||||
// Saved and restored, then the agent is gone: what still works is stopped.
|
||||
let mut c = Conversation::restore(c.main.entries.clone(), c.saved_subagents());
|
||||
c.apply(
|
||||
Some("a"),
|
||||
&json!({"sessionUpdate": "tool_call", "toolCallId": "t", "title": "Run", "status": "in_progress"}),
|
||||
4.0,
|
||||
);
|
||||
let settled = c.settle(9.0);
|
||||
assert_eq!(settled, vec![(None, 0), (a.clone(), 1)]);
|
||||
assert!(matches!(c.main.entries[0], AgentEntry::Subagent { status: AgentSubagentStatus::Cancelled, .. }));
|
||||
assert!(matches!(card(&c, &a, 1), AgentEntry::Tool { status: AgentToolStatus::Failed, .. }));
|
||||
}
|
||||
}
|
||||
+82
-29
@@ -8,6 +8,7 @@
|
||||
//! next prompt when the agent supports `session/load`.
|
||||
|
||||
mod config;
|
||||
mod conversation;
|
||||
mod model;
|
||||
mod provider;
|
||||
mod skills;
|
||||
@@ -16,6 +17,7 @@ mod worktree;
|
||||
|
||||
pub use model::*;
|
||||
|
||||
use conversation::{Conversation, SavedSubagent, Session};
|
||||
use ide_core::{IdeError, IdeResult, IpcPath};
|
||||
use ide_jsonrpc::{Framing, Handler, Peer, RpcError, codes};
|
||||
use parking_lot::{Mutex, RwLock};
|
||||
@@ -26,7 +28,6 @@ use std::path::{Path, PathBuf};
|
||||
use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
|
||||
use std::sync::{Arc, Weak};
|
||||
use std::time::Duration;
|
||||
use timeline::Timeline;
|
||||
|
||||
/// Receives the events of one project's tasks.
|
||||
pub type AgentSink = Arc<dyn Fn(AgentEvent) + Send + Sync>;
|
||||
@@ -39,6 +40,9 @@ const REPLAY_GRACE: Duration = Duration::from_millis(300);
|
||||
/// can trail its `session/prompt` response by this much.
|
||||
const TURN_GRACE: Duration = Duration::from_millis(100);
|
||||
const STDERR_LINES: usize = 20;
|
||||
/// Updates of a session's state rather than its conversation.
|
||||
const SESSION_STATE: [&str; 5] =
|
||||
["session_info_update", "config_option_update", "current_mode_update", "available_commands_update", "usage_update"];
|
||||
/// Referenced files up to this size are embedded in the prompt (when the agent takes it).
|
||||
const MAX_EMBED: u64 = 256 * 1024;
|
||||
|
||||
@@ -155,12 +159,14 @@ struct TaskFile {
|
||||
/// In the project's own checkout: files already changed when the task started.
|
||||
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
|
||||
before: BTreeMap<String, worktree::Stamp>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
subagents: Vec<SavedSubagent>,
|
||||
}
|
||||
|
||||
struct TaskState {
|
||||
task: AgentTask,
|
||||
session_id: Option<String>,
|
||||
timeline: Timeline,
|
||||
conversation: Conversation,
|
||||
wanted: Vec<AgentConfigChoice>,
|
||||
before: Arc<BTreeMap<String, worktree::Stamp>>,
|
||||
/// A turn is running or about to (prompts arriving now are queued).
|
||||
@@ -192,7 +198,7 @@ struct Task {
|
||||
replaying: AtomicBool,
|
||||
/// The current turn is being cancelled to send the first queued message right away.
|
||||
send_now: AtomicBool,
|
||||
dirty: Mutex<BTreeSet<usize>>,
|
||||
dirty: Mutex<BTreeSet<(Session, usize)>>,
|
||||
flush_scheduled: AtomicBool,
|
||||
stderr: Mutex<VecDeque<String>>,
|
||||
/// Set by `delete`; held while saving.
|
||||
@@ -235,11 +241,9 @@ impl AgentManager {
|
||||
// The agent process did not survive the restart: whatever was in flight is over.
|
||||
if matches!(f.task.status, AgentStatus::Running | AgentStatus::NeedsInput | AgentStatus::Starting) {
|
||||
f.task.status = AgentStatus::Ready;
|
||||
let mut timeline = Timeline::from_entries(std::mem::take(&mut f.entries));
|
||||
timeline.end_turn(None);
|
||||
f.entries = timeline.entries;
|
||||
}
|
||||
let task = Task::restore(f, shared.clone());
|
||||
task.state.lock().conversation.settle(now_ms());
|
||||
tasks.insert(task.id.clone(), task);
|
||||
}
|
||||
Err(e) => tracing::warn!("skipping agent task {}: {e}", path.display()),
|
||||
@@ -301,8 +305,12 @@ impl AgentManager {
|
||||
self.tasks.read().get(id).cloned().ok_or_else(|| IdeError::not_found(format!("no agent task {id}")))
|
||||
}
|
||||
|
||||
pub fn entries(&self, id: &str) -> IdeResult<Vec<AgentEntry>> {
|
||||
Ok(self.task(id)?.state.lock().timeline.entries.clone())
|
||||
/// The entries of the task's conversation, or of its subagent `subagent`'s.
|
||||
pub fn entries(&self, id: &str, subagent: Option<String>) -> IdeResult<Vec<AgentEntry>> {
|
||||
let task = self.task(id)?;
|
||||
let s = task.state.lock();
|
||||
let timeline = s.conversation.timeline(&subagent).ok_or_else(|| IdeError::not_found("no such subagent"))?;
|
||||
Ok(timeline.entries.clone())
|
||||
}
|
||||
|
||||
/// Creates the task (and its worktree) and sends `req.prompt` as its first message.
|
||||
@@ -357,7 +365,14 @@ impl AgentManager {
|
||||
usage: None,
|
||||
queue: Vec::new(),
|
||||
};
|
||||
let file = TaskFile { task: task.clone(), session_id: None, entries: Vec::new(), wanted: req.config, before };
|
||||
let file = TaskFile {
|
||||
task: task.clone(),
|
||||
session_id: None,
|
||||
entries: Vec::new(),
|
||||
wanted: req.config,
|
||||
before,
|
||||
subagents: Vec::new(),
|
||||
};
|
||||
let handle = Task::restore(file, self.shared.clone());
|
||||
handle.state.lock().busy = true;
|
||||
self.tasks.write().insert(task.id.clone(), handle.clone());
|
||||
@@ -487,9 +502,17 @@ impl AgentManager {
|
||||
}
|
||||
|
||||
/// Stops the current turn (`session/cancel`); pending permission requests are cancelled.
|
||||
/// Queued messages stay queued.
|
||||
pub fn cancel(&self, id: &str) -> IdeResult<()> {
|
||||
self.task(id)?.cancel();
|
||||
/// Queued messages stay queued. With `subagent`, stops only that subagent's work.
|
||||
pub fn cancel(&self, id: &str, subagent: Option<String>) -> IdeResult<()> {
|
||||
let task = self.task(id)?;
|
||||
match subagent {
|
||||
None => task.cancel(),
|
||||
Some(sub) => {
|
||||
if let Some(live) = task.live.lock().clone() {
|
||||
let _ = live.peer.notify("session/cancel", json!({ "sessionId": sub }));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -551,6 +574,8 @@ impl AgentManager {
|
||||
pub fn shutdown(&self) {
|
||||
for task in self.tasks.read().values() {
|
||||
task.kill();
|
||||
// What arrived after the last save (a subagent's late updates).
|
||||
task.persist();
|
||||
}
|
||||
*self.shared.sink.write() = None;
|
||||
}
|
||||
@@ -559,14 +584,14 @@ impl AgentManager {
|
||||
impl Task {
|
||||
fn restore(file: TaskFile, shared: Arc<Shared>) -> Arc<Self> {
|
||||
let task = file.task;
|
||||
let timeline = Timeline::from_entries(file.entries);
|
||||
let conversation = Conversation::restore(file.entries, file.subagents);
|
||||
Arc::new(Self {
|
||||
id: task.id.clone(),
|
||||
shared,
|
||||
state: Mutex::new(TaskState {
|
||||
task,
|
||||
session_id: file.session_id,
|
||||
timeline,
|
||||
conversation,
|
||||
wanted: file.wanted,
|
||||
before: Arc::new(file.before),
|
||||
busy: false,
|
||||
@@ -623,9 +648,10 @@ impl Task {
|
||||
serde_json::to_vec(&TaskFile {
|
||||
task: s.task.clone(),
|
||||
session_id: s.session_id.clone(),
|
||||
entries: s.timeline.entries.clone(),
|
||||
entries: s.conversation.main.entries.clone(),
|
||||
wanted: s.wanted.clone(),
|
||||
before: (*s.before).clone(),
|
||||
subagents: s.conversation.saved_subagents(),
|
||||
})
|
||||
};
|
||||
let path = self.shared.paths.store_dir.join(format!("{}.json", self.id));
|
||||
@@ -634,7 +660,7 @@ impl Task {
|
||||
}
|
||||
}
|
||||
|
||||
fn mark_dirty(self: &Arc<Self>, indices: impl IntoIterator<Item = usize>) {
|
||||
fn mark_dirty(self: &Arc<Self>, indices: impl IntoIterator<Item = (Session, usize)>) {
|
||||
self.dirty.lock().extend(indices);
|
||||
if !self.flush_scheduled.swap(true, Ordering::AcqRel) {
|
||||
let task = self.clone();
|
||||
@@ -652,9 +678,9 @@ impl Task {
|
||||
let s = self.state.lock();
|
||||
indices
|
||||
.into_iter()
|
||||
.filter_map(|i| {
|
||||
let entry = s.timeline.entries.get(i)?.clone();
|
||||
Some(AgentEvent::Entry { task: self.id.clone(), index: i as u32, entry })
|
||||
.filter_map(|(subagent, i)| {
|
||||
let entry = s.conversation.timeline(&subagent)?.entries.get(i)?.clone();
|
||||
Some(AgentEvent::Entry { task: self.id.clone(), subagent, index: i as u32, entry })
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
@@ -664,8 +690,8 @@ impl Task {
|
||||
}
|
||||
|
||||
fn push_entry(self: &Arc<Self>, entry: AgentEntry) -> usize {
|
||||
let i = self.state.lock().timeline.push(entry);
|
||||
self.mark_dirty([i]);
|
||||
let i = self.state.lock().conversation.main.push(entry);
|
||||
self.mark_dirty([(None, i)]);
|
||||
i
|
||||
}
|
||||
|
||||
@@ -675,7 +701,7 @@ impl Task {
|
||||
loop {
|
||||
let result = self.turn(&prompt).await;
|
||||
tokio::time::sleep(TURN_GRACE).await;
|
||||
let changed = self.state.lock().timeline.end_turn(Some(now_ms()));
|
||||
let changed = self.state.lock().conversation.end_turn(Some(now_ms()));
|
||||
self.mark_dirty(changed);
|
||||
let send_now = self.send_now.swap(false, Ordering::AcqRel);
|
||||
let next = {
|
||||
@@ -789,7 +815,12 @@ impl Task {
|
||||
"initialize",
|
||||
json!({
|
||||
"protocolVersion": 1,
|
||||
"clientCapabilities": { "fs": { "readTextFile": false, "writeTextFile": false }, "terminal": false },
|
||||
"clientCapabilities": {
|
||||
"fs": { "readTextFile": false, "writeTextFile": false },
|
||||
"terminal": false,
|
||||
// Subagents as sessions of their own (ACP subagents RFD).
|
||||
"subagents": {},
|
||||
},
|
||||
"clientInfo": { "name": "OpenIDEA", "version": env!("CARGO_PKG_VERSION") },
|
||||
}),
|
||||
None,
|
||||
@@ -890,8 +921,16 @@ impl Task {
|
||||
}
|
||||
}
|
||||
|
||||
fn on_update(self: &Arc<Self>, update: &Value) {
|
||||
/// Applies a `session/update` of session `session` (the task's, or one of its subagents').
|
||||
fn on_update(self: &Arc<Self>, session: Option<&str>, update: &Value) {
|
||||
let kind = update.get("sessionUpdate").and_then(Value::as_str).unwrap_or_default();
|
||||
// A subagent's title, settings and usage are its own, not the task's.
|
||||
if self.state.lock().conversation.owner(session).is_some() {
|
||||
if !SESSION_STATE.contains(&kind) && !self.replaying.load(Ordering::Acquire) {
|
||||
self.apply(session, kind, update);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if kind == "session_info_update" {
|
||||
// The agent's own title for the session replaces the first line of the prompt.
|
||||
let title = update.get("title").and_then(Value::as_str).map(str::trim).filter(|t| !t.is_empty());
|
||||
@@ -946,8 +985,16 @@ impl Task {
|
||||
if self.replaying.load(Ordering::Acquire) {
|
||||
return;
|
||||
}
|
||||
let applied = self.state.lock().timeline.apply(update);
|
||||
self.mark_dirty(applied.changed);
|
||||
self.apply(session, kind, update);
|
||||
}
|
||||
|
||||
fn apply(self: &Arc<Self>, session: Option<&str>, kind: &str, update: &Value) {
|
||||
let applied = self.state.lock().conversation.apply(session, update, now_ms());
|
||||
self.mark_dirty(applied);
|
||||
// A subagent can start or finish after the turn that started it: saved right away.
|
||||
if kind == "subagent_update" {
|
||||
self.persist();
|
||||
}
|
||||
}
|
||||
|
||||
/// Blocks the calling (blocking-pool) thread until the user answers.
|
||||
@@ -959,6 +1006,12 @@ impl Task {
|
||||
.filter(|t| !t.is_empty())
|
||||
.unwrap_or("The agent asks for permission")
|
||||
.to_owned();
|
||||
// A subagent's request is answered in the task's conversation, named after the subagent.
|
||||
let session = params.get("sessionId").and_then(Value::as_str);
|
||||
let title = match session.and_then(|s| self.state.lock().conversation.label(s)) {
|
||||
Some(label) => format!("{label}: {title}"),
|
||||
None => title,
|
||||
};
|
||||
let options: Vec<AgentPermissionOption> = params
|
||||
.get("options")
|
||||
.and_then(Value::as_array)
|
||||
@@ -986,11 +1039,11 @@ impl Task {
|
||||
.map_or_else(|| "Cancelled".to_owned(), |o| o.name.clone());
|
||||
{
|
||||
let mut s = self.state.lock();
|
||||
if let Some(AgentEntry::Permission { chosen, .. }) = s.timeline.entries.get_mut(entry) {
|
||||
if let Some(AgentEntry::Permission { chosen, .. }) = s.conversation.main.entries.get_mut(entry) {
|
||||
*chosen = Some(name);
|
||||
}
|
||||
}
|
||||
self.mark_dirty([entry]);
|
||||
self.mark_dirty([(None, entry)]);
|
||||
let still_asking = !self.permissions.lock().is_empty();
|
||||
if !still_asking && self.state.lock().task.status == AgentStatus::NeedsInput {
|
||||
self.set_status(AgentStatus::Running, None);
|
||||
@@ -1032,7 +1085,7 @@ impl Handler for Client {
|
||||
if method == "session/update"
|
||||
&& let (Some(task), Some(update)) = (self.task.upgrade(), params.get("update"))
|
||||
{
|
||||
task.on_update(update);
|
||||
task.on_update(params.get("sessionId").and_then(Value::as_str), update);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -166,6 +166,18 @@ pub struct AgentToolDiff {
|
||||
pub removed: u32,
|
||||
}
|
||||
|
||||
/// What a subagent is doing, as its parent reports it.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum AgentSubagentStatus {
|
||||
Running,
|
||||
/// One of its requests (a permission) waits for an answer.
|
||||
NeedsInput,
|
||||
Done,
|
||||
Cancelled,
|
||||
Failed,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AgentPlanItem {
|
||||
@@ -218,6 +230,33 @@ pub enum AgentEntry {
|
||||
Plan {
|
||||
items: Vec<AgentPlanItem>,
|
||||
},
|
||||
/// A subagent the agent started: its own session, whose entries are listed separately.
|
||||
Subagent {
|
||||
/// The subagent's session id.
|
||||
id: String,
|
||||
title: Option<String>,
|
||||
description: Option<String>,
|
||||
status: AgentSubagentStatus,
|
||||
error: Option<String>,
|
||||
/// What it was asked to do, last.
|
||||
prompt: Option<String>,
|
||||
/// Its answer.
|
||||
reply: Option<String>,
|
||||
/// Tool calls it made.
|
||||
tools: u32,
|
||||
/// Title of its latest tool call.
|
||||
activity: Option<String>,
|
||||
started_ms: f64,
|
||||
/// When it last stopped working; `None` while it works.
|
||||
ended_ms: Option<f64>,
|
||||
/// Whether it can be stopped on its own.
|
||||
cancellable: bool,
|
||||
},
|
||||
/// A message between sessions in a subagent's conversation: its task (`incoming`) or its report.
|
||||
Message {
|
||||
incoming: bool,
|
||||
text: String,
|
||||
},
|
||||
Permission {
|
||||
id: u32,
|
||||
title: String,
|
||||
@@ -279,9 +318,10 @@ pub enum AgentEvent {
|
||||
Removed {
|
||||
id: String,
|
||||
},
|
||||
/// Entry `index` of `task` was added or replaced.
|
||||
/// Entry `index` of `task` (of its subagent `subagent`) was added or replaced.
|
||||
Entry {
|
||||
task: String,
|
||||
subagent: Option<String>,
|
||||
index: u32,
|
||||
entry: AgentEntry,
|
||||
},
|
||||
|
||||
+122
-13
@@ -25,6 +25,44 @@ impl FakeAgent {
|
||||
}
|
||||
}
|
||||
|
||||
impl FakeAgent {
|
||||
/// Runs a subagent in session `c1`, as an agent with ACP subagent sessions reports it.
|
||||
fn delegate(&self, root: &str) {
|
||||
let sub = |state: Value| json!({"sessionUpdate": "subagent_update", "sessionId": "c1", "state": state});
|
||||
let mut announce = sub(json!({"state": "running"}));
|
||||
announce["title"] = json!("Explore");
|
||||
self.update(root, announce);
|
||||
self.update(
|
||||
root,
|
||||
json!({"sessionUpdate": "session_message", "messageId": "m1", "senderSessionId": root,
|
||||
"recipientSessionId": "c1", "content": [{"type": "text", "text": "Find the lexer"}]}),
|
||||
);
|
||||
self.update("c1", json!({"sessionUpdate": "usage_update", "used": 900, "size": 1000}));
|
||||
self.update(
|
||||
"c1",
|
||||
json!({"sessionUpdate": "tool_call", "toolCallId": "c1-t1", "title": "Read lexer.rs",
|
||||
"kind": "read", "status": "completed"}),
|
||||
);
|
||||
let peer = self.peer.get().unwrap().clone();
|
||||
let _: Value = tokio::runtime::Handle::current()
|
||||
.block_on(peer.request(
|
||||
"session/request_permission",
|
||||
json!({"sessionId": "c1", "toolCall": {"toolCallId": "c1-t2", "title": "Run ls"},
|
||||
"options": [{"optionId": "yes", "name": "Allow", "kind": "allow_once"}]}),
|
||||
None,
|
||||
))
|
||||
.unwrap();
|
||||
self.say("c1", "It is in lexer.rs");
|
||||
self.update(root, sub(json!({"state": "idle", "stopReason": "end_turn"})));
|
||||
self.update(
|
||||
root,
|
||||
json!({"sessionUpdate": "session_message", "messageId": "m2", "senderSessionId": "c1",
|
||||
"recipientSessionId": root, "content": [{"type": "text", "text": "lexer.rs"}]}),
|
||||
);
|
||||
self.say(root, "Found it");
|
||||
}
|
||||
}
|
||||
|
||||
impl Handler for FakeAgent {
|
||||
fn on_notification(&self, method: &str, _: Value) {
|
||||
self.calls.lock().push(method.to_owned());
|
||||
@@ -34,7 +72,12 @@ impl Handler for FakeAgent {
|
||||
self.calls.lock().push(method.to_owned());
|
||||
let session = params.get("sessionId").and_then(Value::as_str).unwrap_or("s1").to_owned();
|
||||
match method {
|
||||
"initialize" => Ok(json!({"protocolVersion": 1, "agentCapabilities": {"loadSession": true}})),
|
||||
"initialize" => {
|
||||
if params.pointer("/clientCapabilities/subagents").is_some_and(Value::is_object) {
|
||||
self.calls.lock().push("subagents".into());
|
||||
}
|
||||
Ok(json!({"protocolVersion": 1, "agentCapabilities": {"loadSession": true}}))
|
||||
}
|
||||
"session/new" => Ok(json!({"sessionId": "s1",
|
||||
"modes": {"currentModeId": "ask", "availableModes": [{"id": "ask", "name": "Ask"}, {"id": "code", "name": "Code"}]},
|
||||
"configOptions": [{"id": "model", "category": "model", "type": "select", "currentValue": "big",
|
||||
@@ -53,6 +96,10 @@ impl Handler for FakeAgent {
|
||||
self.say(&session, "second turn");
|
||||
return Ok(json!({"stopReason": "end_turn"}));
|
||||
}
|
||||
if text == "delegate" {
|
||||
self.delegate(&session);
|
||||
return Ok(json!({"stopReason": "end_turn"}));
|
||||
}
|
||||
if text == "wait" {
|
||||
// Works until cancelled.
|
||||
while !self.calls.lock().iter().any(|c| c == "session/cancel") {
|
||||
@@ -194,7 +241,7 @@ async fn a_task_runs_asks_for_permission_and_survives_a_restart() {
|
||||
wait_for(&mgr, &task.id, AgentStatus::NeedsInput).await;
|
||||
|
||||
let permission = mgr
|
||||
.entries(&task.id)
|
||||
.entries(&task.id, None)
|
||||
.unwrap()
|
||||
.iter()
|
||||
.find_map(|e| match e {
|
||||
@@ -205,7 +252,7 @@ async fn a_task_runs_asks_for_permission_and_survives_a_restart() {
|
||||
mgr.respond(&task.id, permission, Some("yes".into())).unwrap();
|
||||
wait_for(&mgr, &task.id, AgentStatus::Ready).await;
|
||||
|
||||
let entries = mgr.entries(&task.id).unwrap();
|
||||
let entries = mgr.entries(&task.id, None).unwrap();
|
||||
let kinds: Vec<String> = entries
|
||||
.iter()
|
||||
.map(|e| match e {
|
||||
@@ -261,13 +308,13 @@ async fn a_task_runs_asks_for_permission_and_survives_a_restart() {
|
||||
mgr.shutdown();
|
||||
let agent2 = Arc::new(FakeAgent::default());
|
||||
let mgr2 = manager(tmp.path(), agent2.clone());
|
||||
assert_eq!(mgr2.entries(&task.id).unwrap().len(), 5);
|
||||
assert_eq!(mgr2.entries(&task.id, None).unwrap().len(), 5);
|
||||
assert_eq!(mgr2.task(&task.id).unwrap().state.lock().task.title, "Lexer fix");
|
||||
assert_eq!(mgr2.providers().iter().find(|p| p.id == "claude").unwrap().config.len(), 2);
|
||||
mgr2.prompt(&task.id, text("again"), false).await.unwrap();
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
wait_for(&mgr2, &task.id, AgentStatus::Ready).await;
|
||||
let entries = mgr2.entries(&task.id).unwrap();
|
||||
let entries = mgr2.entries(&task.id, None).unwrap();
|
||||
assert_eq!(entries.len(), 7);
|
||||
assert_eq!(entries[6], AgentEntry::Agent { text: "second turn".into() });
|
||||
assert!(agent2.calls.lock().contains(&"session/load".to_owned()));
|
||||
@@ -303,14 +350,14 @@ async fn messages_sent_while_busy_are_queued_and_send_now_interrupts() {
|
||||
|
||||
mgr.send_queued_now(&task.id, &queue[0].id).unwrap();
|
||||
for _ in 0..500 {
|
||||
if mgr.entries(&task.id).unwrap().iter().any(|e| *e == AgentEntry::Agent { text: "second turn".into() }) {
|
||||
if mgr.entries(&task.id, None).unwrap().iter().any(|e| *e == AgentEntry::Agent { text: "second turn".into() }) {
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
}
|
||||
wait_for(&mgr, &task.id, AgentStatus::Ready).await;
|
||||
let users: Vec<String> = mgr
|
||||
.entries(&task.id)
|
||||
.entries(&task.id, None)
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.filter_map(|e| match e {
|
||||
@@ -326,7 +373,7 @@ async fn messages_sent_while_busy_are_queued_and_send_now_interrupts() {
|
||||
wait_for(&mgr, &task.id, AgentStatus::Running).await;
|
||||
agent.calls.lock().retain(|c| c != "session/cancel");
|
||||
mgr.prompt(&task.id, text("later"), false).await.unwrap();
|
||||
mgr.cancel(&task.id).unwrap();
|
||||
mgr.cancel(&task.id, None).unwrap();
|
||||
wait_for(&mgr, &task.id, AgentStatus::Ready).await;
|
||||
let queued = mgr.task(&task.id).unwrap().state.lock().task.queue.clone();
|
||||
assert_eq!(queued.len(), 1);
|
||||
@@ -334,6 +381,68 @@ async fn messages_sent_while_busy_are_queued_and_send_now_interrupts() {
|
||||
assert!(mgr.task(&task.id).unwrap().state.lock().task.queue.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn subagents_get_their_own_conversation() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let agent = Arc::new(FakeAgent::default());
|
||||
let mgr = manager(tmp.path(), agent.clone());
|
||||
let task = mgr
|
||||
.create(NewAgentTask {
|
||||
prompt: text("delegate"),
|
||||
provider: "claude".into(),
|
||||
config: vec![],
|
||||
isolation: Isolation::Local,
|
||||
base: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
// The subagent's permission request is answered in the task's conversation.
|
||||
wait_for(&mgr, &task.id, AgentStatus::NeedsInput).await;
|
||||
let (id, title) = mgr
|
||||
.entries(&task.id, None)
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.find_map(|e| match e {
|
||||
AgentEntry::Permission { id, title, .. } => Some((id, title)),
|
||||
_ => None,
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(title, "Explore: Run ls");
|
||||
mgr.respond(&task.id, id, Some("yes".into())).unwrap();
|
||||
wait_for(&mgr, &task.id, AgentStatus::Ready).await;
|
||||
assert!(agent.calls.lock().iter().any(|c| c == "subagents"));
|
||||
|
||||
let check = |mgr: &AgentManager| {
|
||||
let main = mgr.entries(&task.id, None).unwrap();
|
||||
let card = main.iter().find(|e| matches!(e, AgentEntry::Subagent { .. })).unwrap();
|
||||
let AgentEntry::Subagent { title, status, prompt, reply, tools, activity, .. } = card else { unreachable!() };
|
||||
assert_eq!(title.as_deref(), Some("Explore"));
|
||||
assert_eq!(*status, AgentSubagentStatus::Done);
|
||||
assert_eq!((prompt.as_deref(), reply.as_deref()), (Some("Find the lexer"), Some("lexer.rs")));
|
||||
assert_eq!((*tools, activity.as_deref()), (1, Some("Read lexer.rs")));
|
||||
assert!(main.contains(&AgentEntry::Agent { text: "Found it".into() }));
|
||||
assert!(!main.iter().any(|e| matches!(e, AgentEntry::Tool { .. })));
|
||||
let sub = mgr.entries(&task.id, Some("c1".into())).unwrap();
|
||||
assert!(matches!(&sub[0], AgentEntry::Tool { title, .. } if title == "Read lexer.rs"));
|
||||
assert_eq!(sub[1], AgentEntry::Agent { text: "It is in lexer.rs".into() });
|
||||
};
|
||||
// Updates can trail the end of the turn.
|
||||
for _ in 0..500 {
|
||||
if mgr.entries(&task.id, None).unwrap().contains(&AgentEntry::Agent { text: "Found it".into() }) {
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
}
|
||||
check(&mgr);
|
||||
// The subagent's context use is not the task's.
|
||||
assert_ne!(mgr.task(&task.id).unwrap().state.lock().task.usage.as_ref().map(|u| u.used), Some(900));
|
||||
assert!(mgr.entries(&task.id, Some("nope".into())).is_err());
|
||||
|
||||
// Saved with the task.
|
||||
mgr.shutdown();
|
||||
check(&manager(tmp.path(), Arc::new(FakeAgent::default())));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prompts_become_acp_content_blocks() {
|
||||
let p = AgentPrompt {
|
||||
@@ -381,15 +490,15 @@ async fn cancelling_resolves_the_open_permission_and_delete_forgets_the_task() {
|
||||
.await
|
||||
.unwrap();
|
||||
wait_for(&mgr, &task.id, AgentStatus::NeedsInput).await;
|
||||
mgr.cancel(&task.id).unwrap();
|
||||
mgr.cancel(&task.id, None).unwrap();
|
||||
wait_for(&mgr, &task.id, AgentStatus::Ready).await;
|
||||
let entries = mgr.entries(&task.id).unwrap();
|
||||
let entries = mgr.entries(&task.id, None).unwrap();
|
||||
assert!(entries.iter().any(|e| matches!(e, AgentEntry::Permission { chosen: Some(c), .. } if c == "Cancelled")));
|
||||
assert_eq!(entries.last(), Some(&AgentEntry::Agent { text: "chose none".into() }));
|
||||
assert_eq!(mgr.task(&task.id).unwrap().state.lock().task.model.as_deref(), Some("big"), "the agent's default");
|
||||
|
||||
mgr.delete(&task.id, false).await.unwrap();
|
||||
assert!(mgr.entries(&task.id).is_err());
|
||||
assert!(mgr.entries(&task.id, None).is_err());
|
||||
assert!(!tmp.path().join("store").join(format!("{}.json", task.id)).exists());
|
||||
assert!(
|
||||
mgr.create(NewAgentTask {
|
||||
@@ -452,7 +561,7 @@ async fn real_claude_task_in_a_worktree() {
|
||||
let status = mgr.task(&task.id).unwrap().state.lock().task.status;
|
||||
match status {
|
||||
AgentStatus::NeedsInput => {
|
||||
let pending = mgr.entries(&task.id).unwrap().into_iter().rev().find_map(|e| match e {
|
||||
let pending = mgr.entries(&task.id, None).unwrap().into_iter().rev().find_map(|e| match e {
|
||||
AgentEntry::Permission { id, options, chosen: None, .. } => Some((id, options)),
|
||||
_ => None,
|
||||
});
|
||||
@@ -467,7 +576,7 @@ async fn real_claude_task_in_a_worktree() {
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(200)).await;
|
||||
}
|
||||
for e in mgr.entries(&task.id).unwrap() {
|
||||
for e in mgr.entries(&task.id, None).unwrap() {
|
||||
eprintln!("{e:?}");
|
||||
}
|
||||
let t = mgr.task(&task.id).unwrap().state.lock().task.clone();
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! Folds ACP `session/update` notifications into a task's entries.
|
||||
|
||||
use crate::model::{AgentEntry, AgentPlanItem, AgentToolDiff, AgentToolStatus};
|
||||
use crate::model::{AgentEntry, AgentPlanItem, AgentSubagentStatus, AgentToolDiff, AgentToolStatus};
|
||||
use ide_core::IpcPath;
|
||||
use serde_json::Value;
|
||||
use std::collections::HashMap;
|
||||
@@ -13,6 +13,10 @@ pub(crate) struct Timeline {
|
||||
pub entries: Vec<AgentEntry>,
|
||||
tools: HashMap<String, usize>,
|
||||
plan: Option<usize>,
|
||||
/// Subagent cards, by the subagent's session id.
|
||||
cards: HashMap<String, usize>,
|
||||
/// Session messages, by message id.
|
||||
messages: HashMap<String, usize>,
|
||||
}
|
||||
|
||||
/// What a `session/update` changed in the timeline.
|
||||
@@ -31,6 +35,9 @@ impl Timeline {
|
||||
t.tools.insert(id.clone(), i);
|
||||
}
|
||||
AgentEntry::Plan { .. } => t.plan = Some(i),
|
||||
AgentEntry::Subagent { id, .. } => {
|
||||
t.cards.insert(id.clone(), i);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -166,6 +173,119 @@ impl Timeline {
|
||||
}
|
||||
}
|
||||
|
||||
/// Adds or updates the card of subagent `id` from its `subagent_update`; returns the
|
||||
/// card's index and whether the subagent just stopped working.
|
||||
pub fn subagent(&mut self, id: &str, update: &Value, now: f64) -> (usize, bool) {
|
||||
let index = match self.cards.get(id) {
|
||||
Some(&i) => i,
|
||||
None => {
|
||||
let i = self.push(AgentEntry::Subagent {
|
||||
id: id.to_owned(),
|
||||
title: None,
|
||||
description: None,
|
||||
status: AgentSubagentStatus::Running,
|
||||
error: None,
|
||||
prompt: None,
|
||||
reply: None,
|
||||
tools: 0,
|
||||
activity: None,
|
||||
started_ms: now,
|
||||
ended_ms: None,
|
||||
cancellable: false,
|
||||
});
|
||||
self.cards.insert(id.to_owned(), i);
|
||||
i
|
||||
}
|
||||
};
|
||||
let AgentEntry::Subagent { title, description, status, error, ended_ms, cancellable, .. } =
|
||||
&mut self.entries[index]
|
||||
else {
|
||||
return (index, false);
|
||||
};
|
||||
// Patch semantics: an omitted field is unchanged, `null` clears it.
|
||||
if let Some(v) = update.get("title") {
|
||||
*title = v.as_str().map(str::to_owned);
|
||||
}
|
||||
if let Some(v) = update.get("description") {
|
||||
*description = v.as_str().map(str::to_owned);
|
||||
}
|
||||
if let Some(v) = update.get("capabilities") {
|
||||
*cancellable = v.get("cancel").is_some_and(Value::is_object);
|
||||
}
|
||||
let mut stopped = false;
|
||||
if let Some((next, message)) = update.get("state").and_then(subagent_status) {
|
||||
let working = matches!(next, AgentSubagentStatus::Running | AgentSubagentStatus::NeedsInput);
|
||||
stopped = !working && ended_ms.is_none();
|
||||
*ended_ms = if working { None } else { ended_ms.or(Some(now)) };
|
||||
*status = next;
|
||||
*error = message;
|
||||
}
|
||||
(index, stopped)
|
||||
}
|
||||
|
||||
/// Sets (or with `append`, extends) what subagent `id` was asked or answered, on its card.
|
||||
pub fn card_message(&mut self, id: &str, reply: bool, text: &str, append: bool) -> Option<usize> {
|
||||
let &index = self.cards.get(id)?;
|
||||
let AgentEntry::Subagent { prompt, reply: answer, .. } = &mut self.entries[index] else { return None };
|
||||
let field = if reply { answer } else { prompt };
|
||||
match field {
|
||||
Some(t) if append => t.push_str(text),
|
||||
_ => *field = Some(text.to_owned()),
|
||||
}
|
||||
Some(index)
|
||||
}
|
||||
|
||||
/// Adds, replaces or (with `append`) extends session message `id`.
|
||||
pub fn message(&mut self, id: &str, incoming: bool, text: &str, append: bool) -> usize {
|
||||
if let Some(&i) = self.messages.get(id)
|
||||
&& let AgentEntry::Message { text: t, .. } = &mut self.entries[i]
|
||||
{
|
||||
if append {
|
||||
t.push_str(text);
|
||||
} else {
|
||||
*t = text.to_owned();
|
||||
}
|
||||
return i;
|
||||
}
|
||||
let i = self.push(AgentEntry::Message { incoming, text: text.to_owned() });
|
||||
self.messages.insert(id.to_owned(), i);
|
||||
i
|
||||
}
|
||||
|
||||
/// Updates subagent `id`'s card with what its own timeline `of` shows: how many tool
|
||||
/// calls it made and its latest one. Returns the card's index when it changed.
|
||||
pub fn card_activity(&mut self, id: &str, of: &Timeline) -> Option<usize> {
|
||||
let &index = self.cards.get(id)?;
|
||||
let AgentEntry::Subagent { tools, activity, .. } = &mut self.entries[index] else { return None };
|
||||
let calls = of.entries.iter().filter_map(|e| match e {
|
||||
AgentEntry::Tool { title, kind, .. } => Some(if title.is_empty() { kind } else { title }),
|
||||
_ => None,
|
||||
});
|
||||
let count = calls.clone().count() as u32;
|
||||
let latest = calls.clone().next_back().cloned();
|
||||
if *tools == count && *activity == latest {
|
||||
return None;
|
||||
}
|
||||
*tools = count;
|
||||
*activity = latest;
|
||||
Some(index)
|
||||
}
|
||||
|
||||
/// Marks the subagents still working as stopped (their agent is gone).
|
||||
pub fn stop_subagents(&mut self, now: f64) -> Vec<usize> {
|
||||
let mut changed = Vec::new();
|
||||
for (i, e) in self.entries.iter_mut().enumerate() {
|
||||
if let AgentEntry::Subagent { status, ended_ms, .. } = e
|
||||
&& matches!(status, AgentSubagentStatus::Running | AgentSubagentStatus::NeedsInput)
|
||||
{
|
||||
*status = AgentSubagentStatus::Cancelled;
|
||||
*ended_ms = Some(now);
|
||||
changed.push(i);
|
||||
}
|
||||
}
|
||||
changed
|
||||
}
|
||||
|
||||
/// Settles what an ended turn left open: running tools and pending permissions,
|
||||
/// and records when the turn ended (`done`) on the message that started it.
|
||||
pub fn end_turn(&mut self, done: Option<f64>) -> Vec<usize> {
|
||||
@@ -198,7 +318,30 @@ fn str_field(v: &Value, key: &str) -> String {
|
||||
v.get(key).and_then(Value::as_str).unwrap_or_default().to_owned()
|
||||
}
|
||||
|
||||
fn content_text(content: Option<&Value>) -> Option<String> {
|
||||
/// The status a subagent `state` reports, with its error message; `None` for an unknown state.
|
||||
fn subagent_status(state: &Value) -> Option<(AgentSubagentStatus, Option<String>)> {
|
||||
Some(match state.get("state")?.as_str()? {
|
||||
"running" => (AgentSubagentStatus::Running, None),
|
||||
"requires_action" => (AgentSubagentStatus::NeedsInput, None),
|
||||
"idle" => match state.get("stopReason").and_then(Value::as_str) {
|
||||
Some("cancelled") => (AgentSubagentStatus::Cancelled, None),
|
||||
Some("error") => (
|
||||
AgentSubagentStatus::Failed,
|
||||
state.pointer("/error/message").and_then(Value::as_str).map(str::to_owned),
|
||||
),
|
||||
_ => (AgentSubagentStatus::Done, None),
|
||||
},
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// The text of a list of content blocks, one per line.
|
||||
pub(crate) fn blocks_text(content: Option<&Value>) -> String {
|
||||
let blocks = content.and_then(Value::as_array).map(Vec::as_slice).unwrap_or_default();
|
||||
blocks.iter().filter_map(|b| content_text(Some(b))).collect::<Vec<_>>().join("\n")
|
||||
}
|
||||
|
||||
pub(crate) fn content_text(content: Option<&Value>) -> Option<String> {
|
||||
let c = content?;
|
||||
match c.get("type").and_then(Value::as_str) {
|
||||
Some("text") => c.get("text").and_then(Value::as_str).map(str::to_owned),
|
||||
|
||||
@@ -28,8 +28,9 @@ pub async fn commands(
|
||||
host.project(window)?.agents.commands(&provider, task.as_deref()).await
|
||||
}
|
||||
|
||||
pub fn entries(host: &Host, window: &str, task: String) -> IdeResult<Vec<AgentEntry>> {
|
||||
host.project(window)?.agents.entries(&task)
|
||||
/// Entries of `task`'s conversation, or of its subagent `subagent`'s.
|
||||
pub fn entries(host: &Host, window: &str, task: String, subagent: Option<String>) -> IdeResult<Vec<AgentEntry>> {
|
||||
host.project(window)?.agents.entries(&task, subagent)
|
||||
}
|
||||
|
||||
pub async fn create(host: &Host, window: &str, request: NewAgentTask) -> IdeResult<AgentTask> {
|
||||
@@ -56,8 +57,9 @@ pub async fn set_config(host: &Host, window: &str, task: String, option: String,
|
||||
host.project(window)?.agents.set_config(&task, &option, value).await
|
||||
}
|
||||
|
||||
pub fn cancel(host: &Host, window: &str, task: String) -> IdeResult<()> {
|
||||
host.project(window)?.agents.cancel(&task)
|
||||
/// Stops the task's current turn, or with `subagent` only that subagent's work.
|
||||
pub fn cancel(host: &Host, window: &str, task: String, subagent: Option<String>) -> IdeResult<()> {
|
||||
host.project(window)?.agents.cancel(&task, subagent)
|
||||
}
|
||||
|
||||
/// Answers a permission request; `option: None` cancels it.
|
||||
@@ -87,7 +89,7 @@ pub(crate) fn register(d: &mut Dispatcher) {
|
||||
d.add("agent_subscribe", |c| async move { subscribe(&c.host, &c.window, c.channel("onEvent")?) });
|
||||
d.add("agent_providers", |c| async move { providers(&c.host, &c.window) });
|
||||
d.add("agent_commands", |c| async move { commands(&c.host, &c.window, c.arg("provider")?, c.arg("task")?).await });
|
||||
d.add("agent_entries", |c| async move { entries(&c.host, &c.window, c.arg("task")?) });
|
||||
d.add("agent_entries", |c| async move { entries(&c.host, &c.window, c.arg("task")?, c.arg("subagent")?) });
|
||||
d.add("agent_create", |c| async move { create(&c.host, &c.window, c.arg("request")?).await });
|
||||
d.add("agent_prompt", |c| async move {
|
||||
prompt(&c.host, &c.window, c.arg("task")?, c.arg("prompt")?, c.arg("now")?).await
|
||||
@@ -100,7 +102,7 @@ pub(crate) fn register(d: &mut Dispatcher) {
|
||||
d.add("agent_set_config", |c| async move {
|
||||
set_config(&c.host, &c.window, c.arg("task")?, c.arg("option")?, c.arg("value")?).await
|
||||
});
|
||||
d.add("agent_cancel", |c| async move { cancel(&c.host, &c.window, c.arg("task")?) });
|
||||
d.add("agent_cancel", |c| async move { cancel(&c.host, &c.window, c.arg("task")?, c.arg("subagent")?) });
|
||||
d.add("agent_respond", |c| async move {
|
||||
respond(&c.host, &c.window, c.arg("task")?, c.arg("permission")?, c.arg("option")?)
|
||||
});
|
||||
|
||||
@@ -46,10 +46,16 @@ pub async fn agent_commands(
|
||||
agent::commands(&state.host, window.label(), provider, task).await
|
||||
}
|
||||
|
||||
/// Entries of `task`'s conversation, or of its subagent `subagent`'s.
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub fn agent_entries(window: Window, state: State<'_, AppState>, task: String) -> IdeResult<Vec<AgentEntry>> {
|
||||
agent::entries(&state.host, window.label(), task)
|
||||
pub fn agent_entries(
|
||||
window: Window,
|
||||
state: State<'_, AppState>,
|
||||
task: String,
|
||||
subagent: Option<String>,
|
||||
) -> IdeResult<Vec<AgentEntry>> {
|
||||
agent::entries(&state.host, window.label(), task, subagent)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -108,10 +114,16 @@ pub async fn agent_set_config(
|
||||
agent::set_config(&state.host, window.label(), task, option, value).await
|
||||
}
|
||||
|
||||
/// Stops the task's current turn, or with `subagent` only that subagent's work.
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub fn agent_cancel(window: Window, state: State<'_, AppState>, task: String) -> IdeResult<()> {
|
||||
agent::cancel(&state.host, window.label(), task)
|
||||
pub fn agent_cancel(
|
||||
window: Window,
|
||||
state: State<'_, AppState>,
|
||||
task: String,
|
||||
subagent: Option<String>,
|
||||
) -> IdeResult<()> {
|
||||
agent::cancel(&state.host, window.label(), task, subagent)
|
||||
}
|
||||
|
||||
/// Answers a permission request; `option: null` cancels it.
|
||||
|
||||
+30
-5
@@ -61,7 +61,8 @@ export const commands = {
|
||||
* before a task exists (skills and custom commands on disk included).
|
||||
*/
|
||||
agentCommands: (provider: string, task: string | null) => typedError<AgentCommand[], IdeError>(__TAURI_INVOKE("agent_commands", { provider, task })),
|
||||
agentEntries: (task: string) => typedError<AgentEntry[], IdeError>(__TAURI_INVOKE("agent_entries", { task })),
|
||||
/** Entries of `task`'s conversation, or of its subagent `subagent`'s. */
|
||||
agentEntries: (task: string, subagent: string | null) => typedError<AgentEntry[], IdeError>(__TAURI_INVOKE("agent_entries", { task, subagent })),
|
||||
agentCreate: (request: NewAgentTask) => typedError<AgentTask, IdeError>(__TAURI_INVOKE("agent_create", { request })),
|
||||
/** Sends a message; while the agent works it is queued, or with `now` it interrupts the turn. */
|
||||
agentPrompt: (task: string, prompt: AgentPrompt, now: boolean) => typedError<null, IdeError>(__TAURI_INVOKE("agent_prompt", { task, prompt, now })),
|
||||
@@ -71,7 +72,8 @@ export const commands = {
|
||||
agentSendQueuedNow: (task: string, queued: string) => typedError<null, IdeError>(__TAURI_INVOKE("agent_send_queued_now", { task, queued })),
|
||||
/** Changes a session setting (model, mode, thinking effort…). */
|
||||
agentSetConfig: (task: string, option: string, value: string) => typedError<null, IdeError>(__TAURI_INVOKE("agent_set_config", { task, option, value })),
|
||||
agentCancel: (task: string) => typedError<null, IdeError>(__TAURI_INVOKE("agent_cancel", { task })),
|
||||
/** Stops the task's current turn, or with `subagent` only that subagent's work. */
|
||||
agentCancel: (task: string, subagent: string | null) => typedError<null, IdeError>(__TAURI_INVOKE("agent_cancel", { task, subagent })),
|
||||
/** Answers a permission request; `option: null` cancels it. */
|
||||
agentRespond: (task: string, permission: number, option: string | null) => typedError<null, IdeError>(__TAURI_INVOKE("agent_respond", { task, permission, option })),
|
||||
agentArchive: (task: string) => typedError<null, IdeError>(__TAURI_INVOKE("agent_archive", { task })),
|
||||
@@ -1001,7 +1003,25 @@ doneMs?: number | null } | { type: "agent"; text: string } | { type: "thought";
|
||||
/** ACP tool kind: `read`, `edit`, `execute`, `search`, `fetch`, `think`… */
|
||||
kind: string; status: AgentToolStatus; locations: IpcPath[];
|
||||
/** Text the tool reported (command output…), truncated. */
|
||||
output: string | null; diffs: AgentToolDiff[] } | { type: "plan"; items: AgentPlanItem[] } | { type: "permission"; id: number; title: string; options: AgentPermissionOption[];
|
||||
output: string | null; diffs: AgentToolDiff[] } | { type: "plan"; items: AgentPlanItem[] } |
|
||||
/** A subagent the agent started: its own session, whose entries are listed separately. */
|
||||
{ type: "subagent";
|
||||
/** The subagent's session id. */
|
||||
id: string; title: string | null; description: string | null; status: AgentSubagentStatus; error: string | null;
|
||||
/** What it was asked to do, last. */
|
||||
prompt: string | null;
|
||||
/** Its answer. */
|
||||
reply: string | null;
|
||||
/** Tool calls it made. */
|
||||
tools: number;
|
||||
/** Title of its latest tool call. */
|
||||
activity: string | null; startedMs: number | null;
|
||||
/** When it last stopped working; `None` while it works. */
|
||||
endedMs: number | null;
|
||||
/** Whether it can be stopped on its own. */
|
||||
cancellable: boolean } |
|
||||
/** A message between sessions in a subagent's conversation: its task (`incoming`) or its report. */
|
||||
{ type: "message"; incoming: boolean; text: string } | { type: "permission"; id: number; title: string; options: AgentPermissionOption[];
|
||||
/** Name of the chosen option, or `None` while pending. Cancelled requests read "Cancelled". */
|
||||
chosen: string | null } | { type: "error"; message: string };
|
||||
|
||||
@@ -1009,8 +1029,8 @@ chosen: string | null } | { type: "error"; message: string };
|
||||
export type AgentEvent =
|
||||
/** A task was created or its metadata changed. */
|
||||
{ type: "task"; task: AgentTask } | { type: "removed"; id: string } |
|
||||
/** Entry `index` of `task` was added or replaced. */
|
||||
{ type: "entry"; task: string; index: number; entry: AgentEntry };
|
||||
/** Entry `index` of `task` (of its subagent `subagent`) was added or replaced. */
|
||||
{ type: "entry"; task: string; subagent: string | null; index: number; entry: AgentEntry };
|
||||
|
||||
export type AgentImage = {
|
||||
mimeType: string,
|
||||
@@ -1065,6 +1085,11 @@ export type AgentStatus =
|
||||
/** The turn ended; the result can be reviewed or the conversation continued. */
|
||||
"ready" | "failed" | "archived";
|
||||
|
||||
/** What a subagent is doing, as its parent reports it. */
|
||||
export type AgentSubagentStatus = "running" |
|
||||
/** One of its requests (a permission) waits for an answer. */
|
||||
"needsInput" | "done" | "cancelled" | "failed";
|
||||
|
||||
export type AgentTask = {
|
||||
id: string,
|
||||
title: string,
|
||||
|
||||
@@ -550,3 +550,185 @@
|
||||
.removed {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
/* Subagents: a card per subagent in its parent's conversation, and the way into its own. */
|
||||
.subagent {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding: 6px 8px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-small);
|
||||
background: var(--bg-elev);
|
||||
}
|
||||
.subagentHead {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
}
|
||||
.subagentOpen {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 2px 4px;
|
||||
margin: -2px -4px;
|
||||
border: 0;
|
||||
border-radius: var(--radius-row);
|
||||
background: none;
|
||||
color: var(--fg);
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
.subagentOpen:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
.subagentTitle {
|
||||
flex: none;
|
||||
max-width: 50%;
|
||||
font-weight: 600;
|
||||
color: var(--fg-strong);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.subagentDescription {
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
color: var(--fg-muted);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.subagentMeta {
|
||||
flex: none;
|
||||
margin-left: auto;
|
||||
color: var(--fg-subtle);
|
||||
font-size: var(--fs-small);
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.subagentActivity {
|
||||
padding-left: 22px;
|
||||
color: var(--fg-muted);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--fs-small);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.subagentSection > summary {
|
||||
padding-left: 22px;
|
||||
color: var(--fg-muted);
|
||||
font-size: var(--fs-small);
|
||||
cursor: pointer;
|
||||
list-style: none;
|
||||
}
|
||||
.subagentSection > summary::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
.subagentSection > summary:hover {
|
||||
color: var(--fg);
|
||||
}
|
||||
.subagentSection > :not(summary) {
|
||||
margin: 4px 0 2px 22px;
|
||||
}
|
||||
.subagentReply {
|
||||
max-height: 320px;
|
||||
overflow: auto;
|
||||
}
|
||||
.done {
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.subagentBar {
|
||||
flex: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
height: 30px;
|
||||
padding: 0 8px 0 6px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
min-width: 0;
|
||||
}
|
||||
.crumbs {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.crumb {
|
||||
flex: 0 1 auto;
|
||||
min-width: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: none;
|
||||
color: var(--link);
|
||||
font: inherit;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
cursor: pointer;
|
||||
}
|
||||
.crumb:hover {
|
||||
color: var(--link-hover);
|
||||
text-decoration: underline;
|
||||
}
|
||||
.crumbCurrent {
|
||||
flex: 0 1 auto;
|
||||
min-width: 0;
|
||||
font-weight: 600;
|
||||
color: var(--fg-strong);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.messageFrom {
|
||||
margin-bottom: 2px;
|
||||
color: var(--fg-subtle);
|
||||
font-size: var(--fs-small);
|
||||
}
|
||||
|
||||
.readOnly {
|
||||
flex: none;
|
||||
padding: 10px 16px 14px;
|
||||
border-top: 1px solid var(--border);
|
||||
color: var(--fg-muted);
|
||||
}
|
||||
|
||||
.subagentStrip {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
}
|
||||
.subagentChip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
max-width: 240px;
|
||||
height: 22px;
|
||||
padding: 0 8px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 11px;
|
||||
background: var(--bg-elev);
|
||||
color: var(--fg-secondary);
|
||||
font: inherit;
|
||||
font-size: var(--fs-small);
|
||||
cursor: pointer;
|
||||
}
|
||||
.subagentChip:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--fg);
|
||||
}
|
||||
.subagentChipTitle {
|
||||
min-width: 0;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
/** An agent task as an editor tab: the conversation, a composer and the task's changed files. */
|
||||
import { useEffect, useLayoutEffect, useMemo, useRef, useState, type RefObject } from "react";
|
||||
import { Fragment, useEffect, useLayoutEffect, useMemo, useRef, useState, type RefObject } from "react";
|
||||
import clsx from "clsx";
|
||||
import { useShallow } from "zustand/react/shallow";
|
||||
import type { AgentChange, AgentEntry, AgentTask, AgentUsage } from "@/bindings";
|
||||
import { executeAction } from "@/platform/actions";
|
||||
import { call, commands, onIdeEvent } from "@/platform/ipc";
|
||||
@@ -28,7 +29,18 @@ import { canSend, emptyDraft, usageLabel, workTime } from "./composerModel";
|
||||
import { ConfigPickers } from "./ConfigPickers";
|
||||
import { findRanges, formatDuration, formatSentAt } from "./conversation";
|
||||
import { Markdown } from "./Markdown";
|
||||
import { branchLabel, isBusy, providerIcon, statusLabel } from "./model";
|
||||
import {
|
||||
branchLabel,
|
||||
entriesKey,
|
||||
findSubagent,
|
||||
isBusy,
|
||||
providerIcon,
|
||||
statusLabel,
|
||||
subagentStatusLabel,
|
||||
subagentTitle,
|
||||
subagentWorking,
|
||||
type SubagentEntry,
|
||||
} from "./model";
|
||||
import {
|
||||
archiveTask,
|
||||
cancelTask,
|
||||
@@ -56,11 +68,23 @@ export function SessionEditor({ input }: { input: EditorInput }) {
|
||||
const entries = useAgents((s) => s.entries.get(id));
|
||||
const changes = useAgents((s) => s.changes.get(id));
|
||||
const head = useLocalHead(task?.isolation === "local" ? task.status : null);
|
||||
// The subagent conversation shown, with the subagents that lead to it (empty: the task's own).
|
||||
const [path, setPath] = useState<string[]>([]);
|
||||
const view = path.at(-1) ?? null;
|
||||
const viewEntries = useAgents((s) => s.entries.get(entriesKey(id, view)));
|
||||
// Each subagent's card, from its parent's conversation.
|
||||
const cards = useAgents(
|
||||
useShallow((s) => path.map((sub, i) => findSubagent(s.entries.get(entriesKey(id, path[i - 1] ?? null)), sub))),
|
||||
);
|
||||
useEffect(() => {
|
||||
void loadEntries(id).catch(() => undefined);
|
||||
void refreshChanges(id);
|
||||
return markVisible(id);
|
||||
}, [id]);
|
||||
useEffect(() => {
|
||||
if (view) void loadEntries(id, view).catch(() => undefined);
|
||||
}, [id, view]);
|
||||
const openSubagent = (sub: string) => setPath((p) => [...p, sub]);
|
||||
const [finding, setFinding] = useState(false);
|
||||
const [findFocus, setFindFocus] = useState(0);
|
||||
const [session] = useState<FocusedSession>(() => ({
|
||||
@@ -88,15 +112,25 @@ export function SessionEditor({ input }: { input: EditorInput }) {
|
||||
>
|
||||
<div className={styles.conversation}>
|
||||
<Header task={task} entries={entries ?? null} head={head} />
|
||||
{view && <SubagentBar task={task} path={path} cards={cards} onNavigate={setPath} />}
|
||||
<PermissionBanner task={task} entries={entries ?? null} />
|
||||
<Stream
|
||||
key={view ?? ""}
|
||||
task={task}
|
||||
entries={entries ?? null}
|
||||
entries={viewEntries ?? null}
|
||||
subagent={view ? (cards.at(-1) ?? null) : null}
|
||||
onOpenSubagent={openSubagent}
|
||||
finding={finding}
|
||||
findFocus={findFocus}
|
||||
onCloseFind={() => setFinding(false)}
|
||||
/>
|
||||
<SessionComposer task={task} entries={entries ?? null} />
|
||||
{view ? (
|
||||
<div className={styles.readOnly}>
|
||||
<T>Subagents take their instructions from the agent that started them.</T>
|
||||
</div>
|
||||
) : (
|
||||
<SessionComposer task={task} entries={entries ?? null} onOpenSubagent={openSubagent} />
|
||||
)}
|
||||
</div>
|
||||
<ChangesPane task={task} changes={changes ?? null} head={head} />
|
||||
</div>
|
||||
@@ -210,16 +244,70 @@ function useNow(interval: number): number {
|
||||
return now;
|
||||
}
|
||||
|
||||
/** Where a subagent conversation sits: the way back to the task, and the subagent's state. */
|
||||
function SubagentBar({
|
||||
task,
|
||||
path,
|
||||
cards,
|
||||
onNavigate,
|
||||
}: {
|
||||
task: AgentTask;
|
||||
path: string[];
|
||||
cards: Array<SubagentEntry | undefined>;
|
||||
onNavigate: (path: string[]) => void;
|
||||
}) {
|
||||
const tx = useTx();
|
||||
const card = cards.at(-1);
|
||||
return (
|
||||
<div className={styles.subagentBar}>
|
||||
<IconButton icon="arrow-left" title="Back" size="small" onClick={() => onNavigate(path.slice(0, -1))} />
|
||||
<nav className={styles.crumbs} aria-label={tx("Subagent")}>
|
||||
<button type="button" className={styles.crumb} onClick={() => onNavigate([])} title={task.title}>
|
||||
{task.title}
|
||||
</button>
|
||||
{path.map((sub, i) => {
|
||||
const title = cards[i] ? subagentTitle(cards[i]) : tx("Subagent");
|
||||
return (
|
||||
<Fragment key={sub}>
|
||||
<Icon name="chevron-right" size={12} className={styles.muted} />
|
||||
{i === path.length - 1 ? (
|
||||
<span className={styles.crumbCurrent} title={cards[i]?.description ?? title}>
|
||||
{title}
|
||||
</span>
|
||||
) : (
|
||||
<button type="button" className={styles.crumb} onClick={() => onNavigate(path.slice(0, i + 1))}>
|
||||
{title}
|
||||
</button>
|
||||
)}
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
{card && (
|
||||
<span className={styles.muted}>
|
||||
<T>{subagentStatusLabel(card.status)}</T>
|
||||
</span>
|
||||
)}
|
||||
{card && subagentWorking(card.status) && card.cancellable && (
|
||||
<IconButton icon="stop" title="Stop Subagent" size="small" onClick={() => void cancelTask(task.id, card.id)} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface StreamProps {
|
||||
task: AgentTask;
|
||||
entries: AgentEntry[] | null;
|
||||
/** The card of the subagent whose conversation this is; `null`: the task's own. */
|
||||
subagent: SubagentEntry | null;
|
||||
onOpenSubagent: (id: string) => void;
|
||||
finding: boolean;
|
||||
/** Bumped by Find in Conversation: focus the find field again. */
|
||||
findFocus: number;
|
||||
onCloseFind: () => void;
|
||||
}
|
||||
|
||||
function Stream({ task, entries, finding, findFocus, onCloseFind }: StreamProps) {
|
||||
function Stream({ task, entries, subagent, onOpenSubagent, finding, findFocus, onCloseFind }: StreamProps) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const stick = useRef(true);
|
||||
useLayoutEffect(() => {
|
||||
@@ -255,9 +343,16 @@ function Stream({ task, entries, finding, findFocus, onCloseFind }: StreamProps)
|
||||
{entries === null ? (
|
||||
<Spinner />
|
||||
) : (
|
||||
entries.map((entry, i) => <Entry key={i} task={task} entry={entry} />)
|
||||
entries.map((entry, i) => <Entry key={i} task={task} entry={entry} onOpenSubagent={onOpenSubagent} />)
|
||||
)}
|
||||
{(task.status === "running" || task.status === "starting") && !waiting && (
|
||||
{subagent && subagentWorking(subagent.status) && (
|
||||
<div className={styles.working}>
|
||||
<Spinner size={12} />
|
||||
<T>{subagent.status === "needsInput" ? "Waiting for your answer…" : "Working…"}</T>
|
||||
{subagent.startedMs != null && <Elapsed since={subagent.startedMs} />}
|
||||
</div>
|
||||
)}
|
||||
{!subagent && (task.status === "running" || task.status === "starting") && !waiting && (
|
||||
<div className={styles.working}>
|
||||
<Spinner size={12} />
|
||||
<T>{task.status === "starting" ? "Starting the agent…" : "Working…"}</T>
|
||||
@@ -439,7 +534,15 @@ const TOOL_ICONS: Record<string, string> = {
|
||||
think: "todo",
|
||||
};
|
||||
|
||||
function Entry({ task, entry }: { task: AgentTask; entry: AgentEntry }) {
|
||||
function Entry({
|
||||
task,
|
||||
entry,
|
||||
onOpenSubagent,
|
||||
}: {
|
||||
task: AgentTask;
|
||||
entry: AgentEntry;
|
||||
onOpenSubagent: (id: string) => void;
|
||||
}) {
|
||||
switch (entry.type) {
|
||||
case "user":
|
||||
return <UserMessage task={task} entry={entry} />;
|
||||
@@ -470,6 +573,24 @@ function Entry({ task, entry }: { task: AgentTask; entry: AgentEntry }) {
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
case "subagent":
|
||||
return <SubagentCard task={task} entry={entry} onOpen={() => onOpenSubagent(entry.id)} />;
|
||||
case "message":
|
||||
return entry.incoming ? (
|
||||
<div className={styles.message}>
|
||||
<div className={styles.messageFrom}>
|
||||
<T>Task from the parent agent</T>
|
||||
</div>
|
||||
<div className={styles.user}>{entry.text}</div>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<div className={styles.messageFrom}>
|
||||
<T>Report to the parent agent</T>
|
||||
</div>
|
||||
<Markdown text={entry.text} cwd={task.cwd} />
|
||||
</div>
|
||||
);
|
||||
case "permission":
|
||||
return <PermissionLine task={task} entry={entry} />;
|
||||
case "error":
|
||||
@@ -568,6 +689,102 @@ function ToolEntry({ task, entry }: { task: AgentTask; entry: Extract<AgentEntry
|
||||
);
|
||||
}
|
||||
|
||||
function SubagentStatusIcon({ status }: { status: SubagentEntry["status"] }) {
|
||||
switch (status) {
|
||||
case "running":
|
||||
return <Spinner size={11} />;
|
||||
case "needsInput":
|
||||
return <Icon name="warning" size={13} label="Needs input" />;
|
||||
case "done":
|
||||
return <Icon name="check" size={13} className={styles.done} label="Done" />;
|
||||
case "cancelled":
|
||||
return <Icon name="minus" size={13} className={styles.muted} label="Stopped" />;
|
||||
case "failed":
|
||||
return <Icon name="close" size={13} className={styles.removed} label="Failed" />;
|
||||
}
|
||||
}
|
||||
|
||||
/** A subagent in its parent's conversation: what it does, its latest step, what it was asked and answered. */
|
||||
function SubagentCard({ task, entry, onOpen }: { task: AgentTask; entry: SubagentEntry; onOpen: () => void }) {
|
||||
const tx = useTx();
|
||||
const working = subagentWorking(entry.status);
|
||||
const now = useNow(working ? 1000 : 60_000);
|
||||
const time = entry.startedMs == null ? null : (entry.endedMs ?? now) - entry.startedMs;
|
||||
const title = subagentTitle(entry);
|
||||
return (
|
||||
<div className={styles.subagent}>
|
||||
<div className={styles.subagentHead}>
|
||||
<button type="button" className={styles.subagentOpen} onClick={onOpen} title={tx("Open the subagent's conversation")}>
|
||||
<SubagentStatusIcon status={entry.status} />
|
||||
<Icon name="toolwindow-agents" size={14} className={styles.muted} />
|
||||
<span className={styles.subagentTitle}>{title}</span>
|
||||
{entry.description && entry.description !== title && (
|
||||
<span className={styles.subagentDescription}>{entry.description}</span>
|
||||
)}
|
||||
<span className={styles.subagentMeta}>
|
||||
{[
|
||||
entry.tools > 0 && `${entry.tools} ${tx(entry.tools === 1 ? "tool call" : "tool calls")}`,
|
||||
time != null && formatDuration(Math.max(0, time)),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" · ")}
|
||||
</span>
|
||||
<Icon name="chevron-right" size={12} className={styles.muted} />
|
||||
</button>
|
||||
{working && entry.cancellable && (
|
||||
<IconButton icon="stop" title="Stop Subagent" size="small" onClick={() => void cancelTask(task.id, entry.id)} />
|
||||
)}
|
||||
</div>
|
||||
{working && entry.activity && (
|
||||
<div className={styles.subagentActivity} title={entry.activity}>
|
||||
{shortTitle(task, entry.activity)}
|
||||
</div>
|
||||
)}
|
||||
{entry.error && (
|
||||
<div className={styles.error} role="alert">
|
||||
<Icon name="error" size={14} />
|
||||
<span>{entry.error}</span>
|
||||
</div>
|
||||
)}
|
||||
{entry.prompt && (
|
||||
<details className={styles.subagentSection}>
|
||||
<summary>
|
||||
<T>Task</T>
|
||||
</summary>
|
||||
<div className={styles.thoughtText}>{entry.prompt}</div>
|
||||
</details>
|
||||
)}
|
||||
{entry.reply && (
|
||||
<details className={styles.subagentSection}>
|
||||
<summary>
|
||||
<T>Result</T>
|
||||
</summary>
|
||||
<div className={styles.subagentReply}>
|
||||
<Markdown text={entry.reply} cwd={task.cwd} />
|
||||
</div>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** The subagents of the conversation still at work, to look in on. */
|
||||
function WorkingSubagents({ entries, onOpen }: { entries: AgentEntry[] | null; onOpen: (id: string) => void }) {
|
||||
const tx = useTx();
|
||||
const working = (entries ?? []).filter((e): e is SubagentEntry => e.type === "subagent" && subagentWorking(e.status));
|
||||
if (!working.length) return null;
|
||||
return (
|
||||
<div className={styles.subagentStrip} aria-label={tx("Subagents at work")}>
|
||||
{working.map((e) => (
|
||||
<button key={e.id} type="button" className={styles.subagentChip} onClick={() => onOpen(e.id)} title={e.activity ?? undefined}>
|
||||
<SubagentStatusIcon status={e.status} />
|
||||
<span className={styles.subagentChipTitle}>{subagentTitle(e)}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Agent-written titles with the task's own folder dropped from paths. */
|
||||
const shortTitle = (task: AgentTask, title: string) => title.replaceAll(`${task.cwd}/`, "");
|
||||
|
||||
@@ -606,7 +823,15 @@ function PermissionBanner({ task, entries }: { task: AgentTask; entries: AgentEn
|
||||
);
|
||||
}
|
||||
|
||||
function SessionComposer({ task, entries }: { task: AgentTask; entries: AgentEntry[] | null }) {
|
||||
function SessionComposer({
|
||||
task,
|
||||
entries,
|
||||
onOpenSubagent,
|
||||
}: {
|
||||
task: AgentTask;
|
||||
entries: AgentEntry[] | null;
|
||||
onOpenSubagent: (id: string) => void;
|
||||
}) {
|
||||
const project = useProject((s) => s.project);
|
||||
const composer = useAgents((s) => s.composers.get(task.id)) ?? emptyDraft();
|
||||
const [sending, setSending] = useState(false);
|
||||
@@ -630,6 +855,7 @@ function SessionComposer({ task, entries }: { task: AgentTask; entries: AgentEnt
|
||||
);
|
||||
return (
|
||||
<div className={styles.composerArea}>
|
||||
<WorkingSubagents entries={entries} onOpen={onOpenSubagent} />
|
||||
{task.queue.length > 0 && <Queue task={task} />}
|
||||
<Composer
|
||||
id={task.id}
|
||||
|
||||
@@ -16,7 +16,17 @@ import {
|
||||
usageLabel,
|
||||
workTime,
|
||||
} from "./composerModel";
|
||||
import { age, attentionChange, branchLabel, groupTasks, upsertEntry } from "./model";
|
||||
import {
|
||||
age,
|
||||
attentionChange,
|
||||
branchLabel,
|
||||
entriesKey,
|
||||
findSubagent,
|
||||
groupTasks,
|
||||
subagentTitle,
|
||||
upsertEntry,
|
||||
type SubagentEntry,
|
||||
} from "./model";
|
||||
import { markdownHtml } from "./Markdown";
|
||||
|
||||
function task(id: string, status: AgentStatus, updatedMs: number): AgentTask {
|
||||
@@ -185,6 +195,30 @@ describe("agents model", () => {
|
||||
expect(attentionChange(undefined, "ready")).toBeNull();
|
||||
});
|
||||
|
||||
it("finds subagent cards and keys their conversations apart", () => {
|
||||
const card: SubagentEntry = {
|
||||
type: "subagent",
|
||||
id: "c1",
|
||||
title: null,
|
||||
description: "Find the lexer",
|
||||
status: "running",
|
||||
error: null,
|
||||
prompt: null,
|
||||
reply: null,
|
||||
tools: 0,
|
||||
activity: null,
|
||||
startedMs: 0,
|
||||
endedMs: null,
|
||||
cancellable: false,
|
||||
};
|
||||
expect(findSubagent([{ type: "agent", text: "a" }, card], "c1")).toBe(card);
|
||||
expect(findSubagent(undefined, "c1")).toBeUndefined();
|
||||
expect(subagentTitle(card)).toBe("Find the lexer");
|
||||
expect(subagentTitle({ ...card, title: "Explore" })).toBe("Explore");
|
||||
expect(entriesKey("t", null)).toBe("t");
|
||||
expect(entriesKey("t", "c1")).not.toBe(entriesKey("t", null));
|
||||
});
|
||||
|
||||
it("formats labels", () => {
|
||||
expect(branchLabel("agent/fix-lexer")).toBe("fix-lexer");
|
||||
expect(branchLabel(null)).toBe("");
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/** Pure helpers: task grouping, entry updates, labels. */
|
||||
import type { AgentEntry, AgentStatus, AgentTask } from "@/bindings";
|
||||
import type { AgentEntry, AgentStatus, AgentSubagentStatus, AgentTask } from "@/bindings";
|
||||
|
||||
export const SESSION_KIND = "agent-session";
|
||||
export const HOME_KIND = "agent-home";
|
||||
@@ -8,6 +8,35 @@ export const TOOL_WINDOW = "agents";
|
||||
|
||||
export const sessionKey = (task: string) => `agent:${task}`;
|
||||
|
||||
/** Key of a conversation in the entries store: the task's own, or its subagent `subagent`'s. */
|
||||
export const entriesKey = (task: string, subagent: string | null) => (subagent ? `${task}/${subagent}` : task);
|
||||
|
||||
export type SubagentEntry = Extract<AgentEntry, { type: "subagent" }>;
|
||||
|
||||
/** The card of subagent `id` in its parent's `entries`. */
|
||||
export const findSubagent = (entries: AgentEntry[] | undefined, id: string) =>
|
||||
entries?.find((e): e is SubagentEntry => e.type === "subagent" && e.id === id);
|
||||
|
||||
export const subagentWorking = (s: AgentSubagentStatus) => s === "running" || s === "needsInput";
|
||||
|
||||
/** "Explore", else what it was started for. */
|
||||
export const subagentTitle = (e: SubagentEntry) => e.title ?? e.description ?? "Subagent";
|
||||
|
||||
export function subagentStatusLabel(s: AgentSubagentStatus): string {
|
||||
switch (s) {
|
||||
case "running":
|
||||
return "Running";
|
||||
case "needsInput":
|
||||
return "Needs input";
|
||||
case "done":
|
||||
return "Done";
|
||||
case "cancelled":
|
||||
return "Stopped";
|
||||
case "failed":
|
||||
return "Failed";
|
||||
}
|
||||
}
|
||||
|
||||
const PROVIDER_ICONS = new Set(["claude", "codex", "gemini", "copilot", "cursor", "opencode", "qwen", "kimi", "goose", "auggie", "kiro", "vibe"]);
|
||||
|
||||
/** The agent's logo; custom agents get the generic agents icon. */
|
||||
|
||||
@@ -15,7 +15,7 @@ import { call, commands, isTauri } from "@/platform/ipc";
|
||||
import { editorService, notifications } from "@/workbench/contributions";
|
||||
import { canSend, emptyDraft, toPrompt, type ComposerDraft, type FileRef } from "./composerModel";
|
||||
import { withQuote } from "./conversation";
|
||||
import { HOME_KEY, HOME_KIND, SESSION_KIND, attentionChange, isBusy, sessionKey, upsertEntry } from "./model";
|
||||
import { HOME_KEY, HOME_KIND, SESSION_KIND, attentionChange, entriesKey, isBusy, sessionKey, upsertEntry } from "./model";
|
||||
|
||||
/** New Task page settings (its text lives in the `HOME` composer). */
|
||||
export interface Draft {
|
||||
@@ -35,7 +35,7 @@ interface AgentsState {
|
||||
/** Why the tasks could not be loaded. */
|
||||
error: string | null;
|
||||
tasks: Map<string, AgentTask>;
|
||||
/** Entries of the tasks opened in this window. */
|
||||
/** Entries of the tasks (and subagents) opened in this window, by `entriesKey`. */
|
||||
entries: Map<string, AgentEntry[]>;
|
||||
changes: Map<string, AgentChange[]>;
|
||||
providers: AgentProvider[];
|
||||
@@ -170,11 +170,12 @@ function onEvent(e: AgentEvent) {
|
||||
break;
|
||||
}
|
||||
case "entry": {
|
||||
const current = s.entries.get(e.task);
|
||||
const key = entriesKey(e.task, e.subagent);
|
||||
const current = s.entries.get(key);
|
||||
if (!current) return;
|
||||
const next = upsertEntry(current, e.index, e.entry);
|
||||
if (next) useAgents.setState({ entries: new Map(s.entries).set(e.task, next) });
|
||||
else void loadEntries(e.task);
|
||||
if (next) useAgents.setState({ entries: new Map(s.entries).set(key, next) });
|
||||
else void loadEntries(e.task, e.subagent);
|
||||
if (e.entry.type === "tool" && e.entry.status === "completed" && e.entry.diffs.length) {
|
||||
scheduleChanges(e.task);
|
||||
}
|
||||
@@ -200,9 +201,10 @@ function notifyChange(prev: AgentTask | undefined, task: AgentTask) {
|
||||
});
|
||||
}
|
||||
|
||||
export async function loadEntries(task: string): Promise<void> {
|
||||
const entries = await call(commands.agentEntries(task));
|
||||
useAgents.setState((s) => ({ entries: new Map(s.entries).set(task, entries) }));
|
||||
/** Loads the entries of the task's conversation, or of its subagent `subagent`'s. */
|
||||
export async function loadEntries(task: string, subagent: string | null = null): Promise<void> {
|
||||
const entries = await call(commands.agentEntries(task, subagent));
|
||||
useAgents.setState((s) => ({ entries: new Map(s.entries).set(entriesKey(task, subagent), entries) }));
|
||||
}
|
||||
|
||||
export async function refreshChanges(task: string): Promise<void> {
|
||||
@@ -373,7 +375,8 @@ export function addReferences(refs: Array<{ path: string; rel: string; mention:
|
||||
else openHome();
|
||||
}
|
||||
|
||||
export const cancelTask = (task: string) => call(commands.agentCancel(task));
|
||||
/** Stops the task's turn, or with `subagent` only that subagent's work. */
|
||||
export const cancelTask = (task: string, subagent: string | null = null) => call(commands.agentCancel(task, subagent));
|
||||
export const respond = (task: string, permission: number, option: string | null) =>
|
||||
call(commands.agentRespond(task, permission, option));
|
||||
export const archiveTask = (task: string) => call(commands.agentArchive(task));
|
||||
|
||||
@@ -3208,6 +3208,17 @@
|
||||
"New worktree": "新 worktree",
|
||||
"Starting…": "正在启动…",
|
||||
"Waiting for your answer": "等待你的回答",
|
||||
"Waiting for your answer…": "等待你的回答…",
|
||||
"Subagent": "子 agent",
|
||||
"Subagents at work": "工作中的子 agent",
|
||||
"Stop Subagent": "停止子 agent",
|
||||
"Open the subagent's conversation": "打开子 agent 的对话",
|
||||
"Subagents take their instructions from the agent that started them.": "子 agent 只接收启动它的 agent 的指令。",
|
||||
"Task from the parent agent": "父 agent 交给的任务",
|
||||
"Report to the parent agent": "交给父 agent 的报告",
|
||||
"Task": "任务",
|
||||
"tool call": "次工具调用",
|
||||
"tool calls": "次工具调用",
|
||||
"The agent stopped": "Agent 已停止",
|
||||
"Not installed": "未安装",
|
||||
"{command} was not found on PATH": "在 PATH 中找不到 {command}",
|
||||
|
||||
Reference in new issue
Block a user