Skip to content
Merged
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
1 change: 1 addition & 0 deletions changelog.d/session-resume-restore.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Preserve recursive compaction transcripts and nested tool restoration across TUI, ACP, SDK, and headless session resumes.
9 changes: 6 additions & 3 deletions n00n-acp/src/server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ use n00n_providers::TokenUsage;
use n00n_providers::model::Model;
use n00n_providers::provider::available_model_specs;
use n00n_storage::id::{SessionRef, n00nId};
use n00n_storage::sessions::Session;
use n00n_storage::sessions::{Session, TranscriptEntry};
use serde::Serialize;
use serde_json::Value;
use smol::io::AsyncBufReadExt;
Expand Down Expand Up @@ -143,7 +143,7 @@ fn handle_request(srv: &mut Server, method: &str, id: RequestId, raw: &Value, pa
methods::initialize_response(),
)),
"session/new" => parse_params::<NewSessionRequest>(raw).map(|req| {
let handle = spawn_session(params, req.cwd, None, Vec::new());
let handle = spawn_session(params, req.cwd, None, Vec::new(), Vec::new());
let spec = params.model.spec();
let resp = methods::new_session_response(handle.session_id.as_str())
.config_options(vec![methods::model_config_option(&spec, &srv.model_specs)]);
Expand All @@ -161,11 +161,12 @@ fn handle_request(srv: &mut Server, method: &str, id: RequestId, raw: &Value, pa
let (current_mode, plan_path) = mode_and_plan_from_stored(&storage, &stored.meta)
.map_err(|e| AcpError::internal_error().data(json_str(&e)))?;
let history = stored.messages;
let transcript = stored.transcript;
let sid = SessionId::from(session_ref.to_string());
for update in translate::replay_history(&history) {
session_update(&srv.out_tx, &sid, update);
}
let handle = spawn_session(params, req.cwd, Some(session_ref), history);
let handle = spawn_session(params, req.cwd, Some(session_ref), history, transcript);
let spec = params.model.spec();
let resp = methods::load_session_response()
.config_options(vec![methods::model_config_option(&spec, &srv.model_specs)]);
Expand All @@ -188,6 +189,7 @@ fn spawn_session(
cwd: PathBuf,
session_id: Option<SessionRef>,
history: Vec<Message>,
transcript: Vec<TranscriptEntry<Message>>,
) -> InteractiveHandle {
headless::spawn_interactive(InteractiveParams {
model: params.model.clone(),
Expand All @@ -201,6 +203,7 @@ fn spawn_session(
initial_wd: cwd,
session_id,
initial_history: history,
initial_transcript: transcript,
yolo: params.yolo,
system_prompt_override: None,
append_system_prompt: None,
Expand Down
109 changes: 96 additions & 13 deletions n00n-agent/src/headless.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ use n00n_providers::model::Model;
use n00n_providers::provider::{self, Provider};
use n00n_storage::StateDir;
use n00n_storage::id::{SessionRef, n00nId};
use n00n_storage::sessions::{Session, StoredMode};
use n00n_storage::sessions::{Session, StoredMode, TranscriptEntry};
use serde_json::Value;
use tracing::{error, warn};

Expand Down Expand Up @@ -107,13 +107,14 @@ impl SessionStore {

fn record_turn(
&mut self,
messages: &[Message],
history: &History,
model_spec: String,
mode: &AgentMode,
plan_path: Option<&Path>,
) -> Result<(), &'static str> {
self.update_turn_metadata(mode, plan_path)?;
self.session.messages = messages.to_vec();
self.session.messages = history.as_slice().to_vec();
self.session.transcript = history.transcript().to_vec();
self.session.model = model_spec;
self.session.update_title_if_default();
self.save();
Expand Down Expand Up @@ -313,7 +314,7 @@ pub fn spawn(params: HeadlessParams) -> HeadlessHandle {

if let Some(store) = &mut session_store
&& let Err(error) =
store.record_turn(history.as_slice(), model_spec, &mode, plan_path.as_deref())
store.record_turn(&history, model_spec, &mode, plan_path.as_deref())
{
warn!(error, "session metadata was not persisted");
}
Expand Down Expand Up @@ -352,6 +353,7 @@ pub struct InteractiveParams {
pub initial_wd: PathBuf,
pub session_id: Option<SessionRef>,
pub initial_history: Vec<Message>,
pub initial_transcript: Vec<TranscriptEntry<Message>>,
pub yolo: bool,
pub system_prompt_override: Option<String>,
pub append_system_prompt: Option<String>,
Expand Down Expand Up @@ -426,7 +428,10 @@ pub fn spawn_interactive(params: InteractiveParams) -> InteractiveHandle {
));

let mut store = store;
let mut history = History::restored(params.initial_history);
let mut history = History::restored_with_transcript(
params.initial_history,
params.initial_transcript,
);
let mut run_id: u64 = 0;
let mut tool_filter = tool_filter.clone();

Expand Down Expand Up @@ -553,7 +558,7 @@ pub fn spawn_interactive(params: InteractiveParams) -> InteractiveHandle {

if let Some(store) = &mut store
&& let Err(error) = store.record_turn(
history.as_slice(),
&history,
model.spec(),
&turn_mode,
turn_plan_path.as_deref(),
Expand Down Expand Up @@ -593,7 +598,8 @@ fn extract_tool_names(tools: &Value) -> Vec<String> {

#[cfg(test)]
mod tests {
use n00n_storage::sessions::generate_title;
use n00n_providers::{ContentBlock, Role};
use n00n_storage::sessions::{TranscriptEntry, generate_title};
use tempfile::TempDir;

use super::*;
Expand All @@ -620,6 +626,28 @@ mod tests {
StoredSession::load(session_id(), &StateDir::from_path(tmp.path().to_path_buf())).unwrap()
}

fn assistant(text: &str) -> Message {
Message {
role: Role::Assistant,
content: vec![ContentBlock::Text { text: text.into() }],
..Default::default()
}
}

fn recursive_transcript() -> Vec<TranscriptEntry<Message>> {
vec![
TranscriptEntry::Compaction {
entries: vec![TranscriptEntry::Compaction {
entries: vec![TranscriptEntry::Message(Message::user("original".into()))],
generated_summary: Some(assistant("first summary")),
}],
generated_summary: Some(assistant("second summary")),
},
TranscriptEntry::GeneratedMessage(Message::user("summary prompt".into())),
TranscriptEntry::GeneratedMessage(assistant("active summary")),
]
}

#[test]
fn new_session_is_loadable_before_first_turn() {
let tmp = TempDir::new().unwrap();
Expand All @@ -638,9 +666,10 @@ mod tests {
let tmp = TempDir::new().unwrap();
let mut store = store_in(&tmp);
let messages = vec![Message::user("fix the login bug".into())];
let history = History::new(messages.clone());
store
.record_turn(
&messages,
&history,
MODEL_SPEC.into(),
&AgentMode::Plan(PathBuf::from("plan.md")),
None,
Expand All @@ -667,7 +696,7 @@ mod tests {

store
.record_turn(
&[],
&History::new(Vec::new()),
MODEL_SPEC.into(),
&AgentMode::Research,
Some(&build_plan),
Expand All @@ -684,7 +713,7 @@ mod tests {
let mut store = store_in(&tmp);
store
.record_turn(
&[Message::user("first prompt".into())],
&History::new(vec![Message::user("first prompt".into())]),
MODEL_SPEC.into(),
&AgentMode::Plan(PathBuf::from("plan.md")),
None,
Expand All @@ -695,13 +724,13 @@ mod tests {
let mut store = store_in(&tmp);
assert_eq!(store.session.messages.len(), 1);

let messages = vec![
let history = History::new(vec![
Message::user("first prompt".into()),
Message::user("second prompt".into()),
];
]);
store
.record_turn(
&messages,
&history,
"other/model".into(),
&AgentMode::Plan(PathBuf::from("plan.md")),
None,
Expand All @@ -713,6 +742,60 @@ mod tests {
assert_eq!(loaded.model, "other/model");
}

#[test]
fn transcript_only_empty_message_state_is_recoverable() {
let history = History::restored_with_transcript(Vec::new(), recursive_transcript());

assert_eq!(history.len(), 2);
assert_eq!(history.as_slice()[0].user_text(), Some("summary prompt"));
assert!(matches!(
&history.as_slice()[1].content[0],
ContentBlock::Text { text } if text == "active summary"
));
assert!(matches!(
history.transcript(),
[
TranscriptEntry::Compaction { entries, .. },
TranscriptEntry::GeneratedMessage(_),
TranscriptEntry::GeneratedMessage(_),
] if matches!(entries.as_slice(), [TranscriptEntry::Compaction { .. }])
));
}

#[test]
fn resume_continue_reload_preserves_recursive_transcript() {
let tmp = TempDir::new().unwrap();
let mut store = store_in(&tmp);
let mut history = History::restored_with_transcript(Vec::new(), recursive_transcript());
history.push(Message::user("Continue".into()));
history.push(assistant("continued response"));

store
.record_turn(
&history,
MODEL_SPEC.into(),
&AgentMode::Plan(PathBuf::from("plan.md")),
None,
)
.unwrap();
drop(store);

let loaded = load(&tmp);
assert_eq!(loaded.messages.len(), 4);
assert_eq!(loaded.messages[2].user_text(), Some("Continue"));
assert!(matches!(
loaded.transcript.as_slice(),
[
TranscriptEntry::Compaction { entries, .. },
TranscriptEntry::GeneratedMessage(_),
TranscriptEntry::GeneratedMessage(_),
TranscriptEntry::Message(message),
TranscriptEntry::Message(_),
] if matches!(entries.as_slice(), [TranscriptEntry::Compaction { .. }])
&& message.user_text() == Some("Continue")
));
}

#[cfg(unix)]
#[test_case::test_case(())]
fn non_utf8_plan_path_is_rejected_without_mutating_metadata(_case: ()) {
Expand Down
23 changes: 23 additions & 0 deletions n00n-ui/src/app/queue.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ pub(crate) use crate::agent::shared_queue::QueuedMessage;

pub(crate) const EMPTY_PROMPT_ERR: &str = "prompt is empty";
pub(crate) const NO_QUEUE_ERR: &str = "session cannot queue messages";
pub(crate) const NEW_SESSION_NOT_IDLE_ERR: &str = "new session is not idle";

pub(crate) enum SubmitOutcome {
Started(Vec<Action>),
Expand Down Expand Up @@ -273,6 +274,28 @@ impl App {
}
}

pub(crate) fn prepare_new_session_prompt(
&mut self,
prompt: Option<String>,
) -> Result<Vec<Action>, &'static str> {
let Some(text) = prompt else {
return Ok(Vec::new());
};
if !matches!(self.status, Status::Idle) {
return Err(NEW_SESSION_NOT_IDLE_ERR);
}
let outcome = self.submit_background_prompt(QueuedMessage {
text,
images: Vec::new(),
control: false,
});
match outcome {
SubmitOutcome::Started(actions) => Ok(actions),
SubmitOutcome::Queued => Err(NEW_SESSION_NOT_IDLE_ERR),
SubmitOutcome::Rejected(error) => Err(error),
}
}

pub(super) fn submit_or_queue(&mut self, msg: QueuedMessage) -> Vec<Action> {
match self.submit_prompt(msg) {
SubmitOutcome::Started(actions) => actions,
Expand Down
45 changes: 45 additions & 0 deletions n00n-ui/src/app/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -287,6 +287,51 @@ fn rapid_submissions_keep_fifo_while_first_waits_for_persistence() {
assert_eq!(second.message, "second");
}

#[test]
fn new_session_prompt_rejection_leaves_blank_app_unchanged() {
let mut app = test_app();
let session_id = app.state.session.id;

let Err(error) = app.prepare_new_session_prompt(Some(" ".into())) else {
panic!("blank new-session prompt must be rejected");
};

assert_eq!(error, queue::EMPTY_PROMPT_ERR);
assert_eq!(app.state.session.id, session_id);
assert!(!app.has_content());
assert!(matches!(app.status, Status::Idle));
}

#[test]
fn new_session_without_prompt_allows_blank_app() {
let mut app = test_app();

let actions = app
.prepare_new_session_prompt(None)
.expect("missing prompt must allow blank session creation");

assert!(actions.is_empty());
assert!(!app.has_content());
assert!(matches!(app.status, Status::Idle));
}

#[test]
fn new_session_prompt_starts_only_from_idle() {
let mut app = test_app();
let actions = app
.prepare_new_session_prompt(Some("start work".into()))
.expect("idle new-session prompt must start");
assert!(matches!(actions.as_slice(), [Action::SendMessage(_)]));

let mut busy = test_app();
busy.status = Status::Streaming;
let Err(error) = busy.prepare_new_session_prompt(Some("do not queue".into())) else {
panic!("busy new session must reject its initial prompt");
};
assert_eq!(error, queue::NEW_SESSION_NOT_IDLE_ERR);
assert!(busy.queue.is_empty());
}

#[test]
fn session_api_prompt_is_explicitly_non_paint_gated() {
let mut app = test_app();
Expand Down
Loading
Loading