дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-08 19:02:17 +02:00
feat(agents): rename tasks, roll back to a fresh session, and show Codex questions as one card (#2065)
Tasks can be renamed from the list and the tab; an explicitly submitted name is kept. Rerunning the first turn asks before clearing the conversation and resets nested subagent history. Codex questions use the shared question card and keep their ids, so identical questions keep separate answers. Co-authored-by: panda-student <[email protected]>
Цей коміт міститься в:
1 джерело
8a563bd185
коміт
9f77f46509
19 files changed
+420
-43
No files matched your search
@@ -218,6 +218,9 @@ struct TaskFile {
|
||||
/// Turns to drop from the session once it is loaded (a fork of an earlier point).
|
||||
#[serde(default, skip_serializing_if = "is_zero")]
|
||||
pending_rollback: u32,
|
||||
/// The user named the task: its agent's own title updates no longer replace it.
|
||||
#[serde(default, skip_serializing_if = "is_false")]
|
||||
title_renamed: bool,
|
||||
}
|
||||
|
||||
struct TaskState {
|
||||
@@ -227,6 +230,8 @@ struct TaskState {
|
||||
wanted: Vec<AgentConfigChoice>,
|
||||
before: Arc<BTreeMap<String, worktree::Stamp>>,
|
||||
pending_rollback: u32,
|
||||
/// The user named the task; see `TaskFile::title_renamed`.
|
||||
title_renamed: bool,
|
||||
/// A turn is running or about to (prompts arriving now are queued).
|
||||
busy: bool,
|
||||
/// Background tasks the agent reported as running (`run_in_background` commands and
|
||||
@@ -523,6 +528,7 @@ impl AgentManager {
|
||||
before,
|
||||
subagents: Vec::new(),
|
||||
pending_rollback: 0,
|
||||
title_renamed: false,
|
||||
};
|
||||
let handle = Task::restore(file, self.shared.clone());
|
||||
handle.state.lock().busy = true;
|
||||
@@ -726,6 +732,7 @@ impl AgentManager {
|
||||
before: (*s.before).clone(),
|
||||
subagents: s.conversation.saved_subagents(),
|
||||
pending_rollback: total - keep,
|
||||
title_renamed: s.title_renamed,
|
||||
}
|
||||
};
|
||||
let task = Task::restore(file, self.shared.clone());
|
||||
@@ -744,12 +751,38 @@ impl AgentManager {
|
||||
if task.state.lock().busy {
|
||||
return Err(IdeError::invalid("wait until the agent is done"));
|
||||
}
|
||||
let starts = turn_starts(&task.state.lock().conversation.main.entries);
|
||||
let Some(&cut) = starts.get(turns as usize) else { return Ok(None) };
|
||||
// Dropping every turn starts the conversation over, which needs no agent-side
|
||||
// rewind — and some agents cannot rewind past their first turn (a resumed
|
||||
// Claude Code session). A fresh session avoids both.
|
||||
if turns == 0 {
|
||||
task.kill();
|
||||
let first = {
|
||||
let mut s = task.state.lock();
|
||||
let first = match s.conversation.main.entries.get(cut) {
|
||||
Some(AgentEntry::User { text, .. }) => {
|
||||
Some(AgentPrompt { text: text.clone(), ..Default::default() })
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
s.session_id = None;
|
||||
s.conversation = Conversation::default();
|
||||
// The fresh session reports its own usage; nothing carries over.
|
||||
s.task.usage = None;
|
||||
s.pending_rollback = 0;
|
||||
s.task.updated_ms = now_ms();
|
||||
first
|
||||
};
|
||||
task.persist();
|
||||
self.shared.emit(AgentEvent::Truncated { task: id.to_owned(), length: 0 });
|
||||
task.emit_task();
|
||||
return Ok(first);
|
||||
}
|
||||
let live = task.ensure_session().await.map_err(IdeError::internal)?;
|
||||
if !live.capabilities.rollback {
|
||||
return Err(IdeError::invalid("this agent can't undo turns"));
|
||||
}
|
||||
let starts = turn_starts(&task.state.lock().conversation.main.entries);
|
||||
let Some(&cut) = starts.get(turns as usize) else { return Ok(None) };
|
||||
let drop = (starts.len() - turns as usize) as u32;
|
||||
let _: Value = live
|
||||
.peer
|
||||
@@ -910,6 +943,28 @@ impl AgentManager {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Renames the task; the agent's own later title updates keep the user's choice.
|
||||
pub fn rename(&self, id: &str, title: &str) -> IdeResult<()> {
|
||||
let title = title.trim();
|
||||
if title.is_empty() {
|
||||
return Err(IdeError::invalid("the title is empty"));
|
||||
}
|
||||
let title: String = title.chars().take(200).collect();
|
||||
let task = self.task(id)?;
|
||||
{
|
||||
let mut s = task.state.lock();
|
||||
if s.task.title == title && s.title_renamed {
|
||||
return Ok(());
|
||||
}
|
||||
s.task.title = title;
|
||||
s.title_renamed = true;
|
||||
s.task.updated_ms = now_ms();
|
||||
}
|
||||
task.persist();
|
||||
task.emit_task();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Forgets the task; with `remove_worktree` also deletes its worktree (the branch stays).
|
||||
pub async fn delete(&self, id: &str, remove_worktree: bool) -> IdeResult<()> {
|
||||
let task = self.task(id)?;
|
||||
@@ -980,6 +1035,7 @@ impl Task {
|
||||
wanted: file.wanted,
|
||||
before: Arc::new(file.before),
|
||||
pending_rollback: file.pending_rollback,
|
||||
title_renamed: file.title_renamed,
|
||||
busy: false,
|
||||
background_tasks: Vec::new(),
|
||||
}),
|
||||
@@ -1091,6 +1147,7 @@ impl Task {
|
||||
before: (*s.before).clone(),
|
||||
subagents: s.conversation.saved_subagents(),
|
||||
pending_rollback: s.pending_rollback,
|
||||
title_renamed: s.title_renamed,
|
||||
})
|
||||
};
|
||||
let path = self.shared.paths.store_dir.join(format!("{}.json", self.id));
|
||||
@@ -1608,12 +1665,18 @@ impl Task {
|
||||
return;
|
||||
}
|
||||
if kind == "session_info_update" {
|
||||
// The agent's own title for the session replaces the first line of the prompt.
|
||||
// The agent's own title for the session replaces the first line of the prompt,
|
||||
// unless the user named the task.
|
||||
let title = update.get("title").and_then(Value::as_str).map(str::trim).filter(|t| !t.is_empty());
|
||||
if let Some(title) = title {
|
||||
self.state.lock().task.title = title.to_owned();
|
||||
self.persist();
|
||||
self.emit_task();
|
||||
// Check and write under one lock: a rename landing between them would be lost.
|
||||
let mut s = self.state.lock();
|
||||
if !s.title_renamed && s.task.title != title {
|
||||
s.task.title = title.to_owned();
|
||||
drop(s);
|
||||
self.persist();
|
||||
self.emit_task();
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -1724,7 +1787,10 @@ impl Task {
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let asked: Vec<String> = questions.iter().map(|question| question.question.clone()).collect();
|
||||
let asked: Vec<String> = questions
|
||||
.iter()
|
||||
.map(|question| question.answer_key.clone().unwrap_or_else(|| question.question.clone()))
|
||||
.collect();
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
let entry =
|
||||
self.push_entry(AgentEntry::Permission { id, title, options: options.clone(), questions, chosen: None });
|
||||
@@ -2001,6 +2067,10 @@ fn turn_starts(entries: &[AgentEntry]) -> Vec<usize> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn is_false(b: &bool) -> bool {
|
||||
!*b
|
||||
}
|
||||
|
||||
fn is_zero(n: &u32) -> bool {
|
||||
*n == 0
|
||||
}
|
||||
|
||||
@@ -282,6 +282,9 @@ pub struct AgentPermissionOption {
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AgentQuestion {
|
||||
/// Stable answer identity when the provider supplies question ids.
|
||||
#[serde(default)]
|
||||
pub answer_key: Option<String>,
|
||||
#[serde(default)]
|
||||
pub header: String,
|
||||
pub question: String,
|
||||
|
||||
@@ -871,31 +871,55 @@ impl CodexShim {
|
||||
Ok(self.ask(&thread, call, &choices, json!({ "permissions": {}, "scope": "turn" })))
|
||||
}
|
||||
"item/tool/requestUserInput" => {
|
||||
let mut answers = serde_json::Map::new();
|
||||
for q in params.get("questions").and_then(Value::as_array).into_iter().flatten() {
|
||||
let Some(qid) = q.get("id").and_then(Value::as_str) else { continue };
|
||||
let choices: Vec<Choice> = q
|
||||
.get("options")
|
||||
.and_then(Value::as_array)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.enumerate()
|
||||
.filter_map(|(i, o)| {
|
||||
let label = o.get("label")?.as_str()?;
|
||||
Some(Choice {
|
||||
option: json!({ "optionId": i.to_string(), "name": label, "kind": "allow_once" }),
|
||||
answer: json!({ "answers": [label] }),
|
||||
let questions = params.get("questions").and_then(Value::as_array).cloned().unwrap_or_default();
|
||||
// One permission request carrying all the questions, so the client shows
|
||||
// them as a card (as it does for Claude's `AskUserQuestion`).
|
||||
let asked: Vec<Value> = questions
|
||||
.iter()
|
||||
.filter_map(|q| {
|
||||
let id = q.get("id").and_then(Value::as_str)?;
|
||||
let question = q.get("question").and_then(Value::as_str)?;
|
||||
let options: Vec<Value> = q
|
||||
.get("options")
|
||||
.and_then(Value::as_array)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(|o| {
|
||||
let label = o.get("label").and_then(Value::as_str)?;
|
||||
Some(json!({
|
||||
"label": label,
|
||||
"description": o.get("description").and_then(Value::as_str).unwrap_or_default(),
|
||||
}))
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
if choices.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let title = q.get("question").and_then(Value::as_str).unwrap_or("Codex has a question");
|
||||
let call = tool_call_of(&item_id, title, "other", None, None);
|
||||
let answer = self.ask(&thread, call, &choices, Value::Null);
|
||||
if !answer.is_null() {
|
||||
answers.insert(qid.to_owned(), answer);
|
||||
.collect();
|
||||
Some(json!({
|
||||
"answerKey": id,
|
||||
"header": q.get("header").and_then(Value::as_str).unwrap_or_default(),
|
||||
"question": question,
|
||||
"multiSelect": q.get("multiSelect").and_then(Value::as_bool).unwrap_or(false),
|
||||
"options": options,
|
||||
}))
|
||||
})
|
||||
.collect();
|
||||
if asked.is_empty() {
|
||||
return Ok(json!({ "answers": {} }));
|
||||
}
|
||||
let title = asked[0].get("question").and_then(Value::as_str).unwrap_or("Codex has a question");
|
||||
let mut call = tool_call_of(&item_id, title, "other", None, None);
|
||||
call["_meta"] = json!({ "questions": asked });
|
||||
let options = [json!({ "optionId": "submit", "name": "Submit", "kind": "allow_once" })];
|
||||
let picked: serde_json::Map<String, Value> = self
|
||||
.ask_option(&thread, call, &options)
|
||||
.and_then(|reply| serde_json::from_str(&reply).ok())
|
||||
.unwrap_or_default();
|
||||
let mut answers = serde_json::Map::new();
|
||||
for q in &questions {
|
||||
let Some(qid) = q.get("id").and_then(Value::as_str) else { continue };
|
||||
if let Some(labels) = picked
|
||||
.get(qid)
|
||||
.filter(|labels| labels.as_array().is_some_and(|labels| labels.iter().all(Value::is_string)))
|
||||
{
|
||||
answers.insert(qid.to_owned(), json!({ "answers": labels }));
|
||||
}
|
||||
}
|
||||
Ok(json!({ "answers": answers }))
|
||||
@@ -928,6 +952,25 @@ impl CodexShim {
|
||||
}
|
||||
}
|
||||
|
||||
/// Asks the client; returns the selected option id (`None` when cancelled).
|
||||
fn ask_option(&self, session: &str, tool_call: Value, options: &[Value]) -> Option<String> {
|
||||
let client = self.client.get().cloned()?;
|
||||
let params = json!({ "sessionId": session, "toolCall": tool_call, "options": options });
|
||||
let outcome: Result<Value, RpcError> =
|
||||
self.runtime.block_on(client.request("session/request_permission", params, None));
|
||||
match outcome {
|
||||
Ok(outcome) => outcome
|
||||
.pointer("/outcome/outcome")
|
||||
.and_then(Value::as_str)
|
||||
.filter(|outcome| *outcome == "selected")
|
||||
.and_then(|_| outcome.pointer("/outcome/optionId").and_then(Value::as_str).map(str::to_owned)),
|
||||
Err(e) => {
|
||||
tracing::debug!("codex: question request failed: {e}");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Asks the client; returns the answer for codex (`otherwise` when cancelled).
|
||||
fn ask(&self, session: &str, tool_call: Value, choices: &[Choice], otherwise: Value) -> Value {
|
||||
let Some(client) = self.client.get().cloned() else { return otherwise };
|
||||
|
||||
@@ -316,6 +316,39 @@ impl Client {
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn questions_share_one_card_and_keep_their_ids() {
|
||||
let client = Arc::new(Client::default());
|
||||
client.answers.lock().extend([Some(r#"{"first":["One"],"second":["Two","Custom"]}"#), None, Some("invalid")]);
|
||||
let (ours, theirs) = tokio::io::duplex(1 << 16);
|
||||
let (read, write) = tokio::io::split(theirs);
|
||||
let codex = Peer::connect_framed(read, write, Arc::new(ide_jsonrpc::NoopHandler), Framing::Lines);
|
||||
let acp = attach(client.clone(), |events| {
|
||||
let (read, write) = tokio::io::split(ours);
|
||||
Ok(Peer::connect_framed(read, write, events, Framing::Lines))
|
||||
})
|
||||
.unwrap();
|
||||
let params = json!({"threadId": "s", "itemId": "q", "questions": [
|
||||
{"id": "first", "question": "Which?", "options": [{"label": "One", "description": "First"}]},
|
||||
{"id": "second", "question": "Which?", "multiSelect": true, "options": [{"label": "Two"}]}
|
||||
]});
|
||||
for expected in
|
||||
[json!({"first": {"answers": ["One"]}, "second": {"answers": ["Two", "Custom"]}}), json!({}), json!({})]
|
||||
{
|
||||
let reply: Value = codex.request("item/tool/requestUserInput", params.clone(), None).await.unwrap();
|
||||
assert_eq!(reply, json!({"answers": expected}));
|
||||
}
|
||||
let asked = client.asked.lock();
|
||||
assert_eq!(asked.len(), 3);
|
||||
let questions = &asked[0]["toolCall"]["_meta"]["questions"];
|
||||
assert_eq!(questions.as_array().unwrap().len(), 2);
|
||||
assert_eq!(questions[0]["answerKey"], "first");
|
||||
assert_eq!(questions[1]["answerKey"], "second");
|
||||
assert_eq!(questions[0]["options"][0]["description"], "First");
|
||||
acp.kill();
|
||||
codex.kill();
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
enum Scenario {
|
||||
AuthFailure,
|
||||
|
||||
@@ -800,6 +800,7 @@ async fn qwen_questions_answer_by_index_beside_the_submit_option() {
|
||||
questions,
|
||||
vec![
|
||||
AgentQuestion {
|
||||
answer_key: None,
|
||||
header: "Database".into(),
|
||||
question: "Which database?".into(),
|
||||
multi_select: false,
|
||||
@@ -809,6 +810,7 @@ async fn qwen_questions_answer_by_index_beside_the_submit_option() {
|
||||
],
|
||||
},
|
||||
AgentQuestion {
|
||||
answer_key: None,
|
||||
header: "Caches".into(),
|
||||
question: "Which caches?".into(),
|
||||
multi_select: true,
|
||||
@@ -992,6 +994,44 @@ async fn real_claude_task_in_a_worktree() {
|
||||
mgr.shutdown();
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn renamed_titles_survive_updates_forks_and_restart() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let agent = Arc::new(FakeAgent::default());
|
||||
let mgr = manager(tmp.path(), agent.clone());
|
||||
let task = mgr
|
||||
.create(NewAgentTask {
|
||||
project_root: None,
|
||||
prompt: text("again"),
|
||||
provider: "claude".into(),
|
||||
config: Vec::new(),
|
||||
isolation: Isolation::Local,
|
||||
base: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
wait_for(&mgr, &task.id, AgentStatus::Ready).await;
|
||||
let handle = mgr.task(&task.id).unwrap();
|
||||
assert!(mgr.rename(&task.id, " ").is_err());
|
||||
let initial = handle.state.lock().task.title.clone();
|
||||
for title in [initial, "My task".to_owned(), "名".repeat(205)] {
|
||||
mgr.rename(&task.id, &title).unwrap();
|
||||
handle.on_update(None, &json!({"sessionUpdate": "session_info_update", "title": "Agent title"}));
|
||||
assert_eq!(handle.state.lock().task.title, title.chars().take(200).collect::<String>());
|
||||
}
|
||||
mgr.rename(&task.id, " My task ").unwrap();
|
||||
let copy = mgr.fork(&task.id, None).await.unwrap();
|
||||
let copied = mgr.task(©.id).unwrap();
|
||||
copied.on_update(None, &json!({"sessionUpdate": "session_info_update", "title": "Copied agent title"}));
|
||||
assert_eq!(copied.state.lock().task.title, "My task");
|
||||
mgr.shutdown();
|
||||
let restored = manager(tmp.path(), agent);
|
||||
let handle = restored.task(&task.id).unwrap();
|
||||
handle.on_update(None, &json!({"sessionUpdate": "session_info_update", "title": "Resumed title"}));
|
||||
assert_eq!(handle.state.lock().task.title, "My task");
|
||||
restored.shutdown();
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn forks_rollbacks_and_steering() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
@@ -1040,6 +1080,33 @@ async fn forks_rollbacks_and_steering() {
|
||||
assert!(mgr.task(&task.id).unwrap().state.lock().task.queue.is_empty());
|
||||
mgr.cancel(&task.id, None).unwrap();
|
||||
wait_for(&mgr, &task.id, AgentStatus::Ready).await;
|
||||
|
||||
// Dropping every turn starts a fresh session instead of rewinding the agent's history.
|
||||
let handle = mgr.task(&task.id).unwrap();
|
||||
for (parent, child) in [(None, "child"), (Some("child"), "grandchild")] {
|
||||
handle.on_update(
|
||||
parent,
|
||||
&json!({"sessionUpdate": "subagent_update", "sessionId": child,
|
||||
"state": {"state": "idle", "stopReason": "end_turn"}}),
|
||||
);
|
||||
}
|
||||
handle.state.lock().conversation.main.entries.insert(0, AgentEntry::Agent { text: "Welcome".into() });
|
||||
let rewinds = agent.calls.lock().iter().filter(|c| c.starts_with("_openidea/rollback")).count();
|
||||
let first = mgr.rollback(&task.id, 0).await.unwrap().unwrap();
|
||||
assert_eq!(first.text, "again");
|
||||
assert!(mgr.entries(&task.id, None).unwrap().is_empty());
|
||||
assert!(mgr.task(&task.id).unwrap().state.lock().session_id.is_none());
|
||||
assert!(handle.state.lock().conversation.saved_subagents().is_empty());
|
||||
assert!(handle.state.lock().task.usage.is_none());
|
||||
assert_eq!(agent.calls.lock().iter().filter(|c| c.starts_with("_openidea/rollback")).count(), rewinds);
|
||||
mgr.shutdown();
|
||||
let restored = manager(tmp.path(), agent.clone());
|
||||
assert!(restored.entries(&task.id, None).unwrap().is_empty());
|
||||
restored.prompt(&task.id, first, false).await.unwrap();
|
||||
wait_for(&restored, &task.id, AgentStatus::Ready).await;
|
||||
assert_eq!(turn_starts(&restored.entries(&task.id, None).unwrap()).len(), 1);
|
||||
assert_eq!(agent.calls.lock().last().unwrap(), "session/prompt");
|
||||
restored.shutdown();
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
|
||||
@@ -290,7 +290,8 @@ impl Timeline {
|
||||
/// and records when the turn ended (`done`) on the message that started it.
|
||||
pub fn end_turn(&mut self, done: Option<f64>) -> Vec<usize> {
|
||||
let mut changed = Vec::new();
|
||||
let started = self.entries.iter().rposition(|e| matches!(e, AgentEntry::User { .. }));
|
||||
// A steered message rides along in a running turn; the one that started it owns its end.
|
||||
let started = self.entries.iter().rposition(|e| matches!(e, AgentEntry::User { steered: false, .. }));
|
||||
for (i, e) in self.entries.iter_mut().enumerate() {
|
||||
match e {
|
||||
AgentEntry::User { done_ms, .. } if Some(i) == started && done.is_some() && done_ms.is_none() => {
|
||||
@@ -468,6 +469,27 @@ mod tests {
|
||||
assert_eq!(t.end_turn(Some(9.0)), Vec::<usize>::new());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn end_turn_records_on_the_message_that_started_the_turn() {
|
||||
let user = |sent: f64, steered: bool| AgentEntry::User {
|
||||
text: "q".into(),
|
||||
attachments: vec![],
|
||||
images: vec![],
|
||||
sent_ms: Some(sent),
|
||||
done_ms: None,
|
||||
steered,
|
||||
};
|
||||
let mut t = Timeline::default();
|
||||
t.push(user(1.0, false));
|
||||
t.push(AgentEntry::Agent { text: "a".into() });
|
||||
t.push(user(2.0, true));
|
||||
// The steered message rides along in the turn: the reply's time belongs to the
|
||||
// message that started it.
|
||||
assert_eq!(t.end_turn(Some(5.0)), vec![0]);
|
||||
assert!(matches!(t.entries[0], AgentEntry::User { done_ms: Some(5.0), .. }));
|
||||
assert!(matches!(t.entries[2], AgentEntry::User { done_ms: None, .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn long_output_keeps_its_tail() {
|
||||
let s = tail("é".repeat(MAX_OUTPUT));
|
||||
|
||||
@@ -257,6 +257,10 @@ pub fn unarchive(host: &Host, window: &str, task: String) -> IdeResult<()> {
|
||||
host.project(window)?.agents.unarchive(&task)
|
||||
}
|
||||
|
||||
pub fn rename(host: &Host, window: &str, task: String, title: String) -> IdeResult<()> {
|
||||
host.project(window)?.agents.rename(&task, &title)
|
||||
}
|
||||
|
||||
pub async fn delete(host: &Host, window: &str, task: String, remove_worktree: bool) -> IdeResult<()> {
|
||||
host.require_trust(window)?;
|
||||
host.project(window)?.agents.delete(&task, remove_worktree).await
|
||||
@@ -309,6 +313,7 @@ pub(crate) fn register(d: &mut Dispatcher) {
|
||||
respond(&c.host, &c.window, c.arg("task")?, c.arg("permission")?, c.arg("option")?)
|
||||
});
|
||||
d.add("agent_unarchive", |c| async move { unarchive(&c.host, &c.window, c.arg("task")?) });
|
||||
d.add("agent_rename", |c| async move { rename(&c.host, &c.window, c.arg("task")?, c.arg("title")?) });
|
||||
d.add("agent_archive", |c| async move { archive(&c.host, &c.window, c.arg("task")?) });
|
||||
d.add(
|
||||
"agent_delete",
|
||||
|
||||
@@ -262,6 +262,12 @@ pub fn agent_unarchive(window: Window, state: State<'_, AppState>, task: String)
|
||||
agent::unarchive(&state.host, window.label(), task)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub fn agent_rename(window: Window, state: State<'_, AppState>, task: String, title: String) -> IdeResult<()> {
|
||||
agent::rename(&state.host, window.label(), task, title)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn agent_delete(
|
||||
|
||||
@@ -127,6 +127,7 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
|
||||
agent::agent_respond,
|
||||
agent::agent_archive,
|
||||
agent::agent_unarchive,
|
||||
agent::agent_rename,
|
||||
agent::agent_delete,
|
||||
agent::agent_changes,
|
||||
agent::agent_base_text,
|
||||
|
||||
@@ -168,6 +168,7 @@ export const commands = {
|
||||
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 })),
|
||||
agentUnarchive: (task: string) => typedError<null, IdeError>(__TAURI_INVOKE("agent_unarchive", { task })),
|
||||
agentRename: (task: string, title: string) => typedError<null, IdeError>(__TAURI_INVOKE("agent_rename", { task, title })),
|
||||
agentDelete: (task: string, removeWorktree: boolean) => typedError<null, IdeError>(__TAURI_INVOKE("agent_delete", { task, removeWorktree })),
|
||||
agentChanges: (task: string) => typedError<AgentChange[], IdeError>(__TAURI_INVOKE("agent_changes", { task })),
|
||||
/** Content of `path` when the task started (left side of its diff). */
|
||||
@@ -1646,6 +1647,8 @@ export type AgentProvider = {
|
||||
|
||||
/** A question the agent asks with `AskUserQuestion`, answered by picking options or typing. */
|
||||
export type AgentQuestion = {
|
||||
/** Stable answer identity when the provider supplies question ids. */
|
||||
answerKey?: string | null,
|
||||
header?: string,
|
||||
question: string,
|
||||
multiSelect?: boolean,
|
||||
|
||||
@@ -1078,7 +1078,10 @@
|
||||
gap: 12px;
|
||||
max-height: 50vh;
|
||||
overflow-y: auto;
|
||||
margin: 0 12px 8px;
|
||||
/* The composer's width, so the card sits exactly over the box it belongs to. */
|
||||
width: 100%;
|
||||
max-width: var(--agent-conversation-width);
|
||||
margin: 0 auto 8px;
|
||||
padding: 12px;
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: var(--radius-control);
|
||||
@@ -1115,9 +1118,26 @@
|
||||
.question .questionOption {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
padding: 4px 6px;
|
||||
padding: 8px 10px;
|
||||
border-radius: var(--radius-row);
|
||||
}
|
||||
/* Each option is a card of its own: separate targets, not one list with hairlines. */
|
||||
.questionChoices {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
.question .questionChoices > * {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-row);
|
||||
}
|
||||
.question .questionChoices > *:hover {
|
||||
border-color: var(--border-strong);
|
||||
}
|
||||
.question .questionChoices > *:has(input:checked) {
|
||||
border-color: var(--accent);
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
.question .questionOption:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
@@ -907,8 +907,7 @@ function UserMessage({
|
||||
later={turn < turnCount - 1}
|
||||
onCancel={() => setEditing(false)}
|
||||
onSend={async (text, images) => {
|
||||
await rerunFrom(task.id, turn, text, images);
|
||||
setEditing(false);
|
||||
if (await rerunFrom(task.id, turn, text, images)) setEditing(false);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
@@ -1379,7 +1378,13 @@ function QuestionForm({
|
||||
void respond(task.id, entry.id, option).finally(() => setBusy(false));
|
||||
};
|
||||
const submit = () =>
|
||||
send(JSON.stringify(Object.fromEntries(answers.map((answer) => [answer.question.question, answerItems(answer)]))));
|
||||
send(
|
||||
JSON.stringify(
|
||||
Object.fromEntries(
|
||||
answers.map((answer) => [answer.question.answerKey ?? answer.question.question, answerItems(answer)]),
|
||||
),
|
||||
),
|
||||
);
|
||||
const update = (previous: QuestionAnswer, next: QuestionAnswer) =>
|
||||
setAnswers(answers.map((answer) => (answer === previous ? next : answer)));
|
||||
const choose = (answer: QuestionAnswer, label: string, checked: boolean) =>
|
||||
|
||||
@@ -3,7 +3,8 @@ import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import type { AgentEntry, AgentTask } from "@/bindings";
|
||||
|
||||
const ipc = vi.hoisted(() => ({ rollback: vi.fn(), prompt: vi.fn(), cancel: vi.fn() }));
|
||||
const ipc = vi.hoisted(() => ({ rollback: vi.fn(), prompt: vi.fn(), cancel: vi.fn(), confirm: vi.fn() }));
|
||||
vi.mock("@/workbench/messages", () => ({ confirm: ipc.confirm }));
|
||||
const entries: AgentEntry[] = [
|
||||
{ type: "user", text: "Original message", attachments: [], sentMs: 1, doneMs: 2, steered: false },
|
||||
{ type: "agent", text: "Original answer" },
|
||||
@@ -25,7 +26,7 @@ vi.mock("@tauri-apps/api/webview", () => ({
|
||||
}));
|
||||
|
||||
import { SessionEditor } from "./SessionEditor";
|
||||
import { composerDraft, sendMessage, updateComposer, useAgents } from "./store";
|
||||
import { composerDraft, rerunFrom, sendMessage, updateComposer, useAgents } from "./store";
|
||||
import { replyActions } from "./replyActions";
|
||||
|
||||
const task: AgentTask = {
|
||||
@@ -50,12 +51,31 @@ const task: AgentTask = {
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
ipc.confirm.mockReset().mockResolvedValue(true);
|
||||
ipc.rollback.mockReset().mockResolvedValue({ status: "ok", data: null });
|
||||
ipc.prompt.mockReset().mockResolvedValue({ status: "ok", data: null });
|
||||
ipc.cancel.mockReset().mockResolvedValue({ status: "ok", data: null });
|
||||
useAgents.setState({ tasks: new Map([[task.id, task]]), entries: new Map(), composers: new Map(), focus: null });
|
||||
});
|
||||
|
||||
it("keeps the transcript and running session when a fresh-session rerun is cancelled", async () => {
|
||||
ipc.confirm.mockResolvedValue(false);
|
||||
useAgents.setState({
|
||||
tasks: new Map([[task.id, { ...task, status: "running" }]]),
|
||||
entries: new Map([[task.id, entries]]),
|
||||
});
|
||||
await rerunFrom(task.id, 0, "Replacement");
|
||||
expect(ipc.confirm).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
message: expect.stringContaining("clears the entire conversation"),
|
||||
}),
|
||||
);
|
||||
expect(ipc.cancel).not.toHaveBeenCalled();
|
||||
expect(ipc.rollback).not.toHaveBeenCalled();
|
||||
expect(ipc.prompt).not.toHaveBeenCalled();
|
||||
expect(useAgents.getState().entries.get(task.id)).toEqual(entries);
|
||||
});
|
||||
|
||||
async function edit() {
|
||||
render(<SessionEditor input={{ kind: "agent-session", key: task.id, data: { task: task.id } }} />);
|
||||
const user = userEvent.setup();
|
||||
|
||||
@@ -43,7 +43,7 @@ function resolveReply(arg: unknown) {
|
||||
}
|
||||
|
||||
const pending = new Set<string>();
|
||||
async function runReplyOperation(task: string, title: string, run: () => Promise<void>) {
|
||||
async function runReplyOperation(task: string, title: string, run: () => Promise<unknown>) {
|
||||
if (pending.has(task)) return;
|
||||
pending.add(task);
|
||||
try {
|
||||
|
||||
@@ -139,6 +139,35 @@ describe("a pending question", () => {
|
||||
});
|
||||
|
||||
describe("a pending AskUserQuestion", () => {
|
||||
it("keeps separate answers for Codex question ids with identical wording", async () => {
|
||||
backend.entries.set("A", [
|
||||
{
|
||||
type: "permission",
|
||||
id: 11,
|
||||
title: "Which?",
|
||||
chosen: null,
|
||||
options: [],
|
||||
questions: ["first", "second"].map((answerKey, index) => ({
|
||||
answerKey,
|
||||
header: answerKey,
|
||||
question: "Which?",
|
||||
multiSelect: false,
|
||||
options: [{ label: index === 0 ? "One" : "Two", description: "", preview: null }],
|
||||
})),
|
||||
},
|
||||
]);
|
||||
backend.answers = [];
|
||||
useAgents.setState({ entries: new Map(backend.entries) });
|
||||
render(<SessionEditor input={input("A")} />);
|
||||
const card = await screen.findByRole("form", { name: "Which?" });
|
||||
fireEvent.click(within(card).getByRole("radio", { name: /^One/ }));
|
||||
fireEvent.click(within(card).getByRole("radio", { name: /^Two/ }));
|
||||
fireEvent.click(within(card).getByRole("button", { name: "Submit" }));
|
||||
await waitFor(() =>
|
||||
expect(backend.answers).toEqual([["A", 11, JSON.stringify({ first: ["One"], second: ["Two"] })]]),
|
||||
);
|
||||
});
|
||||
|
||||
it("shows every question with its descriptions in a card and sends all answers at once", async () => {
|
||||
backend.entries.set("A", [
|
||||
{
|
||||
|
||||
@@ -18,6 +18,7 @@ import { useProject } from "@/platform/project";
|
||||
import { defaultTaskProject } from "./project";
|
||||
import { call, commands, isTauri } from "@/platform/ipc";
|
||||
import { editorService, notifications, toolWindowService } from "@/workbench/contributions";
|
||||
import { confirm } from "@/workbench/messages";
|
||||
import { bypassDefaults, canSend, emptyDraft, toPrompt, type ComposerDraft, type FileRef } from "./composerModel";
|
||||
import { withQuote } from "./conversation";
|
||||
import {
|
||||
@@ -601,7 +602,18 @@ export async function forkTask(task: string, turns: number | null): Promise<void
|
||||
* Runs the conversation again from turn `turn` with `text` as its message: the agent is
|
||||
* stopped if it works, that turn and the later ones are dropped, then `text` is sent.
|
||||
*/
|
||||
export async function rerunFrom(task: string, turn: number, text: string, images: AgentImage[] = []): Promise<void> {
|
||||
export async function rerunFrom(task: string, turn: number, text: string, images: AgentImage[] = []): Promise<boolean> {
|
||||
if (
|
||||
turn === 0 &&
|
||||
!(await confirm({
|
||||
title: "Start a Fresh Agent Session",
|
||||
message:
|
||||
"This clears the entire conversation and starts a fresh agent session with this message. Files the agent changed stay as they are.",
|
||||
ok: "Start Fresh",
|
||||
icon: "warning",
|
||||
}))
|
||||
)
|
||||
return false;
|
||||
if (isBusy(useAgents.getState().tasks.get(task)?.status ?? "ready")) {
|
||||
await cancelTask(task);
|
||||
await settled(task);
|
||||
@@ -619,6 +631,7 @@ export async function rerunFrom(task: string, turn: number, text: string, images
|
||||
focusComposer(task);
|
||||
throw e;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Resolves once the task's turn has ended (or after a while). */
|
||||
@@ -688,5 +701,6 @@ export const stopBackgroundTask = (task: string, background: string) =>
|
||||
export const respond = (task: string, permission: number, option: string | null) =>
|
||||
call(commands.agentRespond(task, permission, option));
|
||||
export const unarchiveTask = (task: string) => call(commands.agentUnarchive(task));
|
||||
export const renameTask = (task: string, title: string) => call(commands.agentRename(task, title));
|
||||
export const archiveTask = (task: string) => call(commands.agentArchive(task));
|
||||
export const deleteTask = (task: string, removeWorktree: boolean) => call(commands.agentDelete(task, removeWorktree));
|
||||
@@ -3,6 +3,7 @@ import type { Action } from "@/platform/actions";
|
||||
import { tx } from "@/platform/i18n";
|
||||
import type { MenuItem } from "@/ui";
|
||||
import { showError, confirmChecked } from "@/workbench/messages";
|
||||
import { promptInput } from "@/workbench/inputDialogs";
|
||||
import { isBusy } from "./model";
|
||||
import {
|
||||
archiveTask,
|
||||
@@ -12,6 +13,7 @@ import {
|
||||
forkTask,
|
||||
openTask,
|
||||
openTaskInEditorLocation,
|
||||
renameTask,
|
||||
setSessionLocation,
|
||||
undockTask,
|
||||
useAgents,
|
||||
@@ -118,6 +120,24 @@ export const taskActions: Action[] = [
|
||||
hidden: true,
|
||||
run: () => undockTask(),
|
||||
},
|
||||
{
|
||||
id: "agents.renameTask",
|
||||
title: "Rename Agent Task",
|
||||
category: "Agents",
|
||||
hidden: true,
|
||||
run: async ({ arg }) => {
|
||||
const task = resolveTasks([arg])[0];
|
||||
if (!task) return;
|
||||
const title = await promptInput({
|
||||
title: "Rename Agent Task",
|
||||
label: "Name",
|
||||
initial: task.title,
|
||||
okLabel: "Rename",
|
||||
});
|
||||
if (title !== null)
|
||||
await renameTask(task.id, title).catch((e: unknown) => showError(tx("Couldn't rename the task"), String(e)));
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "agents.forkTask",
|
||||
title: "Copy Agent Task to a New Task",
|
||||
@@ -169,6 +189,7 @@ export function taskMenu(tasks: AgentTask[]): MenuItem[] {
|
||||
...(!multiple
|
||||
? ([
|
||||
{ kind: "action", action: "agents.openTask", label: "Open", arg: task.id },
|
||||
{ kind: "action", action: "agents.renameTask", label: "Rename…", icon: "edit", arg: task.id },
|
||||
{ kind: "action", action: "terminal.openAt", label: "Open in Terminal", icon: "terminal", arg: task.cwd },
|
||||
...(task.capabilities?.fork && task.status !== "archived"
|
||||
? ([
|
||||
|
||||
@@ -339,5 +339,12 @@
|
||||
"\n[Process finished with exit code {exitCode}]": "\n[Process finished with exit code {exitCode}]",
|
||||
"\nError: {error}": "\nError: {error}",
|
||||
"\n[Log stream stopped]": "\n[Log stream stopped]",
|
||||
"Toggle Log Option": "Toggle Log Option"
|
||||
"Toggle Log Option": "Toggle Log Option",
|
||||
"IDE-managed Tomcat runtime directory must not overlap Tomcat home or base directory": "IDE-managed Tomcat runtime directory must not overlap Tomcat home or base directory",
|
||||
"devcontainer up exited with {status}": "devcontainer up exited with {status}",
|
||||
"Rename Agent Task": "Rename Agent Task",
|
||||
"Couldn't rename the task": "Couldn't rename the task",
|
||||
"Start a Fresh Agent Session": "Start a Fresh Agent Session",
|
||||
"Start Fresh": "Start Fresh",
|
||||
"This clears the entire conversation and starts a fresh agent session with this message. Files the agent changed stay as they are.": "This clears the entire conversation and starts a fresh agent session with this message. Files the agent changed stay as they are."
|
||||
}
|
||||
@@ -6702,5 +6702,13 @@
|
||||
"\n[Process finished with exit code {exitCode}]": "\n[进程已结束,退出代码为 {exitCode}]",
|
||||
"\nError: {error}": "\n错误:{error}",
|
||||
"\n[Log stream stopped]": "\n[日志流已停止]",
|
||||
"Toggle Log Option": "切换日志选项"
|
||||
"Toggle Log Option": "切换日志选项",
|
||||
"IDE-managed Tomcat runtime directory must not overlap Tomcat home or base directory": "IDE 托管的 Tomcat 运行目录不得与 Tomcat 主目录或基目录重叠",
|
||||
"Read-only: the table schema could not be identified in the result database.": "只读:无法确定结果所在数据库中的表架构。",
|
||||
"devcontainer up exited with {status}": "devcontainer up 已退出,状态为 {status}",
|
||||
"Rename Agent Task": "重命名智能体任务",
|
||||
"Couldn't rename the task": "无法重命名任务",
|
||||
"Start a Fresh Agent Session": "启动全新的智能体会话",
|
||||
"Start Fresh": "重新开始",
|
||||
"This clears the entire conversation and starts a fresh agent session with this message. Files the agent changed stay as they are.": "这会清空整个对话,并使用此消息启动全新的智能体会话。智能体已修改的文件保持不变。"
|
||||
}
|
||||
Reference in new issue
Заблокувати користувача