diff --git a/CHANGELOG.md b/CHANGELOG.md index 38f63c9..0f728c0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,9 @@ ## May 2026 +### 06/01 · Fix — Follow-up messages restore for OpenCode and avoid duplicates +Follow-up prompts you send in OpenCode sessions now reappear correctly after you restart Orbit. Claude sessions no longer show the same follow-up message twice when the CLI echoes it back in the stream. + ### 05/25 · Fix — No flashing console windows on Windows Sending messages, watching git status, and spawning agent CLIs no longer briefly opens a black terminal window in the background. diff --git a/tauri/src/journal/mod.rs b/tauri/src/journal/mod.rs index 570253c..6c819ba 100644 --- a/tauri/src/journal/mod.rs +++ b/tauri/src/journal/mod.rs @@ -6,4 +6,5 @@ pub use parser::parse_journal; pub use processor::process_line; pub use processor::process_line_codex; pub use processor::process_line_opencode; +pub use processor::{injected_user_text, is_duplicate_injected_user_line, push_injected_user_line}; pub use state::JournalState; diff --git a/tauri/src/journal/processor.rs b/tauri/src/journal/processor.rs index 5d5f994..e46c652 100644 --- a/tauri/src/journal/processor.rs +++ b/tauri/src/journal/processor.rs @@ -114,6 +114,50 @@ fn count_changed_lines(old_text: &str, new_text: &str) -> LinesChanged { /// Process a single raw JSONL line from PTY stdout and update state. /// This is the real-time counterpart to parse_journal (which reads files). +/// Plain-text user JSONL that Orbit injects before spawn (`create_session` / `send_message`). +pub fn injected_user_text(line: &str) -> Option { + let val: serde_json::Value = serde_json::from_str(line.trim()).ok()?; + if val.get("type").and_then(|v| v.as_str()) != Some("user") { + return None; + } + let content = val.get("message")?.get("content")?; + let text = content.as_str()?; + if text.trim().is_empty() { + None + } else { + Some(text.to_string()) + } +} + +/// True when `line` is a plain-text user echo Orbit already showed (not tool_result blocks). +pub fn is_duplicate_injected_user_line(state: &JournalState, line: &str) -> bool { + let Some(text) = injected_user_text(line) else { + return false; + }; + state.entries.iter().rev().any(|e| { + e.entry_type == JournalEntryType::User + && e.text + .as_deref() + .is_some_and(|existing| existing.trim() == text.trim()) + }) +} + +/// Restore a persisted Orbit user prompt into the journal (used by OpenCode on DB replay). +pub fn push_injected_user_line(state: &mut JournalState, line: &str) -> bool { + let Some(text) = injected_user_text(line) else { + return false; + }; + if is_duplicate_injected_user_line(state, line) { + return false; + } + state.entries.push(JournalEntry { + entry_type: JournalEntryType::User, + text: Some(text), + ..JournalEntry::default() + }); + true +} + pub fn process_line(state: &mut JournalState, line: &str) { let trimmed = line.trim(); if trimmed.is_empty() { @@ -684,8 +728,10 @@ pub fn process_line_opencode(state: &mut JournalState, line: &str) { } } - // Initial user prompt is injected by create_session; OpenCode may echo user JSONL. - "user" => {} + // OpenCode JSONL has no user events; Orbit persists prompts in Claude-compatible JSONL. + "user" => { + push_injected_user_line(state, line); + } _ => { process_line(state, line); @@ -956,6 +1002,25 @@ pub fn process_line_codex(state: &mut JournalState, line: &str) { } } +#[cfg(test)] +mod injected_user_line_tests { + use super::*; + use crate::test_utils::{user_text, TestCase}; + + #[test] + fn should_detect_duplicate_injected_user_line() { + let mut t = TestCase::new("should_detect_duplicate_injected_user_line"); + t.phase("Act"); + let mut state = JournalState::default(); + process_line(&mut state, &user_text("hello")); + let dup = is_duplicate_injected_user_line(&state, &user_text("hello")); + let tool = is_duplicate_injected_user_line(&state, &crate::test_utils::tool_result("out")); + t.phase("Assert"); + t.eq("duplicate plain user", dup, true); + t.eq("tool_result is not a duplicate", tool, false); + } +} + #[cfg(test)] mod process_line_tests { use super::*; @@ -1369,6 +1434,36 @@ mod process_line_opencode_tests { t.eq("cache read", state.cache_read, 4u64); } + #[test] + fn should_restore_orbit_persisted_user_prompt_on_opencode_replay() { + let mut t = TestCase::new("should_restore_orbit_persisted_user_prompt_on_opencode_replay"); + t.phase("Act"); + let mut state = JournalState::default(); + process_line_opencode( + &mut state, + r#"{"type":"user","message":{"content":"Fix the tests"},"timestamp":"2026-01-01T00:00:00Z"}"#, + ); + t.phase("Assert"); + t.len("one user entry", &state.entries, 1); + t.eq( + "text restored", + state.entries[0].text.as_deref(), + Some("Fix the tests"), + ); + } + + #[test] + fn should_not_duplicate_opencode_user_on_replay() { + let mut t = TestCase::new("should_not_duplicate_opencode_user_on_replay"); + t.phase("Act"); + let mut state = JournalState::default(); + let line = r#"{"type":"user","message":{"content":"same prompt"}}"#; + process_line_opencode(&mut state, line); + process_line_opencode(&mut state, line); + t.phase("Assert"); + t.len("still one user entry", &state.entries, 1); + } + #[test] fn should_not_create_empty_entry_for_opencode_space_text_sequence() { let mut t = TestCase::new("should_not_create_empty_entry_for_opencode_space_text_sequence"); diff --git a/tauri/src/services/process_util.rs b/tauri/src/services/process_util.rs index 1f5b6f6..fb5bfec 100644 --- a/tauri/src/services/process_util.rs +++ b/tauri/src/services/process_util.rs @@ -12,6 +12,10 @@ pub fn apply_silent(cmd: &mut Command) { use std::os::windows::process::CommandExt; cmd.creation_flags(CREATE_NO_WINDOW); } + #[cfg(not(windows))] + { + let _ = cmd; + } } fn is_windows_script_shim(path: &str) -> bool { diff --git a/tauri/src/services/session_manager.rs b/tauri/src/services/session_manager.rs index d8520f9..7e23f6c 100644 --- a/tauri/src/services/session_manager.rs +++ b/tauri/src/services/session_manager.rs @@ -767,6 +767,16 @@ impl SessionManager { ); } + let skip_duplicate_user = { + let m = manager.read().unwrap_or_else(|e| e.into_inner()); + m.journal_states.get(&session_id).is_some_and(|s| { + crate::journal::is_duplicate_injected_user_line(s, &trimmed) + }) + }; + if skip_duplicate_user { + continue; + } + let _ = db.insert_output(session_id, &trimmed); let _ = app.emit( "session:raw-output", @@ -1731,6 +1741,41 @@ mod tests { ); } + #[test] + fn should_restore_follow_up_user_message_for_opencode_from_db() { + let mut t = TestCase::new("should_restore_follow_up_user_message_for_opencode_from_db"); + t.phase("Seed"); + let db = make_db(); + let sid = db + .create_session( + None, + None, + "/tmp", + "ignore", + None, + None, + Some("opencode"), + Some("openrouter/minimax"), + ) + .expect("session"); + seed_outputs( + &db, + sid, + &[&crate::test_utils::user_text("Also fix OpenCode history")], + ); + t.phase("Act"); + let mut sm = SessionManager::new(Arc::clone(&db)); + sm.restore_from_db(); + t.phase("Assert"); + let journal = sm.get_journal(sid); + t.len("one user entry", &journal, 1); + t.eq( + "follow-up text restored", + journal[0].text.as_deref(), + Some("Also fix OpenCode history"), + ); + } + #[test] fn should_restore_follow_up_user_message_from_db() { let mut t = TestCase::new("should_restore_follow_up_user_message_from_db");