mirror of
https://github.com/openai/codex.git
synced 2026-10-21 23:19:44 +00:00
Open tasks managed elsewhere as read-only history in the command center (#44969)
## Why The command center previously refused to open tasks managed by another app server, preventing users from viewing their saved history. ## What changed - Fall back to a frozen, read-only history snapshot when attaching fails because another server owns the task. Preserve drafts and skip buffered request replay and paused-goal prompts while viewing. - Keep the snapshot when reopening the displayed task, and allow explicit retry to attach once the other server releases it. - Allow pasting into the command center while a read-only conversation is displayed, while keeping the conversation composer protected. - Box the TUI startup future to keep it out of the CLI caller's frame. ## Testing Add coverage for read-only history, draft and selection preservation, retry before and after ownership release, and history-read failure without leaving the current conversation. Assert that opening a read-only task does not start a turn and that the TUI startup future remains below 64 KiB. GitOrigin-RevId: f1bdb5b2d9182079de4e53346a56742a4a02a1c7
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
//! Daemon-wide overview of recent and locally retained sessions and their subagents.
|
||||
//! Tasks owned by another app server open as frozen, read-only history snapshots.
|
||||
|
||||
#[path = "agents_overview_composer.rs"]
|
||||
mod composer;
|
||||
@@ -494,7 +495,7 @@ impl App {
|
||||
crate::app_server_session::ResumeModelSettings::PreserveExistingThread
|
||||
}
|
||||
};
|
||||
let resumed = match app_server
|
||||
let (resumed, read_only) = match app_server
|
||||
.resume_thread(
|
||||
&local_settings,
|
||||
resume_config.clone(),
|
||||
@@ -503,15 +504,23 @@ impl App {
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(resumed) => resumed,
|
||||
Ok(resumed) => (resumed, false),
|
||||
Err(error) if crate::app_server_session::is_active_writer_error(&error) => {
|
||||
match app_server
|
||||
.read_thread_for_viewing(&resume_config, &local_settings, root_thread_id)
|
||||
.await
|
||||
{
|
||||
Ok(thread) => (thread, true),
|
||||
Err(error) => {
|
||||
self.add_agents_overview_error(format!(
|
||||
"Failed to view task open elsewhere: {error}"
|
||||
));
|
||||
return Ok(AppRunControl::Continue);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(error) => {
|
||||
let message = if crate::app_server_session::is_active_writer_error(&error) {
|
||||
tracing::warn!("failed to attach to task managed by another server");
|
||||
"Task is open elsewhere. Another app server is managing this task, so it can’t be attached here.".to_string()
|
||||
} else {
|
||||
format!("Failed to attach to task: {error}")
|
||||
};
|
||||
self.add_agents_overview_error(message);
|
||||
self.add_agents_overview_error(format!("Failed to attach to task: {error}"));
|
||||
return Ok(AppRunControl::Continue);
|
||||
}
|
||||
};
|
||||
@@ -577,6 +586,11 @@ impl App {
|
||||
self.add_agents_overview_error(format!("Failed to attach to task: {error}"));
|
||||
return Ok(AppRunControl::Continue);
|
||||
}
|
||||
if read_only {
|
||||
self.ensure_thread_channel(root_thread_id)
|
||||
.mark_external_writer();
|
||||
self.chat_widget.show_external_writer_thread();
|
||||
}
|
||||
let mut destination_config = self.chat_widget.config_ref().clone();
|
||||
if self.app_server_target.uses_remote_workspace() {
|
||||
destination_config.cwd.clone_from(&self.config.cwd);
|
||||
@@ -624,20 +638,25 @@ impl App {
|
||||
}
|
||||
|
||||
if self.current_displayed_thread_id() != Some(root_thread_id)
|
||||
|| self.thread_unavailable(root_thread_id)
|
||||
|| (self.thread_unavailable(root_thread_id)
|
||||
&& !self.chat_widget.is_external_writer_view())
|
||||
{
|
||||
self.select_agent_thread_and_discard_side(tui, app_server, root_thread_id)
|
||||
.await?;
|
||||
}
|
||||
self.replay_agents_overview_requests(app_server, root_thread_id)
|
||||
.await;
|
||||
let read_only = self.chat_widget.is_external_writer_view();
|
||||
if !read_only {
|
||||
self.replay_agents_overview_requests(app_server, root_thread_id)
|
||||
.await;
|
||||
}
|
||||
if self.current_displayed_thread_id() == Some(root_thread_id)
|
||||
&& let Some(input_state) = self.agents_overview.input_states.remove(&root_thread_id)
|
||||
{
|
||||
let preserve_in_flight_turn = self
|
||||
.active_turn_id_for_thread(root_thread_id)
|
||||
.await
|
||||
.is_some();
|
||||
let preserve_in_flight_turn = !read_only
|
||||
&& self
|
||||
.active_turn_id_for_thread(root_thread_id)
|
||||
.await
|
||||
.is_some();
|
||||
self.chat_widget.restore_thread_input_state(
|
||||
Some(input_state),
|
||||
ThreadInputStateRestoreMode {
|
||||
@@ -648,8 +667,10 @@ impl App {
|
||||
self.chat_widget.maybe_send_next_queued_input();
|
||||
}
|
||||
}
|
||||
self.maybe_prompt_resume_paused_goal_after_resume(app_server, root_thread_id)
|
||||
.await;
|
||||
if !read_only {
|
||||
self.maybe_prompt_resume_paused_goal_after_resume(app_server, root_thread_id)
|
||||
.await;
|
||||
}
|
||||
|
||||
Ok(AppRunControl::Continue)
|
||||
}
|
||||
|
||||
@@ -2644,31 +2644,82 @@ async fn command_center_handles_resume_failure_and_success() -> Result<()> {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn command_center_attach_conflict_preserves_selection_and_draft() -> Result<()> {
|
||||
async fn command_center_attach_conflict_opens_read_only_and_retries() -> Result<()> {
|
||||
let mut app = Box::pin(make_test_app()).await;
|
||||
trust_fixture_folders(&mut app);
|
||||
std::fs::write(
|
||||
app.config.codex_home.join("config.toml"),
|
||||
"[tui]\nresume_cwd = \"current\"\n",
|
||||
)?;
|
||||
for cwd in [test_path_buf("/"), app.config.cwd.to_path_buf()] {
|
||||
crate::legacy_core::config::set_project_trust_level(
|
||||
app.config.codex_home.as_path(),
|
||||
&cwd,
|
||||
codex_protocol::config_types::TrustLevel::Trusted,
|
||||
)
|
||||
.map_err(std::io::Error::other)?;
|
||||
}
|
||||
let thread_id = ThreadId::from_string(
|
||||
&app_test_support::create_fake_rollout(
|
||||
app.config.codex_home.as_path(),
|
||||
"2025-01-05T12-00-00",
|
||||
"2025-01-05T12:00:00Z",
|
||||
"Saved task",
|
||||
Some(&app.config.model_provider_id),
|
||||
/*git_info*/ None,
|
||||
)
|
||||
.expect("saved task"),
|
||||
)?;
|
||||
let mut owner = Box::pin(crate::start_embedded_app_server_for_picker(&app.config)).await?;
|
||||
let started = Box::pin(owner.start_thread(&app.config)).await?;
|
||||
let thread_id = started.session.thread_id;
|
||||
// Materialize the lazy rollout so the second server can discover the locked task.
|
||||
owner.thread_inject_items(thread_id, vec![serde_json::from_value(serde_json::json!({
|
||||
"type": "message", "role": "assistant", "content": [{"type": "output_text", "text": "Saved task"}]
|
||||
}))?]).await?;
|
||||
Box::pin(owner.resume_thread(
|
||||
&app.local_settings,
|
||||
app.config.clone(),
|
||||
thread_id,
|
||||
crate::app_server_session::ResumeModelSettings::PreserveExistingThread,
|
||||
))
|
||||
.await?;
|
||||
let thread = owner
|
||||
.thread_read(thread_id, /*include_turns*/ false)
|
||||
.await?;
|
||||
let mut server = Box::pin(crate::start_embedded_app_server_for_picker(&app.config)).await?;
|
||||
app.app_server_target = AppServerTarget::LocalDaemon {
|
||||
endpoint: crate::resolve_remote_addr("ws://127.0.0.1:4500")?,
|
||||
};
|
||||
app.chat_widget.remote_connection =
|
||||
crate::status::remote_connection::remote_connection_status_value(
|
||||
&app.app_server_target,
|
||||
/*server_version*/ None,
|
||||
);
|
||||
app.agents_overview
|
||||
.threads
|
||||
.insert(thread_id, Some(thread.clone()));
|
||||
app.chat_widget.insert_str("Retained task draft");
|
||||
app.agents_overview.input_states.insert(
|
||||
thread_id,
|
||||
app.chat_widget
|
||||
.capture_thread_input_state()
|
||||
.expect("task draft"),
|
||||
);
|
||||
app.agents_overview.dispatched_requests.insert(
|
||||
thread_id,
|
||||
vec![ServerRequest::ToolRequestUserInput {
|
||||
request_id: RequestId::Integer(42),
|
||||
params: ToolRequestUserInputParams {
|
||||
thread_id: thread_id.to_string(),
|
||||
turn_id: "turn".into(),
|
||||
item_id: "question".into(),
|
||||
questions: Vec::new(),
|
||||
is_blocking: true,
|
||||
auto_resolution_ms: None,
|
||||
},
|
||||
}],
|
||||
);
|
||||
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
|
||||
app.app_event_tx = AppEventSender::new(tx);
|
||||
let mut view = app.agents_overview_view(vec![thread], Some(thread_id));
|
||||
view.handle_paste("Keep this draft".into());
|
||||
view.handle_key_event(KeyCode::Esc.into());
|
||||
app.chat_widget.show_bottom_pane_view(Box::new(view));
|
||||
let before = render_bottom_popup(&app.chat_widget, /*width*/ 96);
|
||||
let draft = overview_draft(&app);
|
||||
let selection = app
|
||||
.chat_widget
|
||||
@@ -2681,34 +2732,101 @@ async fn command_center_attach_conflict_preserves_selection_and_draft() -> Resul
|
||||
matches!(event, AppEvent::SelectAgentsOverviewThread { thread_id: id } if id == thread_id)
|
||||
);
|
||||
Box::pin(app.handle_event(&mut tui, &mut server, event)).await?;
|
||||
|
||||
assert_eq!(app.chat_widget.thread_id(), Some(thread_id));
|
||||
assert!(app.chat_widget.is_external_writer_view());
|
||||
assert_eq!(
|
||||
app.thread_event_channels[&thread_id].attachment(),
|
||||
ThreadEventAttachment::ExternalWriter
|
||||
);
|
||||
assert_eq!(app.agents_overview.dispatched_requests[&thread_id].len(), 1);
|
||||
assert_eq!(
|
||||
app.chat_widget.composer_text_with_pending(),
|
||||
"Retained task draft"
|
||||
);
|
||||
let turns = app.thread_event_channels[&thread_id]
|
||||
.store
|
||||
.lock()
|
||||
.await
|
||||
.snapshot()
|
||||
.turns;
|
||||
assert!(serde_json::to_string(&turns)?.contains("Saved task"));
|
||||
insta::with_settings!({snapshot_path => "../snapshots"}, {
|
||||
insta::assert_snapshot!("agents_overview_attach_conflict", render_bottom_popup(&app.chat_widget, /*width*/ 96));
|
||||
});
|
||||
app.chat_widget.handle_key_event(KeyCode::Esc.into());
|
||||
assert_eq!(render_bottom_popup(&app.chat_widget, /*width*/ 96), before);
|
||||
|
||||
app.handle_key_event(&mut tui, &mut server, KeyCode::Esc.into())
|
||||
.await;
|
||||
assert_eq!(overview_draft(&app), draft);
|
||||
assert_eq!(
|
||||
app.chat_widget
|
||||
.selected_index_for_present_view(AGENTS_OVERVIEW_VIEW_ID),
|
||||
selection
|
||||
);
|
||||
owner.shutdown().await?;
|
||||
// Once the owner releases the task, the same keyboard path closes the dashboard.
|
||||
app.chat_widget.handle_key_event(KeyCode::Right.into());
|
||||
Box::pin(app.handle_event(&mut tui, &mut server, rx.try_recv()?)).await?;
|
||||
assert_eq!(app.chat_widget.thread_id(), Some(thread_id));
|
||||
app.chat_widget.handle_paste(" and this paste".into());
|
||||
assert_eq!(overview_draft(&app).0, "Keep this draft and this paste");
|
||||
// Opening the displayed task returns to the same frozen snapshot without retrying.
|
||||
Box::pin(app.handle_event(
|
||||
&mut tui,
|
||||
&mut server,
|
||||
AppEvent::SelectAgentsOverviewThread { thread_id },
|
||||
))
|
||||
.await?;
|
||||
assert!(app.chat_widget.no_modal_or_popup_active());
|
||||
assert!(app.chat_widget.is_external_writer_view());
|
||||
app.chat_widget.handle_paste(" should be ignored".into());
|
||||
assert_eq!(
|
||||
app.chat_widget.composer_text_with_pending(),
|
||||
"Retained task draft"
|
||||
);
|
||||
assert_eq!(
|
||||
app.thread_event_channels[&thread_id]
|
||||
.store
|
||||
.lock()
|
||||
.await
|
||||
.snapshot()
|
||||
.turns,
|
||||
turns
|
||||
);
|
||||
|
||||
// Opening the already displayed task also returns to its conversation.
|
||||
let thread = server
|
||||
.thread_read(thread_id, /*include_turns*/ false)
|
||||
.await?;
|
||||
let view = app.agents_overview_view(vec![thread], Some(thread_id));
|
||||
app.chat_widget.show_bottom_pane_view(Box::new(view));
|
||||
while rx.try_recv().is_ok() {}
|
||||
app.chat_widget.handle_key_event(KeyCode::Right.into());
|
||||
Box::pin(app.handle_event(&mut tui, &mut server, rx.try_recv()?)).await?;
|
||||
// An explicit retry remains read-only while the other server owns the task.
|
||||
Box::pin(app.handle_key_event(&mut tui, &mut server, KeyCode::Char('r').into())).await;
|
||||
assert!(app.chat_widget.is_external_writer_view());
|
||||
assert_eq!(
|
||||
app.chat_widget.composer_text_with_pending(),
|
||||
"Retained task draft"
|
||||
);
|
||||
assert!(
|
||||
server
|
||||
.thread_loaded_list(codex_app_server_protocol::ThreadLoadedListParams {
|
||||
cursor: None,
|
||||
limit: None,
|
||||
})
|
||||
.await?
|
||||
.data
|
||||
.is_empty()
|
||||
);
|
||||
|
||||
// Buffered requests stay untouched while viewing; remove the synthetic request before retry.
|
||||
assert_eq!(
|
||||
app.agents_overview
|
||||
.dispatched_requests
|
||||
.remove(&thread_id)
|
||||
.unwrap()
|
||||
.len(),
|
||||
1
|
||||
);
|
||||
owner.shutdown().await?;
|
||||
Box::pin(app.handle_key_event(&mut tui, &mut server, KeyCode::Char('r').into())).await;
|
||||
assert_eq!(app.chat_widget.thread_id(), Some(thread_id));
|
||||
assert!(!app.chat_widget.is_external_writer_view());
|
||||
assert_eq!(
|
||||
app.thread_event_channels[&thread_id].attachment(),
|
||||
ThreadEventAttachment::Live
|
||||
);
|
||||
assert_eq!(
|
||||
app.chat_widget.composer_text_with_pending(),
|
||||
"Retained task draft"
|
||||
);
|
||||
assert!(app.chat_widget.no_modal_or_popup_active());
|
||||
server.shutdown().await?;
|
||||
Ok(())
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use super::*;
|
||||
use crate::app_event::TranscriptExportDestination;
|
||||
use crate::bottom_pane::BottomPaneView;
|
||||
use app_test_support::create_fake_paginated_rollout;
|
||||
use app_test_support::create_fake_parented_rollout_with_source;
|
||||
use app_test_support::create_fake_rollout;
|
||||
@@ -149,6 +150,7 @@ pub(super) enum HistoryCapabilities {
|
||||
LegacyOnlyUnsupportedVariant,
|
||||
LegacyDynamicToolsAndHistory,
|
||||
ForkHydrationFails,
|
||||
ReadAfterResumeFails,
|
||||
ThreadListFails,
|
||||
ThreadStartFails,
|
||||
ConfigReadUnsupported(i64),
|
||||
@@ -358,6 +360,22 @@ pub(super) async fn start_recording_app_server_with_realtime_speech(
|
||||
id: request_id,
|
||||
result: serde_json::json!({}),
|
||||
})
|
||||
} else if history_capabilities == HistoryCapabilities::ReadAfterResumeFails
|
||||
&& request.method == "thread/read"
|
||||
&& request_sink
|
||||
.lock()
|
||||
.expect("request recorder lock")
|
||||
.iter()
|
||||
.any(|recorded| recorded.method == "thread/resume")
|
||||
{
|
||||
JSONRPCMessage::Error(JSONRPCError {
|
||||
id: request_id,
|
||||
error: JSONRPCErrorError {
|
||||
code: -32603,
|
||||
data: None,
|
||||
message: "saved history unavailable".to_string(),
|
||||
},
|
||||
})
|
||||
} else if let HistoryCapabilities::ConfigReadUnsupported(code) =
|
||||
history_capabilities
|
||||
&& request.method == "config/read"
|
||||
@@ -4460,3 +4478,86 @@ async fn external_writer_escape_preserves_snapshot_and_explicit_quits() -> Resul
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn command_center_read_only_open_requests_and_failure_preservation() -> Result<()> {
|
||||
for history_capabilities in [
|
||||
HistoryCapabilities::Current,
|
||||
HistoryCapabilities::ReadAfterResumeFails,
|
||||
] {
|
||||
let (mut app, _codex_home) = Box::pin(make_history_test_app()).await?;
|
||||
std::fs::write(
|
||||
app.config.codex_home.join("config.toml"),
|
||||
"[tui]\nresume_cwd = \"current\"\n",
|
||||
)?;
|
||||
for cwd in [test_path_buf("/"), app.config.cwd.to_path_buf()] {
|
||||
crate::legacy_core::config::set_project_trust_level(
|
||||
app.config.codex_home.as_path(),
|
||||
&cwd,
|
||||
codex_protocol::config_types::TrustLevel::Trusted,
|
||||
)
|
||||
.map_err(std::io::Error::other)?;
|
||||
}
|
||||
let thread_id =
|
||||
create_history_rollout(&app.config, ThreadHistoryMode::Legacy, "Locked task")?;
|
||||
let mut owner = Box::pin(crate::start_embedded_app_server_for_picker(&app.config)).await?;
|
||||
Box::pin(owner.resume_thread(
|
||||
&app.local_settings,
|
||||
app.config.clone(),
|
||||
thread_id,
|
||||
crate::app_server_session::ResumeModelSettings::PreserveExistingThread,
|
||||
))
|
||||
.await?;
|
||||
let (mut server, requests, proxy) = start_recording_app_server_with_history(
|
||||
&app.config,
|
||||
history_capabilities,
|
||||
/*blocked_thread_list*/ None,
|
||||
/*failed_thread_name*/ None,
|
||||
crate::app_server_session::ThreadParamsMode::Embedded,
|
||||
LoaderOverrides::default(),
|
||||
)
|
||||
.await?;
|
||||
app.app_server_target = AppServerTarget::LocalDaemon {
|
||||
endpoint: crate::resolve_remote_addr("ws://127.0.0.1:4500")?,
|
||||
};
|
||||
let current = Box::pin(server.start_thread(&app.config)).await?;
|
||||
let current_id = current.session.thread_id;
|
||||
app.enqueue_primary_thread_session(current.session, current.turns)
|
||||
.await?;
|
||||
let thread = owner
|
||||
.thread_read(thread_id, /*include_turns*/ false)
|
||||
.await?;
|
||||
let mut view = app.agents_overview_view(vec![thread], Some(thread_id));
|
||||
view.handle_paste("Keep this draft".into());
|
||||
app.chat_widget.show_bottom_pane_view(Box::new(view));
|
||||
let before = render_bottom_popup(&app.chat_widget, /*width*/ 96);
|
||||
requests.lock().unwrap().clear();
|
||||
let mut tui = crate::tui::test_support::make_test_tui()?;
|
||||
|
||||
Box::pin(app.select_agents_overview_thread(&mut tui, &mut server, thread_id)).await?;
|
||||
assert_eq!(recorded_params(&requests, "thread/resume").len(), 1);
|
||||
assert!(recorded_params(&requests, "turn/start").is_empty());
|
||||
if history_capabilities == HistoryCapabilities::ReadAfterResumeFails {
|
||||
let error = render_bottom_popup(&app.chat_widget, /*width*/ 96);
|
||||
assert!(
|
||||
error.contains("Failed to view task open elsewhere"),
|
||||
"{error}"
|
||||
);
|
||||
assert_eq!(app.current_displayed_thread_id(), Some(current_id));
|
||||
assert!(recorded_params(&requests, "thread/unsubscribe").is_empty());
|
||||
app.chat_widget.handle_key_event(KeyCode::Esc.into());
|
||||
assert_eq!(render_bottom_popup(&app.chat_widget, /*width*/ 96), before);
|
||||
} else {
|
||||
assert_eq!(app.current_displayed_thread_id(), Some(thread_id));
|
||||
assert!(app.chat_widget.is_external_writer_view());
|
||||
assert_eq!(
|
||||
app.thread_event_channels[&thread_id].attachment(),
|
||||
ThreadEventAttachment::ExternalWriter
|
||||
);
|
||||
}
|
||||
owner.shutdown().await?;
|
||||
server.shutdown().await?;
|
||||
proxy.await??;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -516,7 +516,7 @@ impl ChatWidget {
|
||||
}
|
||||
|
||||
pub(crate) fn handle_paste(&mut self, text: String) {
|
||||
if self.external_writer_view {
|
||||
if self.external_writer_view && !self.bottom_pane.has_active_view() {
|
||||
return;
|
||||
}
|
||||
self.bottom_pane.handle_paste(text);
|
||||
|
||||
+17
-2
@@ -1014,12 +1014,13 @@ pub async fn run_main(
|
||||
explicit_remote_endpoint: Option<RemoteAppServerEndpoint>,
|
||||
) -> std::io::Result<AppExitInfo> {
|
||||
system_motion::initialize().await;
|
||||
match startup_orchestration::run_main_inner(
|
||||
// Keep the startup future out of the CLI caller's frame while the TUI is running.
|
||||
match Box::pin(startup_orchestration::run_main_inner(
|
||||
cli,
|
||||
arg0_paths,
|
||||
loader_overrides,
|
||||
explicit_remote_endpoint,
|
||||
)
|
||||
))
|
||||
.await
|
||||
{
|
||||
Err(err) if startup_draft::StartupCancelled::matches(&err) => Ok(AppExitInfo {
|
||||
@@ -2194,6 +2195,7 @@ pub(crate) mod tests {
|
||||
use super::*;
|
||||
use crate::legacy_core::config::ConfigBuilder;
|
||||
use crate::legacy_core::config::ConfigOverrides;
|
||||
use clap::Parser;
|
||||
use codex_app_server_protocol::AskForApproval;
|
||||
use codex_app_server_protocol::ClientRequest;
|
||||
use codex_app_server_protocol::RequestId;
|
||||
@@ -2205,6 +2207,19 @@ pub(crate) mod tests {
|
||||
use serial_test::serial;
|
||||
use tempfile::TempDir;
|
||||
|
||||
#[test]
|
||||
fn tui_startup_future_stays_bounded() {
|
||||
let future = run_main(
|
||||
Cli::parse_from(["codex"]),
|
||||
Arg0DispatchPaths::default(),
|
||||
LoaderOverrides::default(),
|
||||
/*explicit_remote_endpoint*/ None,
|
||||
);
|
||||
let size = std::mem::size_of_val(&future);
|
||||
|
||||
assert!(size < 64 * 1024, "TUI startup future is {size} bytes");
|
||||
}
|
||||
|
||||
async fn build_config(temp_dir: &TempDir) -> std::io::Result<Config> {
|
||||
ConfigBuilder::default()
|
||||
.codex_home(temp_dir.path().to_path_buf())
|
||||
|
||||
+3
-7
@@ -1,12 +1,8 @@
|
||||
---
|
||||
source: tui/src/app/agents_overview_tests.rs
|
||||
assertion_line: 2231
|
||||
expression: "render_bottom_popup(&app.chat_widget, 96)"
|
||||
---
|
||||
Unable to complete action
|
||||
Task is open elsewhere. Another app server is managing this task, so it can’t be attached
|
||||
here.
|
||||
🔒 This conversation is open in another app R to Retry
|
||||
Close it there and press R to continue here.
|
||||
|
||||
› 1. Return to command center
|
||||
|
||||
Press enter to confirm or esc to go back
|
||||
r retry esc command center ctrl+c/q exit ctrl+t transcript
|
||||
|
||||
Reference in New Issue
Block a user