diff --git a/crates/ide-agent/src/lib.rs b/crates/ide-agent/src/lib.rs index 848f46834..d61845f51 100644 --- a/crates/ide-agent/src/lib.rs +++ b/crates/ide-agent/src/lib.rs @@ -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, before: Arc>, 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 = questions.iter().map(|question| question.question.clone()).collect(); + let asked: Vec = 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 { .collect() } +fn is_false(b: &bool) -> bool { + !*b +} + fn is_zero(n: &u32) -> bool { *n == 0 } diff --git a/crates/ide-agent/src/model.rs b/crates/ide-agent/src/model.rs index e45cafbe2..828102ebb 100644 --- a/crates/ide-agent/src/model.rs +++ b/crates/ide-agent/src/model.rs @@ -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, #[serde(default)] pub header: String, pub question: String, diff --git a/crates/ide-agent/src/native/codex/mod.rs b/crates/ide-agent/src/native/codex/mod.rs index 7f040e4a0..a7d8c850d 100644 --- a/crates/ide-agent/src/native/codex/mod.rs +++ b/crates/ide-agent/src/native/codex/mod.rs @@ -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 = 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 = 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 = 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 = 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 { + let client = self.client.get().cloned()?; + let params = json!({ "sessionId": session, "toolCall": tool_call, "options": options }); + let outcome: Result = + 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 }; diff --git a/crates/ide-agent/src/native/codex/tests.rs b/crates/ide-agent/src/native/codex/tests.rs index 0cc94cf93..66118360b 100644 --- a/crates/ide-agent/src/native/codex/tests.rs +++ b/crates/ide-agent/src/native/codex/tests.rs @@ -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, diff --git a/crates/ide-agent/src/tests.rs b/crates/ide-agent/src/tests.rs index 6f8fed606..928ab0254 100644 --- a/crates/ide-agent/src/tests.rs +++ b/crates/ide-agent/src/tests.rs @@ -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::()); + } + 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")] diff --git a/crates/ide-agent/src/timeline.rs b/crates/ide-agent/src/timeline.rs index 3617046ba..51072a778 100644 --- a/crates/ide-agent/src/timeline.rs +++ b/crates/ide-agent/src/timeline.rs @@ -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) -> Vec { 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::::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)); diff --git a/crates/ide-host/src/ops/agent.rs b/crates/ide-host/src/ops/agent.rs index d764923a2..57b542471 100644 --- a/crates/ide-host/src/ops/agent.rs +++ b/crates/ide-host/src/ops/agent.rs @@ -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", diff --git a/src-tauri/src/commands/agent.rs b/src-tauri/src/commands/agent.rs index 561665ee5..1f5ffd6af 100644 --- a/src-tauri/src/commands/agent.rs +++ b/src-tauri/src/commands/agent.rs @@ -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( diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index c715d8a40..a9cb46f7f 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -127,6 +127,7 @@ pub fn builder() -> tauri_specta::Builder { agent::agent_respond, agent::agent_archive, agent::agent_unarchive, + agent::agent_rename, agent::agent_delete, agent::agent_changes, agent::agent_base_text, diff --git a/src/bindings.ts b/src/bindings.ts index c8f2ea933..cc9bdaa76 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -168,6 +168,7 @@ export const commands = { agentRespond: (task: string, permission: number, option: string | null) => typedError(__TAURI_INVOKE("agent_respond", { task, permission, option })), agentArchive: (task: string) => typedError(__TAURI_INVOKE("agent_archive", { task })), agentUnarchive: (task: string) => typedError(__TAURI_INVOKE("agent_unarchive", { task })), + agentRename: (task: string, title: string) => typedError(__TAURI_INVOKE("agent_rename", { task, title })), agentDelete: (task: string, removeWorktree: boolean) => typedError(__TAURI_INVOKE("agent_delete", { task, removeWorktree })), agentChanges: (task: string) => typedError(__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, diff --git a/src/features/agents/Session.module.css b/src/features/agents/Session.module.css index 48dc26303..01f876af6 100644 --- a/src/features/agents/Session.module.css +++ b/src/features/agents/Session.module.css @@ -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); } diff --git a/src/features/agents/SessionEditor.tsx b/src/features/agents/SessionEditor.tsx index 35e97cb82..b550c4b8a 100644 --- a/src/features/agents/SessionEditor.tsx +++ b/src/features/agents/SessionEditor.tsx @@ -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) => diff --git a/src/features/agents/messageEditing.test.tsx b/src/features/agents/messageEditing.test.tsx index a61238a24..620fc3a95 100644 --- a/src/features/agents/messageEditing.test.tsx +++ b/src/features/agents/messageEditing.test.tsx @@ -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(); const user = userEvent.setup(); diff --git a/src/features/agents/replyActions.ts b/src/features/agents/replyActions.ts index 7c84b2b7b..93c67803a 100644 --- a/src/features/agents/replyActions.ts +++ b/src/features/agents/replyActions.ts @@ -43,7 +43,7 @@ function resolveReply(arg: unknown) { } const pending = new Set(); -async function runReplyOperation(task: string, title: string, run: () => Promise) { +async function runReplyOperation(task: string, title: string, run: () => Promise) { if (pending.has(task)) return; pending.add(task); try { diff --git a/src/features/agents/sessionState.test.tsx b/src/features/agents/sessionState.test.tsx index db97ff054..205cc5d12 100644 --- a/src/features/agents/sessionState.test.tsx +++ b/src/features/agents/sessionState.test.tsx @@ -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(); + 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", [ { diff --git a/src/features/agents/store.ts b/src/features/agents/store.ts index e7f5a17ed..42c415b65 100644 --- a/src/features/agents/store.ts +++ b/src/features/agents/store.ts @@ -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 { +export async function rerunFrom(task: string, turn: number, text: string, images: AgentImage[] = []): Promise { + 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)); diff --git a/src/features/agents/taskActions.ts b/src/features/agents/taskActions.ts index e9dd22521..d313486cb 100644 --- a/src/features/agents/taskActions.ts +++ b/src/features/agents/taskActions.ts @@ -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" ? ([ diff --git a/src/platform/locales/text/en.json b/src/platform/locales/text/en.json index 5db21f60d..6f2225999 100644 --- a/src/platform/locales/text/en.json +++ b/src/platform/locales/text/en.json @@ -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." } diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index ecde6c917..ba6cc67d8 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -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.": "这会清空整个对话,并使用此消息启动全新的智能体会话。智能体已修改的文件保持不变。" }