synaps-engine 0.10.1

Runtime engine — streaming, tools, MCP, skills, extensions, sidecar
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
//! B2 — spawned compaction (PLAN-phase3 §2.5, §4.2): Attach/Detach/Cancel/
//! Query serviced while a compaction is in flight; Submit queued; Cancel
//! aborts; LinkedSuccessor policy updates `journal_id` + the continue target;
//! `SYNAPS_SESSION_COMPACT_INLINE=1` restores the #107 inline body.

mod session_actor_common;
use session_actor_common::*;

use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
use std::time::Duration;

use agent_engine::session::{
    ClientKind, ClientMeta, ClientTransport, CompactionPolicyWire, LocalTransport,
    SessionCommand, SessionConfig, SessionEventWire, SessionQuery,
};
use axum::response::IntoResponse;
use serial_test::serial;

/// Streaming turns (`"stream":true`) → SSE_HI; the non-streaming compaction
/// call → a JSON summary after `delay`. Counts compaction hits.
async fn stub_compact(delay: Duration) -> (String, Arc<AtomicUsize>) {
    let compactions = Arc::new(AtomicUsize::new(0));
    let c = Arc::clone(&compactions);
    let app = axum::Router::new().fallback(move |body: String| {
        let c = Arc::clone(&c);
        async move {
            if body.contains("\"stream\":true") {
                return (
                    axum::http::StatusCode::OK,
                    [("content-type", "text/event-stream")],
                    SSE_HI.to_string(),
                )
                    .into_response();
            }
            c.fetch_add(1, Ordering::SeqCst);
            tokio::time::sleep(delay).await;
            axum::Json(serde_json::json!({
                "id": "msg_c", "type": "message", "role": "assistant",
                "model": MODEL, "stop_reason": "end_turn",
                "content": [{"type": "text", "text": "SUMMARY: the user said hello a few times."}],
                "usage": {"input_tokens": 50, "output_tokens": 10}
            }))
            .into_response()
        }
    });
    let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
    let addr = listener.local_addr().unwrap();
    tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
    (format!("http://{addr}"), compactions)
}

/// 4+ messages so the engine has something to compact.
async fn warm(t: &mut LocalTransport, turns: usize) {
    for i in 0..turns {
        t.send(submit(&format!("hello {i}"))).await.unwrap();
        until(t, |e| matches!(e, SessionEventWire::Idle)).await;
    }
}

#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[serial]
async fn compaction_does_not_block_attach_and_submit_is_queued_and_restored() {
    let _h = Home::new();
    let (url, compactions) = stub_compact(Duration::from_secs(2)).await;
    std::env::set_var("SYNAPS_ANTHROPIC_BASE_URL", &url);
    let host = host().await;
    let handle = host
        .create_session(SessionConfig {
            persist: true,
            ..cfg()
        })
        .await
        .unwrap();
    let (mut a, _) = LocalTransport::attach(handle.clone(), ClientMeta::new(ClientKind::Tui))
        .await
        .unwrap();
    warm(&mut a, 2).await;

    a.send(SessionCommand::Compact { instructions: None }).await.unwrap();
    let seen = until(&mut a, |e| matches!(e, SessionEventWire::CompactionStarted { .. })).await;
    assert!(matches!(
        &seen.last().unwrap().event,
        SessionEventWire::CompactionStarted { source, disclosure } if source == "manual" && !disclosure.is_empty()
    ));

    // Serviced during compaction: Attach (with snapshot), Query, Detach.
    let t0 = std::time::Instant::now();
    let (mut b, snap) = LocalTransport::attach(handle.clone(), ClientMeta::new(ClientKind::Attach))
        .await
        .unwrap();
    assert!(t0.elapsed() < Duration::from_millis(1500), "attach did not wait for compaction");
    assert_eq!(snap.conversation.api_messages.len(), 4);
    b.send(SessionCommand::Query {
        id: 5,
        query: SessionQuery::Status,
    })
    .await
    .unwrap();
    until(&mut b, |e| matches!(e, SessionEventWire::QueryResult { id: 5, .. })).await;
    b.send(SessionCommand::Detach {
        client: b.client_id(),
    })
    .await
    .unwrap();
    until(&mut a, |e| matches!(e, SessionEventWire::ClientLeft { .. })).await;

    // Submit during compaction → queued, not sent.
    a.send(submit("while compacting")).await.unwrap();
    let seen = until(&mut a, |e| matches!(e, SessionEventWire::Steered { .. })).await;
    assert!(matches!(
        &seen.last().unwrap().event,
        SessionEventWire::Steered { text, delivered: false } if text == "while compacting"
    ));

    let seen = until(&mut a, |e| matches!(e, SessionEventWire::Idle)).await;
    let applied = seen
        .iter()
        .find_map(|e| match &e.event {
            SessionEventWire::CompactionApplied {
                previous_session_id,
                session_id,
                queued_restored,
                msg_count,
                ..
            } => Some((
                previous_session_id.clone(),
                session_id.clone(),
                queued_restored.clone(),
                *msg_count,
            )),
            _ => None,
        })
        .expect("CompactionApplied");
    assert_eq!(applied.0, handle.id.as_str());
    assert_eq!(applied.1, handle.id.as_str(), "InPlace keeps the id");
    assert_eq!(applied.2.as_deref(), Some("while compacting"));
    assert_eq!(applied.3, 4);
    assert_eq!(compactions.load(Ordering::SeqCst), 1);
    let conv = last_conversation(&seen);
    assert!(conv.queued_message.is_none());
    assert!(conv.api_messages.len() < 4 + 1 || conv.api_messages.len() >= 2);
    // The queued text rides the transition as the last user message; it is
    // reported, never re-sent (loop_arms.rs:806-811).
    assert!(!seen
        .iter()
        .any(|e| matches!(e.event, SessionEventWire::TurnStarted { .. })));
    assert_eq!(
        conv.api_messages.last().unwrap()["content"],
        "while compacting"
    );
    end(&mut a).await;
}

#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[serial]
async fn cancel_aborts_compaction_and_auto_turns_see_busy() {
    let _h = Home::new();
    let (url, _) = stub_compact(Duration::from_secs(5)).await;
    std::env::set_var("SYNAPS_ANTHROPIC_BASE_URL", &url);
    let host = host().await;
    let handle = host.create_session(cfg()).await.unwrap();
    let (mut a, _) = LocalTransport::attach(handle.clone(), ClientMeta::new(ClientKind::Tui))
        .await
        .unwrap();
    warm(&mut a, 2).await;
    let before = {
        a.send(SessionCommand::Query {
            id: 1,
            query: SessionQuery::Messages,
        })
        .await
        .unwrap();
        let seen = until(&mut a, |e| matches!(e, SessionEventWire::QueryResult { id: 1, .. })).await;
        match &seen.last().unwrap().event {
            SessionEventWire::QueryResult { value, .. } => value.clone(),
            _ => unreachable!(),
        }
    };

    a.send(SessionCommand::Compact {
        instructions: Some("short".into()),
    })
    .await
    .unwrap();
    until(&mut a, |e| matches!(e, SessionEventWire::CompactionStarted { .. })).await;
    a.send(submit("queued")).await.unwrap();
    until(&mut a, |e| matches!(e, SessionEventWire::Steered { .. })).await;

    let t0 = std::time::Instant::now();
    a.send(SessionCommand::Cancel).await.unwrap();
    let seen = until(&mut a, |e| matches!(e, SessionEventWire::Idle)).await;
    assert!(t0.elapsed() < Duration::from_secs(2), "cancel does not wait for the job");
    assert!(seen
        .iter()
        .any(|e| matches!(e.event, SessionEventWire::CompactionCancelled)));
    assert!(seen.iter().any(|e| matches!(
        &e.event,
        SessionEventWire::Dequeued { text } if text == "queued"
    )));
    assert!(!seen
        .iter()
        .any(|e| matches!(e.event, SessionEventWire::CompactionApplied { .. })));
    // Prior state intact.
    a.send(SessionCommand::Query {
        id: 2,
        query: SessionQuery::Messages,
    })
    .await
    .unwrap();
    let seen = until(&mut a, |e| matches!(e, SessionEventWire::QueryResult { id: 2, .. })).await;
    match &seen.last().unwrap().event {
        SessionEventWire::QueryResult { value, .. } => assert_eq!(value, &before),
        _ => unreachable!(),
    }
    // Nothing lands later either.
    let late = drain_for(&mut a, Duration::from_secs(1)).await;
    assert!(!late.iter().any(|e| matches!(
        e.event,
        SessionEventWire::CompactionApplied { .. } | SessionEventWire::CompactionFailed { .. }
    )));
    end(&mut a).await;
}

#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[serial]
async fn linked_successor_updates_journal_id_and_continue_target() {
    let _h = Home::new();
    let (url, _) = stub_compact(Duration::from_millis(200)).await;
    std::env::set_var("SYNAPS_ANTHROPIC_BASE_URL", &url);
    let host = host().await;
    let handle = host
        .create_session(SessionConfig {
            persist: true,
            compaction_policy: CompactionPolicyWire::LinkedSuccessor,
            ..cfg()
        })
        .await
        .unwrap();
    let (mut a, _) = LocalTransport::attach(handle.clone(), ClientMeta::new(ClientKind::Tui))
        .await
        .unwrap();
    warm(&mut a, 2).await;
    assert_eq!(handle.journal_id(), handle.id.as_str());

    a.send(SessionCommand::Compact { instructions: None }).await.unwrap();
    let seen = until(&mut a, |e| matches!(e, SessionEventWire::Idle)).await;
    let (prev, new) = seen
        .iter()
        .find_map(|e| match &e.event {
            SessionEventWire::CompactionApplied {
                previous_session_id,
                session_id,
                ..
            } => Some((previous_session_id.clone(), session_id.clone())),
            _ => None,
        })
        .expect("CompactionApplied");
    assert_eq!(prev, handle.id.as_str());
    assert_ne!(new, prev, "LinkedSuccessor mints a successor");
    assert_eq!(handle.journal_id(), new);
    let conv = last_conversation(&seen);
    assert_eq!(conv.header.id, new);
    assert_eq!(conv.header.parent_session.as_deref(), Some(prev.as_str()));
    assert_eq!(conv.tokens.input, 0, "counters reset on successor");
    assert_eq!(conv.cost, 0.0);
    // Successor on disk with the parent link; parent marks compacted_into.
    let succ = agent_engine::core::session::Session::load(&new).expect("successor saved");
    assert_eq!(succ.parent_session.as_deref(), Some(prev.as_str()));
    let parent = agent_engine::core::session::Session::load(&prev).unwrap();
    assert_eq!(parent.compacted_into.as_deref(), Some(new.as_str()));

    // `--continue <successor>` / `--continue` (latest = the successor on
    // disk) while this actor is live must attach to it, not build a second
    // actor on the successor journal (the map is keyed by the ORIGINAL id).
    for q in [Some(Some(new.clone())), Some(None)] {
        let again = host
            .create_session(SessionConfig {
                continue_session: q.clone(),
                persist: true,
                ..cfg()
            })
            .await
            .unwrap();
        assert_eq!(again.id, handle.id, "continue {q:?} → the live actor");
        assert_eq!(host.sessions().len(), 1, "no second actor for {q:?}");
    }

    // The next turn journals into the successor.
    a.send(submit("after")).await.unwrap();
    let seen = until(&mut a, |e| matches!(e, SessionEventWire::Idle)).await;
    let n = last_conversation(&seen).api_messages.len();
    end(&mut a).await;
    let succ = agent_engine::core::session::Session::load(&new).unwrap();
    assert_eq!(succ.api_messages.len(), n);
    assert_eq!(host.sessions().len(), 0);
}

#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[serial]
async fn compact_inline_kill_switch_restores_inline_notices() {
    let _h = Home::new();
    std::env::set_var("SYNAPS_SESSION_COMPACT_INLINE", "1");
    let (url, _) = stub_compact(Duration::from_millis(100)).await;
    std::env::set_var("SYNAPS_ANTHROPIC_BASE_URL", &url);
    let host = host().await;
    let handle = host.create_session(cfg()).await.unwrap();
    let (mut a, _) = LocalTransport::attach(handle.clone(), ClientMeta::new(ClientKind::Tui))
        .await
        .unwrap();
    warm(&mut a, 2).await;
    a.send(SessionCommand::Compact { instructions: None }).await.unwrap();
    let seen = until(&mut a, |e| matches!(e, SessionEventWire::Conversation(_))).await;
    std::env::remove_var("SYNAPS_SESSION_COMPACT_INLINE");
    assert!(!seen
        .iter()
        .any(|e| matches!(e.event, SessionEventWire::CompactionStarted { .. })));
    assert!(seen.iter().any(|e| matches!(
        &e.event,
        SessionEventWire::SystemNotice(n) if n.starts_with("[compacted → ~")
    )));
    end(&mut a).await;
}

/// H1: the typed events are the contract — `/compact` (both the `Compact`
/// command and the `/compact` engine command) produces exactly one
/// `CompactionStarted`, one `CompactionApplied`, and no "compacting..."
/// `SystemNotice` a client would double-render.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[serial]
async fn compaction_emits_typed_events_exactly_once_and_no_notice() {
    let _h = Home::new();
    let (url, _) = stub_compact(Duration::from_millis(50)).await;
    std::env::set_var("SYNAPS_ANTHROPIC_BASE_URL", &url);
    let host = host().await;
    let handle = host
        .create_session(SessionConfig {
            persist: true,
            ..cfg()
        })
        .await
        .unwrap();
    let (mut a, _) = LocalTransport::attach(handle.clone(), ClientMeta::new(ClientKind::Tui))
        .await
        .unwrap();
    warm(&mut a, 2).await;

    for via_engine_command in [false, true] {
        if via_engine_command {
            a.send(SessionCommand::EngineCommand {
                id: 9,
                name: "compact".into(),
                arg: String::new(),
            })
            .await
            .unwrap();
        } else {
            a.send(SessionCommand::Compact { instructions: None }).await.unwrap();
        }
        let seen = until(&mut a, |e| matches!(e, SessionEventWire::Idle)).await;
        let started = seen
            .iter()
            .filter(|e| matches!(e.event, SessionEventWire::CompactionStarted { .. }))
            .count();
        let applied = seen
            .iter()
            .filter(|e| matches!(e.event, SessionEventWire::CompactionApplied { .. }))
            .count();
        assert_eq!(started, 1, "via_engine_command={via_engine_command}");
        assert_eq!(applied, 1, "via_engine_command={via_engine_command}");
        assert!(
            !seen.iter().any(|e| matches!(
                &e.event,
                SessionEventWire::SystemNotice(n) if n.contains("compact")
            )),
            "no compaction SystemNotice: {:?}",
            seen.iter().map(|e| &e.event).collect::<Vec<_>>()
        );
    }
    end(&mut a).await;
}

/// `Cancel` mid-turn and `NewSession` emit the typed `Aborted` / `Cleared`
/// events, never the legacy notice text.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[serial]
async fn abort_and_clear_are_typed_events() {
    let _h = Home::new();
    let (url, _) = stub_compact(Duration::from_millis(50)).await;
    std::env::set_var("SYNAPS_ANTHROPIC_BASE_URL", &url);
    let host = host().await;
    let handle = host.create_session(cfg()).await.unwrap();
    let (mut a, _) = LocalTransport::attach(handle.clone(), ClientMeta::new(ClientKind::Tui))
        .await
        .unwrap();
    warm(&mut a, 1).await;

    a.send(SessionCommand::NewSession).await.unwrap();
    let seen = until(&mut a, |e| matches!(e, SessionEventWire::Conversation(_))).await;
    assert_eq!(
        seen.iter()
            .filter(|e| matches!(e.event, SessionEventWire::Cleared { .. }))
            .count(),
        1
    );
    assert!(!seen.iter().any(|e| matches!(
        &e.event,
        SessionEventWire::SystemNotice(n) if n.contains("cleared") || n.starts_with("aborted")
    )));
    end(&mut a).await;
}