mirror of
https://github.com/andrewyng/openworker.git
synced 2026-11-04 21:37:21 +00:00
713 lines
26 KiB
Rust
713 lines
26 KiB
Rust
use super::*;
|
|
use crate::provider::{Completion, Error, Failure};
|
|
use std::sync::atomic::AtomicUsize;
|
|
|
|
#[tokio::test]
|
|
async fn live_tool_access_reaches_model_and_refreshes_on_retry_without_entering_history() {
|
|
use openworker_tools::{Tool, ToolContext, ToolDescriptor, ToolFuture};
|
|
struct Access(Arc<AtomicBool>);
|
|
impl Tool for Access {
|
|
fn descriptor(&self) -> ToolDescriptor {
|
|
ToolDescriptor {
|
|
qualified_name: "test.access".into(),
|
|
description: "test".into(),
|
|
version: "1".into(),
|
|
input_schema: json!({"type":"object"}),
|
|
}
|
|
}
|
|
fn call(&self, _: ToolContext, _: Value) -> ToolFuture<'_> {
|
|
Box::pin(async { unreachable!() })
|
|
}
|
|
fn model_context(&self, _: &str) -> Option<Value> {
|
|
Some(json!({"enabled": self.0.load(Ordering::SeqCst)}))
|
|
}
|
|
}
|
|
let enabled = Arc::new(AtomicBool::new(true));
|
|
let calls = Arc::new(AtomicUsize::new(0));
|
|
let (access, count) = (enabled.clone(), calls.clone());
|
|
let mut model = Model::new("test".into(), true);
|
|
model.sample_hook = Some(Arc::new(move |instructions, _, tx| {
|
|
let (access, count) = (access.clone(), count.clone());
|
|
Box::pin(async move {
|
|
assert!(instructions.contains("supersede older access errors"));
|
|
if count.fetch_add(1, Ordering::SeqCst) == 0 {
|
|
assert!(instructions.contains(r#""test.access":{"enabled":true}"#));
|
|
access.store(false, Ordering::SeqCst);
|
|
return Err(Error::new(Failure::Transport).into());
|
|
}
|
|
assert!(instructions.contains(r#""test.access":{"enabled":false}"#));
|
|
tx.send(Progress::Complete(Completion {
|
|
output: vec![],
|
|
usage: None,
|
|
}))
|
|
.await
|
|
.unwrap();
|
|
Ok(())
|
|
})
|
|
}));
|
|
let (_tmp, mut rt, sid) = fixture(model, true).await;
|
|
let bundle = &mut Arc::get_mut(&mut rt).unwrap().bundle;
|
|
bundle.registry.register(Access(enabled)).unwrap();
|
|
bundle.tools.bindings.insert(
|
|
"test__access".into(),
|
|
crate::bundle::Binding {
|
|
server: "test".into(),
|
|
tool: "test.access".into(),
|
|
},
|
|
);
|
|
let old_history = rt.store.lock().unwrap().get(&sid).unwrap().history;
|
|
rt.sample(&sid, &[], &[]).await.unwrap();
|
|
assert_eq!(calls.load(Ordering::SeqCst), 2);
|
|
assert_eq!(
|
|
rt.store.lock().unwrap().get(&sid).unwrap().history,
|
|
old_history
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn image_consent_is_rechecked_on_each_provider_retry_without_journaling_pixels() {
|
|
use openworker_tools::{Tool, ToolContext, ToolDescriptor, ToolFuture, ToolImage};
|
|
struct ImageGate(Arc<AtomicBool>);
|
|
impl Tool for ImageGate {
|
|
fn descriptor(&self) -> ToolDescriptor {
|
|
ToolDescriptor {
|
|
qualified_name: "test.image".into(),
|
|
description: "test".into(),
|
|
version: "1".into(),
|
|
input_schema: json!({"type":"object"}),
|
|
}
|
|
}
|
|
fn call(&self, _: ToolContext, _: Value) -> ToolFuture<'_> {
|
|
Box::pin(async { unreachable!() })
|
|
}
|
|
fn images_allowed(&self, _: &str) -> bool {
|
|
self.0.load(Ordering::SeqCst)
|
|
}
|
|
}
|
|
let consent = Arc::new(AtomicBool::new(true));
|
|
let calls = Arc::new(AtomicUsize::new(0));
|
|
let (allowed, count) = (consent.clone(), calls.clone());
|
|
let mut model = Model::new("test".into(), true);
|
|
model.sample_hook = Some(Arc::new(move |_, history, tx| {
|
|
let (allowed, count) = (allowed.clone(), count.clone());
|
|
Box::pin(async move {
|
|
if count.fetch_add(1, Ordering::SeqCst) == 0 {
|
|
assert_eq!(history[0]["output"][1]["type"], "input_image");
|
|
allowed.store(false, Ordering::SeqCst);
|
|
return Err(Error::new(Failure::Transport).into());
|
|
}
|
|
assert!(history[0]["output"]
|
|
.as_str()
|
|
.unwrap()
|
|
.contains("no longer available"));
|
|
tx.send(Progress::Complete(Completion {
|
|
output: vec![],
|
|
usage: None,
|
|
}))
|
|
.await
|
|
.unwrap();
|
|
Ok(())
|
|
})
|
|
}));
|
|
let (_tmp, mut rt, sid) = fixture(model, true).await;
|
|
Arc::get_mut(&mut rt)
|
|
.unwrap()
|
|
.bundle
|
|
.registry
|
|
.register(ImageGate(consent))
|
|
.unwrap();
|
|
let meta = rt
|
|
.images
|
|
.lock()
|
|
.unwrap()
|
|
.put(
|
|
&sid,
|
|
"test.image",
|
|
vec![ToolImage {
|
|
mime_type: "image/jpeg".into(),
|
|
base64: "/9j/2Q==".into(),
|
|
}],
|
|
)
|
|
.unwrap();
|
|
let history = vec![
|
|
json!({"type":"function_call_output","call_id":"test","output":json!({"ephemeral_image":meta}).to_string()}),
|
|
];
|
|
rt.sample(&sid, &history, &[]).await.unwrap();
|
|
assert_eq!(calls.load(Ordering::SeqCst), 2);
|
|
assert!(
|
|
!serde_json::to_string(&rt.store.lock().unwrap().get(&sid).unwrap().history)
|
|
.unwrap()
|
|
.contains("/9j/2Q==")
|
|
);
|
|
}
|
|
|
|
async fn fixture(model: Model, attended: bool) -> (tempfile::TempDir, Arc<Runtime>, String) {
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let runner = tmp.path().join("runner");
|
|
std::fs::write(&runner, b"\x7fELFtest-only-no-execution").unwrap();
|
|
let workspace = tmp.path().join("workspace");
|
|
std::fs::create_dir(&workspace).unwrap();
|
|
let bundle = Path::new(env!("CARGO_MANIFEST_DIR")).join("../examples/assistant");
|
|
let rt = Runtime::open(&tmp.path().join("state"), &bundle, &runner, model).unwrap();
|
|
let created=rt.dispatch("session/create",json!({"name":"test","coworker":"assistant-example","workspace":workspace,"compaction":{"cap_tokens":512,"auto_trim_on_failure":!attended}})).await.unwrap();
|
|
let sid = created["session"]["id"].as_str().unwrap().to_string();
|
|
let (mut s, _, _) = rt
|
|
.store
|
|
.lock()
|
|
.unwrap()
|
|
.accept(
|
|
&sid,
|
|
"submit",
|
|
"Original task: preserve the BLUE marker; never deploy anything.",
|
|
)
|
|
.unwrap();
|
|
for n in 0..6 {
|
|
s.history.push(json!({"type":"message","role":"assistant","content":[{"type":"output_text","text":"historical detail ".repeat(120)}]}));
|
|
s.history
|
|
.push(json!({"role":"user","content":format!("continue {n}; keep BLUE")}));
|
|
}
|
|
s.turn.as_mut().unwrap().status = "running".into();
|
|
rt.store
|
|
.lock()
|
|
.unwrap()
|
|
.save(&s, "seeded", json!({}))
|
|
.unwrap();
|
|
(tmp, rt, sid)
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn segment_checkpoint_preserves_history_and_rolls_back_failed_event() {
|
|
struct Discard;
|
|
impl crate::telemetry::Sink for Discard {
|
|
fn export(
|
|
&mut self,
|
|
_: &[crate::telemetry::Record],
|
|
_: crate::telemetry::Snapshot,
|
|
) -> bool {
|
|
true
|
|
}
|
|
}
|
|
let (tmp, mut rt, sid) = fixture(Model::new("fixture".into(), true), true).await;
|
|
let (t, c) = crate::telemetry::Telemetry::start(Discard, 16).unwrap();
|
|
Arc::get_mut(&mut rt).unwrap().telemetry = t;
|
|
let original = rt.store.lock().unwrap().get(&sid).unwrap().history;
|
|
let db = rusqlite::Connection::open(tmp.path().join("state/runtime.sqlite3")).unwrap();
|
|
db.execute_batch("CREATE TRIGGER fail_segment BEFORE INSERT ON events WHEN NEW.kind='execution_segment_started' BEGIN SELECT RAISE(ABORT,'fixture'); END;").unwrap();
|
|
assert!(rt.begin_segment(&sid).is_err());
|
|
let s = rt.store.lock().unwrap().get(&sid).unwrap();
|
|
assert_eq!(s.history, original);
|
|
assert!(s.turn.unwrap().observation.is_none());
|
|
db.execute_batch("DROP TRIGGER fail_segment").unwrap();
|
|
let (first, turn) = rt.begin_segment(&sid).unwrap();
|
|
let context = first.context();
|
|
first.finish(Outcome::Waiting);
|
|
let (second, same) = rt.begin_segment(&sid).unwrap();
|
|
assert_eq!(turn, same);
|
|
assert_eq!(context.trace, second.context().trace);
|
|
assert_ne!(context.span, second.context().span);
|
|
second.finish(Outcome::Ok);
|
|
let s = rt.store.lock().unwrap().get(&sid).unwrap();
|
|
assert_eq!(s.history, original);
|
|
assert_eq!(s.turn.unwrap().observation.unwrap().segments, 2);
|
|
assert!(c.shutdown(std::time::Duration::from_secs(2)));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn completed_turn_cleanup_cannot_accept_and_strand_the_next_submission() {
|
|
let (_tmp, rt, sid) = fixture(Model::new("fixture".into(), true), true).await;
|
|
rt.update(&sid, "turn_completed", json!({"status":"completed"}), |s| {
|
|
s.turn.as_mut().unwrap().status = "completed".into();
|
|
Ok(())
|
|
})
|
|
.unwrap();
|
|
let before = rt.store.lock().unwrap().get(&sid).unwrap().history;
|
|
// Reproduce the interval between durable completion and old-job cleanup.
|
|
rt.jobs
|
|
.lock()
|
|
.unwrap()
|
|
.insert(sid.clone(), CancellationToken::new());
|
|
let error = rt
|
|
.dispatch(
|
|
"turn/start",
|
|
json!({"session_id":sid,"submission_id":"next","text":"Do the next task"}),
|
|
)
|
|
.await
|
|
.unwrap_err();
|
|
assert!(error.to_string().contains("checkpointing"));
|
|
assert!(!rt
|
|
.store
|
|
.lock()
|
|
.unwrap()
|
|
.has_submission(&sid, "next")
|
|
.unwrap());
|
|
assert_eq!(rt.store.lock().unwrap().get(&sid).unwrap().history, before);
|
|
let duplicate = rt.dispatch("turn/start", json!({"session_id":sid,"submission_id":"submit","text":"Original task: preserve the BLUE marker; never deploy anything."})).await.unwrap();
|
|
assert_eq!(duplicate["duplicate"], true);
|
|
rt.jobs.lock().unwrap().clear();
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn compaction_is_outbound_only_and_survives_restart() {
|
|
let (tmp, rt, sid) = fixture(Model::new("fixture".into(), true), true).await;
|
|
let original = rt.store.lock().unwrap().get(&sid).unwrap().history;
|
|
assert!(matches!(
|
|
rt.compact(&sid, false).await.unwrap(),
|
|
CompactResult::Applied
|
|
));
|
|
let s = rt.store.lock().unwrap().get(&sid).unwrap();
|
|
assert_eq!(s.history, original);
|
|
assert!(s.compaction.as_ref().unwrap().users[0].contains("never deploy"));
|
|
let view = crate::compaction::outbound(&s.history, s.compaction.as_ref());
|
|
assert!(view[0]["content"].as_str().unwrap().contains("BLUE"));
|
|
let boundary = s.compaction.unwrap().boundary;
|
|
let model = rt.model.clone();
|
|
let bundle = rt.bundle.root.clone();
|
|
drop(rt);
|
|
let rt = Runtime::open(
|
|
&tmp.path().join("state"),
|
|
&bundle,
|
|
&tmp.path().join("runner"),
|
|
model,
|
|
)
|
|
.unwrap();
|
|
let s = rt.store.lock().unwrap().get(&sid).unwrap();
|
|
assert_eq!(s.history, original);
|
|
assert_eq!(s.compaction.unwrap().boundary, boundary);
|
|
assert_eq!(s.turn.unwrap().status, "interrupted");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn summary_failures_retry_once_then_park_or_trim() {
|
|
for attended in [true, false] {
|
|
let count = Arc::new(AtomicUsize::new(0));
|
|
let seen = count.clone();
|
|
let mut model = Model::new("fixture".into(), true);
|
|
model.summary_hook = Some(Arc::new(move |_| {
|
|
seen.fetch_add(1, Ordering::SeqCst);
|
|
Box::pin(async { bail!("synthetic failure") })
|
|
}));
|
|
let (_tmp, rt, sid) = fixture(model, attended).await;
|
|
let result = rt.compact(&sid, false).await.unwrap();
|
|
assert_eq!(count.load(Ordering::SeqCst), 2);
|
|
let s = rt.store.lock().unwrap().get(&sid).unwrap();
|
|
if attended {
|
|
assert!(matches!(result, CompactResult::Waiting));
|
|
assert_eq!(s.interactions.len(), 1);
|
|
assert_eq!(s.interactions[0].kind, "compaction");
|
|
assert!(s.compaction.is_none());
|
|
rt.store.lock().unwrap().recover().unwrap();
|
|
let restored = rt.store.lock().unwrap().get(&sid).unwrap();
|
|
assert_eq!(restored.interactions[0].id, s.interactions[0].id);
|
|
assert_eq!(restored.turn.unwrap().status, "recoverable");
|
|
} else {
|
|
assert!(matches!(result, CompactResult::Applied));
|
|
assert!(s.compaction.unwrap().trimmed);
|
|
assert!(s.interactions.is_empty());
|
|
}
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn cancellation_drops_native_summarizer_without_late_commit() {
|
|
let entered = Arc::new(tokio::sync::Notify::new());
|
|
let notify = entered.clone();
|
|
let mut model = Model::new("fixture".into(), true);
|
|
model.summary_hook = Some(Arc::new(move |_| {
|
|
let notify = notify.clone();
|
|
Box::pin(async move {
|
|
notify.notify_one();
|
|
tokio::time::sleep(std::time::Duration::from_secs(60)).await;
|
|
Ok("should never finish".into())
|
|
})
|
|
}));
|
|
let (_tmp, rt, sid) = fixture(model, true).await;
|
|
let token = CancellationToken::new();
|
|
let worker = token.clone();
|
|
let task_rt = rt.clone();
|
|
let task_sid = sid.clone();
|
|
let task = tokio::spawn(async move {
|
|
tokio::select! {biased;
|
|
_=worker.cancelled()=>{},r=task_rt.compact(&task_sid,false)=>{r.unwrap();panic!("summary should have been cancelled");}
|
|
}
|
|
});
|
|
entered.notified().await;
|
|
token.cancel();
|
|
let result = tokio::time::timeout(std::time::Duration::from_millis(200), task).await;
|
|
assert_eq!(
|
|
result.is_err(),
|
|
crate::compaction::DEVIATIONS["rust"]["summary_still_waiting_after_interrupt"]
|
|
.as_bool()
|
|
.unwrap()
|
|
);
|
|
result.unwrap().unwrap();
|
|
assert!(rt
|
|
.store
|
|
.lock()
|
|
.unwrap()
|
|
.get(&sid)
|
|
.unwrap()
|
|
.compaction
|
|
.is_none());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn stale_summary_cannot_replace_current_history_view() {
|
|
let entered = Arc::new(tokio::sync::Notify::new());
|
|
let release = Arc::new(tokio::sync::Notify::new());
|
|
let mut model = Model::new("fixture".into(), true);
|
|
let e = entered.clone();
|
|
let r = release.clone();
|
|
model.summary_hook = Some(Arc::new(move |_| {
|
|
let e = e.clone();
|
|
let r = r.clone();
|
|
Box::pin(async move {
|
|
e.notify_one();
|
|
r.notified().await;
|
|
Ok(crate::compaction::REFERENCE["cases"]
|
|
.as_array()
|
|
.unwrap()
|
|
.iter()
|
|
.find(|c| c["op"] == "quality" && c["expected"] == true)
|
|
.unwrap()["text"]
|
|
.as_str()
|
|
.unwrap()
|
|
.into())
|
|
})
|
|
}));
|
|
let (_tmp, rt, sid) = fixture(model, true).await;
|
|
let task_rt = rt.clone();
|
|
let task_sid = sid.clone();
|
|
let task = tokio::spawn(async move { task_rt.compact(&task_sid, false).await });
|
|
entered.notified().await;
|
|
rt.update(&sid, "changed", json!({}), |s| {
|
|
s.history
|
|
.push(json!({"role":"user","content":"new instruction"}));
|
|
Ok(())
|
|
})
|
|
.unwrap();
|
|
release.notify_one();
|
|
assert!(task.await.unwrap().is_err());
|
|
assert!(rt
|
|
.store
|
|
.lock()
|
|
.unwrap()
|
|
.get(&sid)
|
|
.unwrap()
|
|
.compaction
|
|
.is_none());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn retries_keep_partial_attempts_separate_and_do_not_touch_tools_or_history() {
|
|
let count = Arc::new(AtomicUsize::new(0));
|
|
let seen = count.clone();
|
|
let mut model = Model::new("fixture".into(), true);
|
|
model.sample_hook = Some(Arc::new(move |_, _, tx| {
|
|
let n = seen.fetch_add(1, Ordering::SeqCst);
|
|
Box::pin(async move {
|
|
tx.send(Progress::Delta(
|
|
if n == 0 {
|
|
"failed partial"
|
|
} else {
|
|
"final answer"
|
|
}
|
|
.into(),
|
|
))
|
|
.await
|
|
.unwrap();
|
|
tx.send(Progress::Usage(crate::telemetry::TokenUsage {
|
|
input: Some(10),
|
|
output: Some(2),
|
|
..Default::default()
|
|
}))
|
|
.await
|
|
.unwrap();
|
|
if n == 0 {
|
|
return Err(Error {
|
|
kind: Failure::RateLimit,
|
|
retry_after: Some(0),
|
|
http_status: None,
|
|
}
|
|
.into());
|
|
}
|
|
tx.send(Progress::Complete(Completion{output:vec![json!({"type":"message","content":[{"type":"output_text","text":"final answer"}]})],usage:None})).await.unwrap();
|
|
Ok(())
|
|
})
|
|
}));
|
|
let (_tmp, mut rt, sid) = fixture(model, true).await;
|
|
struct Capture(Arc<Mutex<Vec<crate::telemetry::Record>>>);
|
|
impl crate::telemetry::Sink for Capture {
|
|
fn export(
|
|
&mut self,
|
|
r: &[crate::telemetry::Record],
|
|
_: crate::telemetry::Snapshot,
|
|
) -> bool {
|
|
self.0.lock().unwrap().extend_from_slice(r);
|
|
true
|
|
}
|
|
}
|
|
let rows = Arc::new(Mutex::new(vec![]));
|
|
let (telemetry, controller) =
|
|
crate::telemetry::Telemetry::start(Capture(rows.clone()), 64).unwrap();
|
|
Arc::get_mut(&mut rt).unwrap().telemetry = telemetry;
|
|
let before = rt.store.lock().unwrap().get(&sid).unwrap().history;
|
|
let result = rt.sample(&sid, &before, &[]).await.unwrap();
|
|
assert!(!serde_json::to_string(&result.output)
|
|
.unwrap()
|
|
.contains("failed partial"));
|
|
assert!(controller.shutdown(std::time::Duration::from_secs(2)));
|
|
let records = rows.lock().unwrap();
|
|
let requests: Vec<_> = records
|
|
.iter()
|
|
.filter(|r| r.operation == Op::Model)
|
|
.collect();
|
|
assert_eq!(requests.len(), 2);
|
|
assert_eq!(requests[0].attempt, Some(1));
|
|
assert_eq!(requests[1].attempt, Some(2));
|
|
assert_eq!(requests[0].context.trace, requests[1].context.trace);
|
|
assert_ne!(requests[0].identity.action, requests[1].identity.action);
|
|
assert_eq!(
|
|
requests
|
|
.iter()
|
|
.map(|r| r.input_tokens.unwrap())
|
|
.sum::<u64>(),
|
|
20
|
|
);
|
|
assert_eq!(requests[0].outcome, Outcome::Error);
|
|
assert_eq!(requests[1].outcome, Outcome::Ok);
|
|
let db = rt.store.lock().unwrap();
|
|
let attempts = db.attempts(&sid).unwrap();
|
|
assert_eq!(attempts.len(), 2);
|
|
assert_eq!(attempts[0]["status"], "completed");
|
|
assert_eq!(attempts[1]["status"], "failed");
|
|
assert_eq!(attempts[1]["text"], "failed partial");
|
|
assert_ne!(attempts[0]["id"], attempts[1]["id"]);
|
|
assert_eq!(db.get(&sid).unwrap().history, before);
|
|
assert!(!db
|
|
.events(&sid, 0)
|
|
.unwrap()
|
|
.iter()
|
|
.any(|e| e.kind == "tool_started"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn safety_block_is_durable_and_never_retried() {
|
|
let count = Arc::new(AtomicUsize::new(0));
|
|
let seen = count.clone();
|
|
let mut model = Model::new("fixture".into(), true);
|
|
model.sample_hook = Some(Arc::new(move |_, _, _| {
|
|
seen.fetch_add(1, Ordering::SeqCst);
|
|
Box::pin(async { Err(Error::new(Failure::SafetyBlocked).into()) })
|
|
}));
|
|
let (_tmp, rt, sid) = fixture(model, true).await;
|
|
assert!(rt.sample(&sid, &[], &[]).await.is_err());
|
|
assert_eq!(count.load(Ordering::SeqCst), 1);
|
|
rt.update(&sid, "turn_completed", json!({"status":"failed"}), |s| {
|
|
s.turn.as_mut().unwrap().status = "failed".into();
|
|
Ok(())
|
|
})
|
|
.unwrap();
|
|
assert!(rt.store.lock().unwrap().get(&sid).unwrap().provider_blocked);
|
|
assert!(rt
|
|
.store
|
|
.lock()
|
|
.unwrap()
|
|
.accept(&sid, "again", "retry")
|
|
.is_err());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn compaction_event_failure_rolls_back_checkpoint() {
|
|
let (tmp, rt, sid) = fixture(Model::new("fixture".into(), true), true).await;
|
|
let db = rusqlite::Connection::open(tmp.path().join("state/runtime.sqlite3")).unwrap();
|
|
db.execute_batch("CREATE TRIGGER fail_compaction BEFORE INSERT ON events WHEN NEW.kind='compacted' BEGIN SELECT RAISE(ABORT,'injected commit fault'); END;").unwrap();
|
|
let before = rt.store.lock().unwrap().get(&sid).unwrap().history;
|
|
assert!(rt.compact(&sid, false).await.is_err());
|
|
let store = rt.store.lock().unwrap();
|
|
assert!(store.get(&sid).unwrap().compaction.is_none());
|
|
assert_eq!(before, store.get(&sid).unwrap().history);
|
|
assert!(!store
|
|
.events(&sid, 0)
|
|
.unwrap()
|
|
.iter()
|
|
.any(|e| e.kind == "compacted"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn repeated_transient_failure_stops_after_three_attempts() {
|
|
let count = Arc::new(AtomicUsize::new(0));
|
|
let seen = count.clone();
|
|
let mut model = Model::new("fixture".into(), true);
|
|
model.sample_hook = Some(Arc::new(move |_, _, _| {
|
|
seen.fetch_add(1, Ordering::SeqCst);
|
|
Box::pin(async {
|
|
Err(Error {
|
|
kind: Failure::Transient,
|
|
retry_after: Some(0),
|
|
http_status: None,
|
|
}
|
|
.into())
|
|
})
|
|
}));
|
|
let (_tmp, rt, sid) = fixture(model, true).await;
|
|
assert!(rt.sample(&sid, &[], &[]).await.is_err());
|
|
assert_eq!(count.load(Ordering::SeqCst), 3);
|
|
assert_eq!(
|
|
rt.store
|
|
.lock()
|
|
.unwrap()
|
|
.get(&sid)
|
|
.unwrap()
|
|
.turn
|
|
.unwrap()
|
|
.provider_attempts,
|
|
3
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn retry_backoff_is_cancellable_without_another_request() {
|
|
let count = Arc::new(AtomicUsize::new(0));
|
|
let seen = count.clone();
|
|
let mut model = Model::new("fixture".into(), true);
|
|
model.sample_hook = Some(Arc::new(move |_, _, _| {
|
|
seen.fetch_add(1, Ordering::SeqCst);
|
|
Box::pin(async {
|
|
Err(Error {
|
|
kind: Failure::RateLimit,
|
|
retry_after: Some(30),
|
|
http_status: None,
|
|
}
|
|
.into())
|
|
})
|
|
}));
|
|
let (_tmp, rt, sid) = fixture(model, true).await;
|
|
let mut events = rt.events.subscribe();
|
|
let token = CancellationToken::new();
|
|
let cancel = token.clone();
|
|
let worker = rt.clone();
|
|
let id = sid.clone();
|
|
let task = tokio::spawn(async move {
|
|
tokio::select! {biased;
|
|
_=cancel.cancelled()=>{},r=worker.sample(&id,&[],&[])=>{r.unwrap();panic!("expected cancellation in backoff");}
|
|
}
|
|
});
|
|
tokio::time::timeout(std::time::Duration::from_secs(2), async {
|
|
while events.recv().await.unwrap().kind != "model_retrying" {}
|
|
})
|
|
.await
|
|
.unwrap();
|
|
token.cancel();
|
|
tokio::time::timeout(std::time::Duration::from_millis(200), task)
|
|
.await
|
|
.unwrap()
|
|
.unwrap();
|
|
assert_eq!(count.load(Ordering::SeqCst), 1);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn output_limit_continuation_is_bounded_and_not_a_user_instruction() {
|
|
let (_tmp, rt, sid) = fixture(Model::new("fixture".into(), true), true).await;
|
|
assert!(rt.continue_truncated(&sid).unwrap());
|
|
assert!(rt.continue_truncated(&sid).unwrap());
|
|
assert!(!rt.continue_truncated(&sid).unwrap());
|
|
let s = rt.store.lock().unwrap().get(&sid).unwrap();
|
|
assert_eq!(s.turn.unwrap().continuations, 2);
|
|
assert_eq!(
|
|
s.history.last().unwrap()["_runtime_notice"],
|
|
"output_continuation"
|
|
);
|
|
assert!(crate::store::terminal("truncated"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn compaction_retry_and_pending_approval_survive_the_same_restart_scenario() {
|
|
let count = Arc::new(AtomicUsize::new(0));
|
|
let seen = count.clone();
|
|
let mut model = Model::new("fixture".into(), true);
|
|
model.sample_hook = Some(Arc::new(move |_, history, tx| {
|
|
let n = seen.fetch_add(1, Ordering::SeqCst);
|
|
Box::pin(async move {
|
|
assert!(history[0]["content"].as_str().unwrap().contains("BLUE"));
|
|
if n == 0 {
|
|
tx.send(Progress::Delta("partial before injected failure".into()))
|
|
.await
|
|
.unwrap();
|
|
return Err(Error {
|
|
kind: Failure::Transient,
|
|
retry_after: Some(0),
|
|
http_status: None,
|
|
}
|
|
.into());
|
|
}
|
|
tx.send(Progress::Complete(Completion{output:vec![json!({"type":"function_call","call_id":"c_stable","name":"write_file","arguments":"{\"path\":\"proof.txt\",\"content\":\"BLUE\"}"})],usage:None})).await.unwrap();
|
|
Ok(())
|
|
})
|
|
}));
|
|
let (tmp, rt, sid) = fixture(model, true).await;
|
|
assert!(matches!(
|
|
rt.compact(&sid, false).await.unwrap(),
|
|
CompactResult::Applied
|
|
));
|
|
let s = rt.store.lock().unwrap().get(&sid).unwrap();
|
|
let view = crate::compaction::outbound(&s.history, s.compaction.as_ref());
|
|
let completion = rt.sample(&sid, &view, &[]).await.unwrap();
|
|
let call = completion.output[0].clone();
|
|
// Seed the same pre-execution checkpoint used by drive; no tool is executed.
|
|
rt.update(
|
|
&sid,
|
|
"interaction_required",
|
|
json!({"id":"i_stable"}),
|
|
|s| {
|
|
s.history.extend(completion.output);
|
|
let t = s.turn.as_mut().unwrap();
|
|
t.queue = vec![call.clone()];
|
|
t.status = "waiting".into();
|
|
s.interactions.push(Interaction {
|
|
requested_at_ms: None,
|
|
resolved_at_ms: None,
|
|
grant_id: None,
|
|
id: "i_stable".into(),
|
|
turn_id: t.id.clone(),
|
|
kind: "approval".into(),
|
|
call,
|
|
bundle_digest: s.bundle_digest.clone(),
|
|
schema_digest: "test".into(),
|
|
response: None,
|
|
origin: None,
|
|
server: None,
|
|
remote_tool: None,
|
|
policy_digest: s.tool_policy.digest.clone(),
|
|
resource_policy_digest: s.resource_policy.digest.clone(),
|
|
});
|
|
Ok(())
|
|
},
|
|
)
|
|
.unwrap();
|
|
let bundle = rt.bundle.root.clone();
|
|
let model = rt.model.clone();
|
|
drop(rt);
|
|
let rt = Runtime::open(
|
|
&tmp.path().join("state"),
|
|
&bundle,
|
|
&tmp.path().join("runner"),
|
|
model,
|
|
)
|
|
.unwrap();
|
|
let snap = rt.snapshot(&sid).unwrap();
|
|
assert_eq!(snap["turn"]["status"], "recoverable");
|
|
assert_eq!(snap["interactions"][0]["id"], "i_stable");
|
|
assert!(snap["compaction"].is_object());
|
|
assert!(snap["attempts"]
|
|
.to_string()
|
|
.contains("partial before injected failure"));
|
|
assert!(!rt
|
|
.store
|
|
.lock()
|
|
.unwrap()
|
|
.events(&sid, 0)
|
|
.unwrap()
|
|
.iter()
|
|
.any(|e| e.kind == "tool_started"));
|
|
}
|