Harness API messages are accepted but invisible in an already attached TUI
Nobody has claimed this yet.
- Dominant language
- Rust
- Stars
- 19.9k
- Forks
- 2.3k
- Avg merge
- 2d 7h
- Merged PRs (30d)
- 30
Description
Problem
A message submitted by an external client through api-bridge is accepted and appears in the session history, but its text is not added to another TUI already attached to that same session. This makes reviewer/orchestrator instructions invisible to the person watching the terminal.
Observed on macOS arm64 with Homebrew jcode 0.84.0 (v0.84.0-dev (unknown)). The relevant source was checked at upstream 4e85d4423cfd248236044564aa8e4d48ce0ae473. The proposed fix was developed on top of the local scheduled-delivery fix 6a945d3 from #1194.
Reproduction
- Leave a jcode TUI open and idle; resolve that session's ID and daemon socket.
- Start
jcode --no-update --socket <socket> api-bridge --stdio. - Send these JSONL requests sequentially, waiting for the matching
reply_tobefore the next request:
{"v":1,"id":1,"req":"hello","min_version":1,"max_version":1,"client":"visibility-repro"}
{"v":1,"id":2,"req":"attach_session","session_id":"<session-id>"}
{"v":1,"id":3,"req":"send_message","session_id":"<session-id>","content":"External reviewer visibility check","no_reply":true}
{"v":1,"id":4,"req":"get_history","session_id":"<session-id>"}
Actual: request 3 receives ok, request 4 contains the exact message, but the open TUI does not display it. no_reply is useful here because the reproduction needs no model call.
Expected: the other attached TUI immediately displays the accepted user/context message without starting a turn, clearing its draft or changing its active-turn state. Normal external send_message input should also be visible; the submitting client should not receive a duplicate local echo.
For a normal message, acceptance is an uncorrelated message_accepted event; keep the bridge open until turn_done. Disconnect/turn ownership is a separate issue (#977), not the cause of this missing input display.
Cause and proposed fix
append_context_message persists the message and acknowledges only its submitting connection. start_processing_message starts a normal turn, but neither path forwards the accepted input text to the other attached clients as a display event. Streaming assistant output does not supply that missing input text.
The attached patch:
- adds a session-scoped
remote_user_messageevent; - fans accepted normal/context input out to the same session's other
event_txs, excluding the submitting channel; - sends no echo for rejected/busy context requests;
- handles the event in the TUI as a user display message, ignoring other sessions and preserving the input draft and turn state.
Validation
Five focused tests pass: protocol roundtrip; accepted context/observer delivery and busy rejection; normal-turn fanout with sender exclusion; and actual Ratatui TestBackend rendering with draft/turn preservation in idle and active states. The existing isolated-socket scheduled-delivery regression also passes with both local fixes combined.
A default-feature scheduled regression initially timed out in its separate missing-target check; rerunning the same test binary passed. The no-default-features run also passed. The initial timeout's cause is not established. Full workspace CI has not been run.
After installing the combined build and reloading both server and TUI, a fresh no_reply message appeared immediately in the existing terminal, was present in live history, and left the session idle. The user confirmed visibility. The existing scheduled task remained unchanged; its next real scheduled execution is still pending.
This issue concerns external input visibility. #1194 concerns scheduled output/progress/completion and has a different routing cause.
Patch for maintainer review
The repository currently reports pull_request_creation_policy: collaborators_only, and this account has no push permission, so I have not retried PR creation. The proposed production and test diffs are included below for review; this is a local patch, not an upstream merge or release.
Production patch
diff --git a/crates/jcode-app-core/src/server/client_lifecycle.rs b/crates/jcode-app-core/src/server/client_lifecycle.rs
index 639a820..5bd12f3 100644
--- a/crates/jcode-app-core/src/server/client_lifecycle.rs
+++ b/crates/jcode-app-core/src/server/client_lifecycle.rs
@@ -1188,6 +1188,7 @@ pub(super) async fn handle_client(
client_is_processing,
&agent,
&client_event_tx,
+ &swarm_members,
)
.await;
continue;
@@ -3115,6 +3116,7 @@ async fn append_context_message(
client_is_processing: bool,
agent: &Arc<Mutex<Agent>>,
client_event_tx: &mpsc::UnboundedSender<ServerEvent>,
+ swarm_members: &Arc<RwLock<HashMap<String, SwarmMember>>>,
) {
let Ok(mut agent) = agent.try_lock() else {
send_agent_busy_error(
@@ -3135,7 +3137,38 @@ async fn append_context_message(
retry_after_secs: None,
},
};
+ let accepted = matches!(event, ServerEvent::ContextMessageAdded { .. });
let _ = client_event_tx.send(event);
+ drop(agent);
+ if accepted {
+ echo_remote_user_message(swarm_members, client_session_id, client_event_tx, content).await;
+ }
+}
+
+async fn echo_remote_user_message(
+ members: &Arc<RwLock<HashMap<String, SwarmMember>>>,
+ session_id: &str,
+ sender: &mpsc::UnboundedSender<ServerEvent>,
+ content: &str,
+) {
+ let targets = {
+ let members = members.read().await;
+ let Some(member) = members.get(session_id) else {
+ return;
+ };
+ member
+ .event_txs
+ .values()
+ .filter(|tx| !tx.same_channel(sender))
+ .cloned()
+ .collect::<Vec<_>>()
+ };
+ for tx in targets {
+ let _ = tx.send(ServerEvent::RemoteUserMessage {
+ session_id: session_id.to_string(),
+ content: content.to_string(),
+ });
+ }
}
#[allow(clippy::too_many_arguments)]
@@ -3188,6 +3221,8 @@ async fn start_processing_message(
return;
}
+ echo_remote_user_message(swarm.members, client_session_id, client_event_tx, &content).await;
+
*state.client_is_processing = true;
*state.message_id = Some(id);
*state.session_id = Some(client_session_id.to_string());
diff --git a/crates/jcode-protocol/src/wire.rs b/crates/jcode-protocol/src/wire.rs
index d300ced..3943cf1 100644
--- a/crates/jcode-protocol/src/wire.rs
+++ b/crates/jcode-protocol/src/wire.rs
@@ -1045,6 +1045,11 @@ pub enum ServerEvent {
#[serde(rename = "context_message_added")]
ContextMessageAdded { id: u64 },
+ /// Echo an accepted user message to other clients attached to this session.
+ /// The submitting client already displays its own input locally.
+ #[serde(rename = "remote_user_message")]
+ RemoteUserMessage { session_id: String, content: String },
+
/// Error occurred
#[serde(rename = "error")]
Error {
diff --git a/crates/jcode-tui/src/tui/app/remote/server_events.rs b/crates/jcode-tui/src/tui/app/remote/server_events.rs
index 1a2f2be..8e2a3bb 100644
--- a/crates/jcode-tui/src/tui/app/remote/server_events.rs
+++ b/crates/jcode-tui/src/tui/app/remote/server_events.rs
@@ -1499,6 +1499,17 @@ pub(in crate::tui::app) fn handle_server_event(
app.status_notice = Some((format!("Reload: {}", message), std::time::Instant::now()));
false
}
+ ServerEvent::RemoteUserMessage {
+ session_id,
+ content,
+ } => {
+ if app.remote_session_id.as_deref().unwrap_or(&app.session.id) == session_id {
+ app.commit_pending_streaming_assistant_message();
+ app.push_display_message(DisplayMessage::user(content));
+ return true;
+ }
+ false
+ }
ServerEvent::History {
messages,
images,
Focused regression tests
diff --git a/crates/jcode-app-core/src/server/client_lifecycle_tests.rs b/crates/jcode-app-core/src/server/client_lifecycle_tests.rs
index 59e6273..31406e7 100644
--- a/crates/jcode-app-core/src/server/client_lifecycle_tests.rs
+++ b/crates/jcode-app-core/src/server/client_lifecycle_tests.rs
@@ -210,6 +210,35 @@ async fn context_message_persists_without_starting_turn() {
)));
let (client_event_tx, mut client_event_rx) = mpsc::unbounded_channel::<ServerEvent>();
let before = agent.lock().await.message_count();
+ let (observer_tx, mut observer_rx) = mpsc::unbounded_channel();
+ let members = Arc::new(RwLock::new(HashMap::from([(
+ session_id.to_string(),
+ SwarmMember {
+ session_id: session_id.to_string(),
+ event_tx: client_event_tx.clone(),
+ event_txs: HashMap::from([
+ ("origin".to_string(), client_event_tx.clone()),
+ ("observer".to_string(), observer_tx),
+ ]),
+ working_dir: None,
+ swarm_id: None,
+ swarm_enabled: false,
+ status: "ready".to_string(),
+ detail: None,
+ task_label: None,
+ friendly_name: None,
+ report_back_to_session_id: None,
+ latest_completion_report: None,
+ role: "agent".to_string(),
+ joined_at: Instant::now(),
+ last_status_change: Instant::now(),
+ is_headless: false,
+ output_tail: None,
+ todo_progress: None,
+ todo_items: Vec::new(),
+ runtime: crate::protocol::SwarmMemberRuntime::default(),
+ },
+ )])));
append_context_message(
77,
@@ -219,6 +248,7 @@ async fn context_message_persists_without_starting_turn() {
false,
&agent,
&client_event_tx,
+ &members,
)
.await;
@@ -228,6 +258,31 @@ async fn context_message_persists_without_starting_turn() {
));
assert!(client_event_rx.try_recv().is_err());
assert!(!forked.load(Ordering::SeqCst));
+ assert!(matches!(observer_rx.try_recv().unwrap(),
+ ServerEvent::RemoteUserMessage { session_id: target, content }
+ if target == session_id && content == "remember this context"));
+ assert!(observer_rx.try_recv().is_err());
+ let guard = agent.lock().await;
+ append_context_message(
+ 78,
+ "rejected",
+ vec![],
+ session_id,
+ true,
+ &agent,
+ &client_event_tx,
+ &members,
+ )
+ .await;
+ assert!(matches!(
+ client_event_rx.try_recv().unwrap(),
+ ServerEvent::Error { .. }
+ ));
+ assert!(
+ observer_rx.try_recv().is_err(),
+ "rejected input must not be echoed"
+ );
+ drop(guard);
let persisted = crate::session::Session::load(session_id).expect("persisted session");
assert_eq!(persisted.messages.len(), before + 1);
@@ -263,6 +318,7 @@ async fn context_message_rejects_while_busy_without_waiting_for_agent_lock() {
true,
&agent,
&client_event_tx,
+ &Arc::new(RwLock::new(HashMap::new())),
)
.await;
})
@@ -991,7 +1047,7 @@ fn reload_starting_rejects_new_turn_without_spawning_processing_task() {
#[tokio::test]
async fn client_initiated_turn_fans_out_stream_and_terminal_events_to_live_attachments() {
let _guard = crate::storage::lock_test_env();
- let _runtime = IsolatedRuntimeDir::new();
+ let _runtime = IsolatedReloadRecoveryEnv::new();
let session_id = "session_live_attachment_fanout";
let provider: Arc<dyn Provider> = Arc::new(FanoutStreamProvider);
@@ -1004,12 +1060,16 @@ async fn client_initiated_turn_fans_out_stream_and_terminal_events_to_live_attac
let (origin_tx, mut origin_rx) = mpsc::unbounded_channel::<ServerEvent>();
let (attached_tx, mut attached_rx) = mpsc::unbounded_channel::<ServerEvent>();
+ let (echo_tx, mut echo_rx) = mpsc::unbounded_channel();
let swarm_members = Arc::new(RwLock::new(HashMap::from([(
session_id.to_string(),
SwarmMember {
session_id: session_id.to_string(),
event_tx: origin_tx.clone(),
- event_txs: HashMap::from([("origin".to_string(), origin_tx.clone())]),
+ event_txs: HashMap::from([
+ ("origin".to_string(), origin_tx.clone()),
+ ("echo".to_string(), echo_tx),
+ ]),
working_dir: None,
swarm_id: None,
swarm_enabled: false,
@@ -1068,11 +1128,18 @@ async fn client_initiated_turn_fans_out_stream_and_terminal_events_to_live_attac
)
.await;
+ assert!(matches!(echo_rx.try_recv().unwrap(),
+ ServerEvent::RemoteUserMessage { session_id: target, content }
+ if target == session_id && content == "stream to every attachment"));
loop {
let event = tokio::time::timeout(Duration::from_secs(2), origin_rx.recv())
.await
.expect("origin should receive the initial stream event promptly")
.expect("origin event channel should remain open");
+ assert!(
+ !matches!(event, ServerEvent::RemoteUserMessage { .. }),
+ "no duplicate sender echo"
+ );
if matches!(event, ServerEvent::TextDelta { ref text } if text == "before attach") {
break;
}
diff --git a/crates/jcode-protocol/src/protocol_tests/core_events.rs b/crates/jcode-protocol/src/protocol_tests/core_events.rs
index 12ab75b..ca6547d 100644
--- a/crates/jcode-protocol/src/protocol_tests/core_events.rs
+++ b/crates/jcode-protocol/src/protocol_tests/core_events.rs
@@ -637,3 +637,17 @@ fn test_error_event_retry_after_back_compat_default() -> Result<()> {
assert_eq!(retry_after_secs, None);
Ok(())
}
+
+#[test]
+fn remote_user_message_roundtrip() {
+ let event = ServerEvent::RemoteUserMessage {
+ session_id: "review-target".to_string(),
+ content: "[Codex reviewer] 지시\nsecond line".to_string(),
+ };
+ let encoded = serde_json::to_string(&event).unwrap();
+ let decoded: ServerEvent = serde_json::from_str(&encoded).unwrap();
+ assert!(
+ matches!(decoded, ServerEvent::RemoteUserMessage { session_id, content }
+ if session_id == "review-target" && content == "[Codex reviewer] 지시\nsecond line")
+ );
+}
diff --git a/crates/jcode-tui/src/tui/app/tests/remote_events_reload_01/part_01.rs b/crates/jcode-tui/src/tui/app/tests/remote_events_reload_01/part_01.rs
index d972f8e..ed5d3a1 100644
--- a/crates/jcode-tui/src/tui/app/tests/remote_events_reload_01/part_01.rs
+++ b/crates/jcode-tui/src/tui/app/tests/remote_events_reload_01/part_01.rs
@@ -2012,3 +2012,52 @@ fn test_pending_startup_notice_survives_history_bootstrap_for_fresh_session() {
"startup notice should be re-applied exactly once after bootstrap"
);
}
+
+#[test]
+fn remote_user_message_preserves_draft_and_turn_state() {
+ let rt = tokio::runtime::Runtime::new().unwrap();
+ let _guard = rt.enter();
+ for processing in [false, true] {
+ let mut app = create_test_app();
+ let mut remote = crate::tui::backend::RemoteConnection::dummy();
+ app.remote_session_id = Some("review-target".to_string());
+ app.input = "unfinished user draft".to_string();
+ app.cursor_pos = 4;
+ app.is_processing = processing;
+ app.current_message_id = processing.then_some(7);
+ let before = app.display_messages().len();
+ for target in ["other-session", "review-target"] {
+ let redraw = app.handle_server_event(
+ crate::protocol::ServerEvent::RemoteUserMessage {
+ session_id: target.to_string(),
+ content: "[Codex reviewer] preserve this instruction verbatim".to_string(),
+ },
+ &mut remote,
+ );
+ assert_eq!(redraw, target == "review-target");
+ }
+ assert_eq!(app.display_messages().len(), before + 1);
+ assert_eq!(
+ app.display_messages().last().unwrap().content,
+ "[Codex reviewer] preserve this instruction verbatim"
+ );
+ assert_eq!(app.input, "unfinished user draft");
+ assert_eq!(app.cursor_pos, 4);
+ assert_eq!(app.is_processing, processing);
+ assert_eq!(app.current_message_id, processing.then_some(7));
+ let backend = ratatui::backend::TestBackend::new(100, 32);
+ let mut terminal = ratatui::Terminal::new(backend).unwrap();
+ terminal
+ .draw(|frame| crate::tui::ui::draw(frame, &app))
+ .unwrap();
+ let rendered = terminal
+ .backend()
+ .buffer()
+ .content
+ .iter()
+ .map(|cell| cell.symbol())
+ .collect::<String>();
+ assert!(rendered.contains("[Codex reviewer] preserve this instruction verbatim"));
+ assert!(rendered.contains("unfinished user draft"));
+ }
+}
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start with append_context_message and start_processing_message in crates/jcode-app-core/src/server/client_lifecycle.rs, then trace ServerEvent handling in crates/jcode-tui/src/tui/app/remote/server_events.rs. Run the focused client lifecycle, protocol roundtrip, and TUI rendering tests. Done means accepted messages appear in other attached TUIs without echoing to the sender or changing draft and turn state.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- rust
- Domain
- backend, cli
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Active
- Clarity
- Clearly specified
- Newbie friendliness
- 30/100