use super::*;
use std::sync::Mutex;
#[derive(Default)]
struct RecordingArtifactSink {
writes: Mutex<Vec<newt_core::NewPromptArtifact>>,
}
impl RecordingArtifactSink {
fn artifacts(&self) -> Vec<newt_core::NewPromptArtifact> {
self.writes.lock().unwrap().clone()
}
}
impl newt_core::agentic::PromptArtifactSink for RecordingArtifactSink {
fn append_artifact(
&self,
originating_prompt_id: newt_core::PromptId,
objective_root_id: newt_core::PromptId,
artifact: newt_core::NewPromptArtifact,
) -> anyhow::Result<newt_core::agentic::ArtifactReadRecord> {
let mut writes = self.writes.lock().unwrap();
writes.push(artifact.clone());
Ok(newt_core::agentic::ArtifactReadRecord {
id: newt_core::ArtifactId::new(),
prompt_id: originating_prompt_id,
root_prompt_id: objective_root_id,
writer_fingerprint: "test-writer".to_string(),
seq: writes.len() as u64,
prev_hash: "prev".to_string(),
kind: format!("{:?}", artifact.kind()),
relation: format!("{:?}", artifact.relation()),
locator: artifact.locator().map(str::to_string),
body: artifact.body().map(str::to_string),
metadata: artifact.metadata().clone(),
ts_claim: 1,
artifact_hash: "hash".to_string(),
})
}
}
struct Fixture {
_root: tempfile::TempDir,
_ws: tempfile::TempDir,
store: newt_core::ConversationStore,
conversation_id: String,
memory: newt_core::MemoryManager,
scratchpad_store: newt_core::SessionScratchpadStore,
step_ledger: newt_core::SessionStepLedger,
pricing: newt_core::PricingConfig,
sink: RecordingArtifactSink,
turn: newt_core::TurnPromptContext,
}
fn fixture(conversation_id: &str) -> Fixture {
let root = tempfile::tempdir().unwrap();
let ws = tempfile::tempdir().unwrap();
let store = newt_core::ConversationStore::new(root.path(), ws.path(), 100).unwrap();
let turn = newt_core::TurnPromptContext::ephemeral_operator(
conversation_id.to_string(),
b"continue".to_vec(),
b"continue".to_vec(),
);
Fixture {
_root: root,
_ws: ws,
store,
conversation_id: conversation_id.to_string(),
memory: newt_core::MemoryManager::new(),
scratchpad_store: newt_core::SessionScratchpadStore::default(),
step_ledger: newt_core::SessionStepLedger::default(),
pricing: newt_core::PricingConfig::default(),
sink: RecordingArtifactSink::default(),
turn,
}
}
fn a_tool_event() -> newt_core::ToolEvent {
newt_core::ToolEvent {
tool: "read_file".to_string(),
args_digest: "keys=path;abc123".to_string(),
ok: true,
duration_ms: Some(42),
execution: None,
from_cache: false,
}
}
#[derive(Clone, Default)]
struct RecordingMemoryProvider(std::sync::Arc<Mutex<Vec<(String, String)>>>);
impl RecordingMemoryProvider {
fn calls(&self) -> Vec<(String, String)> {
self.0.lock().unwrap().clone()
}
}
#[async_trait::async_trait]
impl newt_core::MemoryProvider for RecordingMemoryProvider {
fn name(&self) -> &str {
"recording_memory_provider"
}
fn build_messages(&self, _system_prompt: &str, _new_task: &str) -> Vec<newt_core::MemMessage> {
Vec::new()
}
async fn sync_turn(&mut self, user: &str, assistant: &str, _metrics: &newt_core::TurnMetrics) {
self.0
.lock()
.unwrap()
.push((user.to_string(), assistant.to_string()));
}
}
#[tokio::test(flavor = "multi_thread")]
async fn cancelled_turn_persists_a_row_and_outcome_with_real_partial_usage() {
let mut f = fixture("conv-cancel-test");
assert!(
!f.store.exists(&f.conversation_id).unwrap(),
"the conversation must not exist before the interrupted turn is persisted"
);
let memory_calls = RecordingMemoryProvider::default();
f.memory.add_provider(memory_calls.clone());
let tool_events = vec![a_tool_event()];
let real_usage = newt_core::TokenUsage {
input_tokens: 12_345,
output_tokens: 678,
};
let rt = tokio::runtime::Handle::current();
persist_incomplete_turn(
Some(&f.store),
&f.conversation_id,
None,
"continue",
"partial streamed answer before the interrupt",
&tool_events,
&[],
Some(real_usage),
newt_core::attempts::UsageTotals {
attempts: 1,
usage_missing: 1,
in_tokens: 0,
out_tokens: 0,
usage_complete: false,
},
0,
newt_core::TurnEndReason::Cancelled,
std::time::Duration::from_millis(4200),
"test-model",
newt_core::BackendKind::Ollama,
"http://test-endpoint",
&f.pricing,
&mut f.memory,
&f.scratchpad_store,
&f.step_ledger,
Some(&f.sink as &dyn newt_core::agentic::PromptArtifactSink),
Some(&f.turn),
None,
None,
&rt,
false,
false,
);
let record = f.store.load(&f.conversation_id).unwrap();
assert_eq!(
record.turns.len(),
1,
"exactly one turns row — not zero (the #1963 bug) and not two (a double write)"
);
let saved = &record.turns[0];
assert_eq!(
saved.assistant,
"partial streamed answer before the interrupt"
);
assert_eq!(
saved.tokens_in,
Some(12_345),
"real accumulated usage, not NULL"
);
assert_eq!(saved.tokens_out, Some(678));
assert_eq!(
saved.events.len(),
1,
"the real tool-event ledger, not dropped"
);
assert_eq!(saved.events[0].tool, "read_file");
let artifacts = f.sink.artifacts();
let outcome = artifacts
.iter()
.find(|a| a.kind() == newt_core::ArtifactKind::TurnOutcome)
.expect("a turn_outcome artifact must be recorded for a cancelled turn");
assert_eq!(outcome.metadata()["end_reason"], "cancelled");
assert_eq!(outcome.metadata()["usage"]["input_tokens"], 12_345);
assert_eq!(outcome.metadata()["usage"]["output_tokens"], 678);
let synced = memory_calls.calls();
assert_eq!(
synced.len(),
1,
"memory.sync_all_with_active_task must run on the cancel path too — it used to be Ok-only, \
which lost the interrupted segment from resume context, not just forensics"
);
assert_eq!(synced[0].1, "partial streamed answer before the interrupt");
}
#[tokio::test(flavor = "multi_thread")]
async fn failed_turn_persists_with_null_usage_never_a_fabricated_zero() {
let mut f = fixture("conv-err-test");
let tool_events = vec![a_tool_event(), a_tool_event()];
let rt = tokio::runtime::Handle::current();
persist_incomplete_turn(
Some(&f.store),
&f.conversation_id,
None,
"continue",
"",
&tool_events,
&[],
None,
newt_core::attempts::UsageTotals {
attempts: 1,
usage_missing: 1,
in_tokens: 0,
out_tokens: 0,
usage_complete: false,
},
0,
newt_core::TurnEndReason::Failed,
std::time::Duration::from_millis(900),
"test-model",
newt_core::BackendKind::Ollama,
"http://test-endpoint",
&f.pricing,
&mut f.memory,
&f.scratchpad_store,
&f.step_ledger,
Some(&f.sink as &dyn newt_core::agentic::PromptArtifactSink),
Some(&f.turn),
None,
None,
&rt,
false,
false,
);
let record = f.store.load(&f.conversation_id).unwrap();
assert_eq!(record.turns.len(), 1);
let saved = &record.turns[0];
assert_eq!(
saved.tokens_in, None,
"no fabricated usage on a genuine error"
);
assert_eq!(saved.tokens_out, None);
assert_eq!(
saved.events.len(),
2,
"the real tool-event ledger survives the failure"
);
let artifacts = f.sink.artifacts();
let outcome = artifacts
.iter()
.find(|a| a.kind() == newt_core::ArtifactKind::TurnOutcome)
.expect("a turn_outcome artifact must be recorded for a failed turn too");
assert_eq!(outcome.metadata()["end_reason"], "failed");
assert_eq!(
outcome.metadata()["usage"],
serde_json::Value::Null,
"NULL usage is the honest value for an unrecoverable error, not 0"
);
}
#[test]
fn an_interrupted_unstreamed_reply_is_persisted_empty_with_its_usage() {
let usage = Some(newt_core::TokenUsage {
input_tokens: 10,
output_tokens: 3,
});
let unstreamed: anyhow::Result<_> = Ok(("never shown".to_string(), false, usage, 1));
assert_eq!(interrupted_turn_record(&unstreamed), ("", usage, 1));
let streamed: anyhow::Result<_> = Ok(("on screen".to_string(), true, usage, 0));
assert_eq!(interrupted_turn_record(&streamed), ("on screen", usage, 0));
let failed: anyhow::Result<(String, bool, Option<newt_core::TokenUsage>, u32)> =
Err(anyhow::anyhow!("boom"));
assert_eq!(interrupted_turn_record(&failed), ("", None, 0));
}