Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
109 changes: 107 additions & 2 deletions tauri/src/journal/processor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -684,15 +684,53 @@ 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);
}
}
}

/// 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'`
Expand Down Expand Up @@ -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");
Expand Down
4 changes: 4 additions & 0 deletions tauri/src/services/process_util.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
44 changes: 44 additions & 0 deletions tauri/src/services/session_manager.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
Loading