From 2ea3a8e7d9efbeeedd3e6db7e97f7942690e8946 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 8 Jun 2026 12:07:16 +0000 Subject: [PATCH] fix: restore OpenCode user messages after app restart MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OpenCode journal replay ignored all user JSONL lines to suppress live echoes, which also dropped persisted prompts and follow-ups on restore. Replay Orbit-format user lines and skip only immediate duplicates. Co-authored-by: José Fernando --- CHANGELOG.md | 3 + tauri/src/journal/processor.rs | 109 +++++++++++++++++++++++++- tauri/src/services/process_util.rs | 4 + tauri/src/services/session_manager.rs | 44 +++++++++++ 4 files changed, 158 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 38f63c9..4258035 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,9 @@ ## May 2026 +### 06/08 · Fix — OpenCode chat history keeps your messages after restart +OpenCode sessions again show your initial prompt and follow-up messages after you restart Orbit. A recent change that hid duplicate live echoes had also dropped every saved user message when reopening a session. + ### 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/processor.rs b/tauri/src/journal/processor.rs index 5d5f994..46dda10 100644 --- a/tauri/src/journal/processor.rs +++ b/tauri/src/journal/processor.rs @@ -684,8 +684,9 @@ pub fn process_line_opencode(state: &mut JournalState, line: &str) { } } - // Initial user prompt is injected by create_session; OpenCode may echo user JSONL. - "user" => {} + // Orbit persists user prompts in Claude-style JSONL; OpenCode may echo the same line + // on stdout. Replay from DB, but skip an immediate duplicate (live echo). + "user" => replay_opencode_user_line(state, &val), _ => { process_line(state, line); @@ -693,6 +694,43 @@ pub fn process_line_opencode(state: &mut JournalState, line: &str) { } } +/// Restore Orbit-persisted user prompts for OpenCode sessions without duplicating live echoes. +fn replay_opencode_user_line(state: &mut JournalState, val: &Value) { + let Some(content) = val.pointer("/message/content").and_then(|c| c.as_str()) else { + return; + }; + if content.is_empty() { + return; + } + + let is_immediate_echo = state.entries.last().is_some_and(|entry| { + entry.entry_type == JournalEntryType::User + && entry + .text + .as_deref() + .is_some_and(|text| text.trim() == content.trim()) + }); + if is_immediate_echo { + return; + } + + let timestamp = val + .get("timestamp") + .and_then(|v| v.as_str()) + .map(String::from) + .unwrap_or_else(|| chrono::Utc::now().to_rfc3339()); + + state.last_activity = Some(timestamp.clone()); + state.entries.push(JournalEntry { + timestamp, + entry_type: JournalEntryType::User, + text: Some(content.to_string()), + ..JournalEntry::default() + }); + state.status = AgentStatus::Working; + state.pending_approval = None; +} + /// Extract the inner command from a Codex PowerShell wrapper. /// Codex on Windows wraps commands as: /// `"C:\\WINDOWS\\...\\powershell.exe" -Command 'echo hello'` @@ -1369,6 +1407,73 @@ mod process_line_opencode_tests { t.eq("cache read", state.cache_read, 4u64); } + #[test] + fn should_restore_orbit_persisted_user_line_for_opencode() { + let mut t = TestCase::new("should_restore_orbit_persisted_user_line_for_opencode"); + t.phase("Act"); + let mut state = JournalState::default(); + process_line_opencode( + &mut state, + r#"{"type":"user","message":{"content":"Fix the OpenCode feed"},"timestamp":"2026-01-01T00:00:01Z"}"#, + ); + t.phase("Assert"); + t.len("one user entry", &state.entries, 1); + t.eq( + "user text restored", + state.entries[0].text.as_deref(), + Some("Fix the OpenCode feed"), + ); + t.eq( + "entry type", + state.entries[0].entry_type, + JournalEntryType::User, + ); + } + + #[test] + fn should_skip_immediate_opencode_user_echo() { + let mut t = TestCase::new("should_skip_immediate_opencode_user_echo"); + t.phase("Act"); + let mut state = JournalState::default(); + state.entries.push(JournalEntry { + entry_type: JournalEntryType::User, + text: Some("Already in journal".to_string()), + ..JournalEntry::default() + }); + process_line_opencode( + &mut state, + r#"{"type":"user","message":{"content":"Already in journal"}}"#, + ); + t.phase("Assert"); + t.len("still one user entry", &state.entries, 1); + } + + #[test] + fn should_restore_follow_up_user_after_assistant_reply() { + let mut t = TestCase::new("should_restore_follow_up_user_after_assistant_reply"); + t.phase("Act"); + let mut state = JournalState::default(); + process_line_opencode( + &mut state, + r#"{"type":"user","message":{"content":"First prompt"}}"#, + ); + process_line_opencode( + &mut state, + r#"{"type":"text","part":{"type":"text","text":"Done"}}"#, + ); + process_line_opencode( + &mut state, + r#"{"type":"user","message":{"content":"Follow up"}}"#, + ); + t.phase("Assert"); + t.len("two user entries and one assistant", &state.entries, 3); + t.eq( + "follow-up text restored", + state.entries[2].text.as_deref(), + Some("Follow up"), + ); + } + #[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..b5390a7 100644 --- a/tauri/src/services/session_manager.rs +++ b/tauri/src/services/session_manager.rs @@ -1762,6 +1762,50 @@ mod tests { ); } + #[test] + fn should_restore_opencode_user_messages_from_db() { + let mut t = TestCase::new("should_restore_opencode_user_messages_from_db"); + t.phase("Seed"); + let db = make_db(); + let sid = db + .create_session( + None, + None, + "/tmp", + "ignore", + None, + Some("opencode"), + None, + None, + ) + .expect("session"); + seed_outputs( + &db, + sid, + &[ + &crate::test_utils::user_text("Initial OpenCode prompt"), + &crate::test_utils::user_text("Initial OpenCode prompt"), + &crate::test_utils::user_text("Follow-up after restart"), + ], + ); + 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("echo deduped, follow-up kept", &journal, 2); + t.eq( + "initial prompt restored", + journal[0].text.as_deref(), + Some("Initial OpenCode prompt"), + ); + t.eq( + "follow-up restored", + journal[1].text.as_deref(), + Some("Follow-up after restart"), + ); + } + #[test] fn should_not_duplicate_entries_on_double_restore() { let mut t = TestCase::new("should_not_duplicate_entries_on_double_restore");