Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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