ed-agent: streaming, history restore, one-shot generate, SessionStore
Ed::stream gives plain chat a stream of text deltas, and Ed::run forwards deltas to the new EventSink::text_delta (a no-op by default, so existing sinks still compile). Adds restore_history, generate, the system prompt and sampling setters, and the SessionStore trait as the seam for host storage. The FFI exposes the same calls, plus stream_message with cancel_stream and an app-id constructor. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Co-Authored-By: siGit Code v1.6.3 <noreply@sigit.si>
Seto Elkahfi committed
Oct 9, 2026 at 00:12 UTC
5969eea2c98108155e530f2ea93ee303ad0051c8
9 files changed
+595
-20
Cargo.lock
+2
@@ -1731,6 +1731,7 @@ name = "ed-agent"
1731
version = "1.0.1"
1732
dependencies = [
1733
"async-trait",
1734
+ "futures-core",
1735
"log",
1736
"onde",
1737
"serde",
@@ -1750,6 +1751,7 @@ dependencies = [
1751
"serde_json",
1752
"smbcloud-gresiq-sdk",
1753
"thiserror 2.0.21",
1754
+ "tokio",
1755
"uniffi",
1756
]
1757
crates/ed-agent-ffi/Cargo.toml
+1
@@ -18,6 +18,7 @@ crate-type = ["lib", "cdylib", "staticlib"]
18
ed-agent.workspace = true
19
onde.workspace = true
20
async-trait.workspace = true
21
+tokio.workspace = true
22
serde_json.workspace = true
23
thiserror.workspace = true
24
uniffi.workspace = true
crates/ed-agent-ffi/src/lib.rs
+137
-4
@@ -216,6 +216,20 @@ impl From<ChatMessage> for FfiChatMessage {
216
}
217
}
218
219
+impl From<FfiChatMessage> for ChatMessage {
220
+ fn from(value: FfiChatMessage) -> Self {
221
+ let role = match value.role {
222
+ FfiChatRole::System => ed_agent::ChatRole::System,
223
+ FfiChatRole::User => ed_agent::ChatRole::User,
224
+ FfiChatRole::Assistant => ed_agent::ChatRole::Assistant,
225
+ };
226
+ Self {
227
+ role,
228
+ content: value.content,
229
+ }
230
+ }
231
+}
232
+
233
#[derive(Debug, Clone, uniffi::Record)]
234
pub struct FfiSamplingConfig {
235
pub temperature: Option<f64>,
@@ -333,6 +347,8 @@ pub trait FfiApprovalHandler: Send + Sync {
347
#[uniffi::export(callback_interface)]
348
pub trait FfiEventListener: Send + Sync {
349
fn status_changed(&self, update: FfiStatusUpdate);
350
+ /// A piece of the reply as it is generated, during `run`.
351
+ fn text_delta(&self, delta: String);
352
fn tool_requested(&self, call: FfiToolCall);
353
fn approval_requested(&self, request: FfiApprovalRequest);
354
fn tool_started(&self, call: FfiToolCall);
@@ -341,6 +357,12 @@ pub trait FfiEventListener: Send + Sync {
357
fn warning(&self, message: String);
358
}
359
360
+/// Receives a streamed reply from `stream_message`.
361
+#[uniffi::export(callback_interface)]
362
+pub trait FfiStreamListener: Send + Sync {
363
+ fn on_delta(&self, delta: String);
364
+}
365
+
366
struct ForeignExecutor(Arc<dyn FfiToolExecutor>);
367
368
#[async_trait]
@@ -378,6 +400,10 @@ impl EventSink for FfiSink {
400
});
401
}
402
403
+ fn text_delta(&self, delta: &str) {
404
+ self.0.text_delta(delta.to_owned());
405
+ }
406
+
407
fn tool_requested(&self, call: &ToolCall) {
408
self.0.tool_requested(call.into());
409
}
@@ -410,20 +436,21 @@ impl EventSink for FfiSink {
436
#[derive(uniffi::Object)]
437
pub struct FfiEdAgent {
438
inner: Ed<FfiSink>,
439
+ /// Ends the stream in progress, if any.
440
+ stream_cancel: std::sync::Mutex<Option<tokio::sync::oneshot::Sender<()>>>,
441
}
442
415
-#[uniffi::export(async_runtime = "tokio")]
443
impl FfiEdAgent {
417
- #[uniffi::constructor]
418
- pub fn new(
444
+ fn build(
445
executor: Box<dyn FfiToolExecutor>,
446
approvals: Box<dyn FfiApprovalHandler>,
447
events: Box<dyn FfiEventListener>,
448
config: Option<FfiAgentConfig>,
449
+ app_id: Option<String>,
450
) -> Arc<Self> {
451
let config = config.unwrap_or_default();
452
Arc::new(Self {
426
- inner: Ed::with_agent(
453
+ inner: Ed::with_agent_and_app_id(
454
FfiSink(Arc::from(events)),
455
Arc::new(ForeignExecutor(Arc::from(executor))),
456
Arc::new(ForeignApprovals(Arc::from(approvals))),
@@ -432,9 +459,36 @@ impl FfiEdAgent {
459
max_tool_output_chars: usize::try_from(config.max_tool_output_chars)
460
.unwrap_or(usize::MAX),
461
},
462
+ app_id,
463
),
464
+ stream_cancel: std::sync::Mutex::new(None),
465
})
466
}
467
+}
468
+
469
+#[uniffi::export(async_runtime = "tokio")]
470
+impl FfiEdAgent {
471
+ #[uniffi::constructor]
472
+ pub fn new(
473
+ executor: Box<dyn FfiToolExecutor>,
474
+ approvals: Box<dyn FfiApprovalHandler>,
475
+ events: Box<dyn FfiEventListener>,
476
+ config: Option<FfiAgentConfig>,
477
+ ) -> Arc<Self> {
478
+ Self::build(executor, approvals, events, config, None)
479
+ }
480
+
481
+ /// Like `new`, with an Onde app id so the engine reports usage under it.
482
+ #[uniffi::constructor]
483
+ pub fn new_with_app_id(
484
+ executor: Box<dyn FfiToolExecutor>,
485
+ approvals: Box<dyn FfiApprovalHandler>,
486
+ events: Box<dyn FfiEventListener>,
487
+ config: Option<FfiAgentConfig>,
488
+ app_id: Option<String>,
489
+ ) -> Arc<Self> {
490
+ Self::build(executor, approvals, events, config, app_id)
491
+ }
492
493
pub async fn register_tool(&self, tool: FfiToolDefinition) -> Result<(), FfiEdError> {
494
self.inner
@@ -556,6 +610,85 @@ impl FfiEdAgent {
610
}
611
}
612
613
+ /// Stream the reply to `message` into `listener`, for plain chat without tools.
614
+ /// Returns when the reply is complete, on error, or after `cancel_stream`.
615
+ /// The turn so far stays in history either way.
616
+ pub async fn stream_message(
617
+ &self,
618
+ message: String,
619
+ listener: Box<dyn FfiStreamListener>,
620
+ ) -> Result<(), FfiEdError> {
621
+ let mut stream = self
622
+ .inner
623
+ .stream(message)
624
+ .await
625
+ .map_err(|error| FfiEdError::Failure {
626
+ reason: error.to_string(),
627
+ })?;
628
+ let (cancel, mut cancelled) = tokio::sync::oneshot::channel();
629
+ *self.stream_cancel.lock().unwrap_or_else(|e| e.into_inner()) = Some(cancel);
630
+ loop {
631
+ tokio::select! {
632
+ next = stream.next() => match next {
633
+ Some(Ok(delta)) => listener.on_delta(delta),
634
+ Some(Err(error)) => {
635
+ return Err(FfiEdError::Failure { reason: error.to_string() });
636
+ }
637
+ None => return Ok(()),
638
+ },
639
+ _ = &mut cancelled => return Ok(()),
640
+ }
641
+ }
642
+ }
643
+
644
+ /// Stop the `stream_message` in progress. Does nothing when none is.
645
+ pub fn cancel_stream(&self) {
646
+ if let Some(cancel) = self
647
+ .stream_cancel
648
+ .lock()
649
+ .unwrap_or_else(|e| e.into_inner())
650
+ .take()
651
+ {
652
+ let _ = cancel.send(());
653
+ }
654
+ }
655
+
656
+ /// One-shot generation over `messages`. Leaves the conversation untouched.
657
+ pub async fn generate(
658
+ &self,
659
+ messages: Vec<FfiChatMessage>,
660
+ sampling: Option<FfiSamplingConfig>,
661
+ ) -> Result<String, FfiEdError> {
662
+ self.inner
663
+ .generate(
664
+ messages.into_iter().map(Into::into).collect(),
665
+ sampling.map(Into::into),
666
+ )
667
+ .await
668
+ .map_err(|error| FfiEdError::Failure {
669
+ reason: error.to_string(),
670
+ })
671
+ }
672
+
673
+ /// Replace the conversation with `messages`. Needs a loaded model.
674
+ pub async fn restore_history(&self, messages: Vec<FfiChatMessage>) {
675
+ self.inner
676
+ .restore_history(messages.into_iter().map(Into::into).collect())
677
+ .await;
678
+ }
679
+
680
+ pub async fn set_system_prompt(&self, prompt: String) {
681
+ self.inner.set_system_prompt(prompt).await;
682
+ }
683
+
684
+ pub async fn clear_system_prompt(&self) {
685
+ self.inner.clear_system_prompt().await;
686
+ }
687
+
688
+ pub async fn set_sampling(&self, sampling: FfiSamplingConfig) {
689
+ self.inner.set_sampling(sampling.into()).await;
690
+ }
691
+
692
pub fn cancel(&self) {
693
self.inner.cancel();
694
}
crates/ed-agent/Cargo.toml
+1
@@ -19,6 +19,7 @@ serde_json.workspace = true
19
log.workspace = true
20
thiserror.workspace = true
21
tokio.workspace = true
22
+futures-core = "0.3"
23
24
[dev-dependencies]
25
tokio = { version = "1", features = ["macros", "rt", "sync"] }
crates/ed-agent/src/ed.rs
+130
-15
@@ -4,7 +4,7 @@ use std::collections::{HashMap, HashSet};
4
use std::future::Future;
5
use std::sync::atomic::{AtomicBool, Ordering};
6
use std::sync::Arc;
7
-use std::time::Duration;
7
+use std::time::{Duration, Instant};
8
9
use log::{error, info};
10
use onde::inference::types::{format_duration, ChatMessage};
@@ -14,9 +14,10 @@ use onde::inference::{
14
};
15
16
use crate::{
17
- tool::truncate_output, AgentConfig, AgentError, AgentReply, AgentToolDefinition,
18
- ApprovalDecision, ApprovalHandler, ApprovalRequest, ChatReply, DenyApprovals, EventSink,
19
- NoopSink, RejectingExecutor, ToolCall, ToolExecutionResult, ToolExecutor, ToolRisk,
17
+ stream::chunk_error, tool::truncate_output, AgentConfig, AgentError, AgentReply,
18
+ AgentToolDefinition, ApprovalDecision, ApprovalHandler, ApprovalRequest, ChatReply,
19
+ DenyApprovals, EventSink, NoopSink, RejectingExecutor, TextStream, ToolCall,
20
+ ToolExecutionResult, ToolExecutor, ToolRisk,
21
};
22
23
/// An embeddable chat agent over a single [`ChatEngine`].
@@ -226,6 +227,60 @@ impl<S: EventSink> Ed<S> {
227
reply
228
}
229
230
+ /// Stream the reply to one message, for plain chat without tools.
231
+ ///
232
+ /// Returns once generation has started; the stream yields text deltas as
233
+ /// they are produced. Unlike [`run`](Ed::run) this takes no turn lock and
234
+ /// reports nothing to the sink, so don't overlap it with another turn.
235
+ /// Drop the stream to cancel; a [`cancel`](Ed::cancel) call is not needed.
236
+ pub async fn stream(&self, message: impl Into<String>) -> Result<TextStream, InferenceError> {
237
+ Ok(TextStream::new(self.engine.stream_message(message).await?))
238
+ }
239
+
240
+ /// One-shot generation over explicit messages.
241
+ ///
242
+ /// Leaves the conversation history untouched; the engine's system prompt
243
+ /// is still applied. `sampling` overrides the engine's for this call only.
244
+ pub async fn generate(
245
+ &self,
246
+ messages: Vec<ChatMessage>,
247
+ sampling: Option<SamplingConfig>,
248
+ ) -> Result<String, InferenceError> {
249
+ Ok(self.engine.generate(messages, sampling).await?.text)
250
+ }
251
+
252
+ /// Set the system prompt used for every later turn.
253
+ ///
254
+ /// Like the other engine setters below, and `restore_history`, this does
255
+ /// nothing until a model is loaded: `onde` keeps these on the loaded model.
256
+ pub async fn set_system_prompt(&self, prompt: impl Into<String>) {
257
+ self.engine.set_system_prompt(prompt).await;
258
+ }
259
+
260
+ pub async fn clear_system_prompt(&self) {
261
+ self.engine.clear_system_prompt().await;
262
+ }
263
+
264
+ /// Change the default sampling parameters.
265
+ pub async fn set_sampling(&self, sampling: SamplingConfig) {
266
+ self.engine.set_sampling(sampling).await;
267
+ }
268
+
269
+ /// Replace the conversation with `messages`.
270
+ ///
271
+ /// For switching to a saved session or restoring one at launch. Clears
272
+ /// the current history first, and with it every `AllowForSession` grant,
273
+ /// exactly as [`clear_history`](Ed::clear_history) does: a restored
274
+ /// conversation starts without consent from the previous one.
275
+ pub async fn restore_history(&self, messages: Vec<ChatMessage>) {
276
+ self.clear_history().await;
277
+ let count = messages.len();
278
+ for message in messages {
279
+ self.engine.push_history(message).await;
280
+ }
281
+ info!("ed: restored {count} messages");
282
+ }
283
+
284
/// Register a host capability that the model may request.
285
pub async fn register_tool(&self, tool: AgentToolDefinition) -> Result<(), AgentError> {
286
tool.validate()?;
@@ -345,20 +400,39 @@ impl<S: EventSink> Ed<S> {
400
// `ChatReply` to the sink, and a host listening for both events
401
// would render this turn's answer twice. An agent turn reports
402
// `agent_replied` and nothing else.
348
- let result =
349
- self.engine
350
- .send_message(message)
351
- .await
352
- .map_err(|error| AgentError::Inference {
353
- reason: error.to_string(),
354
- })?;
355
- if result.text.trim().is_empty() {
403
+ let started = Instant::now();
404
+ let mut rx = self.engine.stream_message(message).await.map_err(|error| {
405
+ AgentError::Inference {
406
+ reason: error.to_string(),
407
+ }
408
+ })?;
409
+ let mut text = String::new();
410
+ while let Some(chunk) = rx.recv().await {
411
+ if self.cancelled.load(Ordering::Acquire) {
412
+ // Dropping the receiver stops generation.
413
+ return Err(AgentError::Cancelled);
414
+ }
415
+ if chunk.done {
416
+ if let Some(error) = chunk_error(&chunk) {
417
+ return Err(AgentError::Inference {
418
+ reason: error.to_string(),
419
+ });
420
+ }
421
+ break;
422
+ }
423
+ if !chunk.delta.is_empty() {
424
+ self.sink.text_delta(&chunk.delta);
425
+ text.push_str(&chunk.delta);
426
+ }
427
+ }
428
+ if text.trim().is_empty() {
429
return Err(AgentError::EmptyReply);
430
}
431
+ let elapsed = started.elapsed();
432
let agent_reply = AgentReply {
359
- text: result.text,
360
- duration_seconds: result.duration_secs,
361
- duration: result.duration_display,
433
+ text,
434
+ duration_seconds: elapsed.as_secs_f64(),
435
+ duration: format_duration(elapsed),
436
tool_rounds: 0,
437
};
438
self.sink.agent_replied(&agent_reply);
@@ -482,6 +556,10 @@ impl<S: EventSink> Ed<S> {
556
AgentError::EmptyReply
557
});
558
}
559
+ // Tool rounds are not streamed: `onde` only parses tool calls out of a
560
+ // complete response, so the answer arrives whole. Report it as one
561
+ // delta so a host rendering deltas sees every turn the same way.
562
+ self.sink.text_delta(&result.text);
563
let reply = AgentReply {
564
text: result.text,
565
duration_seconds,
@@ -833,4 +911,41 @@ mod tests {
911
assert!(result.content.contains("JSON object"));
912
assert_eq!(executor.0.load(Ordering::Relaxed), 0);
913
}
914
+
915
+ #[tokio::test]
916
+ async fn restoring_history_revokes_session_grants() {
917
+ let ed = Ed::with_agent(
918
+ NoopSink,
919
+ Arc::new(CountingExecutor(AtomicUsize::new(0))),
920
+ Arc::new(FixedApproval(ApprovalDecision::AllowForSession)),
921
+ AgentConfig::default(),
922
+ );
923
+ ed.register_tool(AgentToolDefinition::mutating(
924
+ "save_note",
925
+ "Save a note",
926
+ r#"{"type":"object"}"#,
927
+ ))
928
+ .await
929
+ .unwrap();
930
+ let tools = ed.tools.read().await.clone();
931
+ ed.execute_tool_call(&call("save_note"), &tools).await;
932
+ assert!(ed.session_grants.lock().await.contains("save_note"));
933
+
934
+ ed.restore_history(Vec::new()).await;
935
+
936
+ assert!(ed.session_grants.lock().await.is_empty());
937
+ }
938
+
939
+ #[tokio::test]
940
+ async fn generation_and_streaming_need_a_loaded_model() {
941
+ let ed = Ed::new();
942
+ assert!(matches!(
943
+ ed.generate(vec![ChatMessage::user("hi")], None).await,
944
+ Err(InferenceError::NoModelLoaded)
945
+ ));
946
+ assert!(matches!(
947
+ ed.stream("hi").await,
948
+ Err(InferenceError::NoModelLoaded)
949
+ ));
950
+ }
951
}
crates/ed-agent/src/lib.rs
+6
-1
@@ -15,7 +15,8 @@
15
//! a frontend wants one shape covering both outcomes. That's [`ChatReply`].
16
//!
17
//! Ed deliberately does *not* own sessions, persistence, model catalogues, or
18
-//! downloads. Those differ per application and belong to the host.
18
+//! downloads. Those differ per application and belong to the host. For
19
+//! persistence it defines the [`SessionStore`] seam the host implements.
20
//!
21
//! # Platforms
22
//!
@@ -49,12 +50,16 @@
50
51
mod ed;
52
mod reply;
53
+mod session;
54
mod sink;
55
+mod stream;
56
mod tool;
57
58
pub use ed::Ed;
59
pub use reply::ChatReply;
60
+pub use session::{SessionError, SessionMeta, SessionStore};
61
pub use sink::{EventSink, NoopSink, StatusSink};
62
+pub use stream::TextStream;
63
pub use tool::{
64
AgentConfig, AgentError, AgentReply, AgentToolDefinition, ApprovalDecision, ApprovalHandler,
65
ApprovalRequest, DenyApprovals, RejectingExecutor, ToolCall, ToolExecutionResult, ToolExecutor,
crates/ed-agent/src/session.rs
new
+178
@@ -0,0 +1,178 @@
1
+//! The seam between Ed and a host's chat storage.
2
+//!
3
+//! Ed owns no storage. A host that keeps several conversations implements
4
+//! [`SessionStore`] over whatever it already has (files, SQLite, a keychain)
5
+//! and hands the messages to [`Ed::restore_history`](crate::Ed::restore_history)
6
+//! when the user switches conversation.
7
+
8
+use async_trait::async_trait;
9
+use onde::inference::types::ChatMessage;
10
+use serde::{Deserialize, Serialize};
11
+
12
+/// What a session list needs to render a row, without loading the messages.
13
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
14
+pub struct SessionMeta {
15
+ pub id: String,
16
+ pub title: String,
17
+ /// Unix seconds.
18
+ pub created_at: i64,
19
+ /// Unix seconds.
20
+ pub updated_at: i64,
21
+ pub message_count: usize,
22
+}
23
+
24
+/// A storage failure. The store decides the wording; Ed only carries it.
25
+#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
26
+#[error("{0}")]
27
+pub struct SessionError(pub String);
28
+
29
+/// Persistent conversations, keyed by an id the host chooses.
30
+///
31
+/// Contract:
32
+/// - `load` of an unknown id is `Ok(None)`, not an error.
33
+/// - `save` creates the session if it doesn't exist and replaces its
34
+/// messages if it does.
35
+/// - `delete` of an unknown id succeeds.
36
+/// - `list` is ordered most recently updated first.
37
+#[async_trait]
38
+pub trait SessionStore: Send + Sync + 'static {
39
+ async fn list(&self) -> Result<Vec<SessionMeta>, SessionError>;
40
+ async fn load(&self, id: &str) -> Result<Option<Vec<ChatMessage>>, SessionError>;
41
+ async fn save(&self, id: &str, messages: &[ChatMessage]) -> Result<(), SessionError>;
42
+ async fn delete(&self, id: &str) -> Result<(), SessionError>;
43
+}
44
+
45
+#[cfg(test)]
46
+mod tests {
47
+ use std::collections::HashMap;
48
+ use std::sync::Mutex;
49
+
50
+ use super::*;
51
+
52
+ struct Stored {
53
+ messages: Vec<ChatMessage>,
54
+ created: i64,
55
+ updated: i64,
56
+ }
57
+
58
+ /// The reference implementation of the contract above.
59
+ #[derive(Default)]
60
+ struct MemoryStore {
61
+ sessions: Mutex<HashMap<String, Stored>>,
62
+ clock: Mutex<i64>,
63
+ }
64
+
65
+ impl MemoryStore {
66
+ fn tick(&self) -> i64 {
67
+ let mut clock = self.clock.lock().unwrap();
68
+ *clock += 1;
69
+ *clock
70
+ }
71
+ }
72
+
73
+ #[async_trait]
74
+ impl SessionStore for MemoryStore {
75
+ async fn list(&self) -> Result<Vec<SessionMeta>, SessionError> {
76
+ let mut metas: Vec<_> = self
77
+ .sessions
78
+ .lock()
79
+ .unwrap()
80
+ .iter()
81
+ .map(|(id, stored)| SessionMeta {
82
+ id: id.clone(),
83
+ title: stored
84
+ .messages
85
+ .first()
86
+ .map(|m| m.content.clone())
87
+ .unwrap_or_default(),
88
+ created_at: stored.created,
89
+ updated_at: stored.updated,
90
+ message_count: stored.messages.len(),
91
+ })
92
+ .collect();
93
+ metas.sort_by(|a, b| b.updated_at.cmp(&a.updated_at));
94
+ Ok(metas)
95
+ }
96
+
97
+ async fn load(&self, id: &str) -> Result<Option<Vec<ChatMessage>>, SessionError> {
98
+ Ok(self
99
+ .sessions
100
+ .lock()
101
+ .unwrap()
102
+ .get(id)
103
+ .map(|s| s.messages.clone()))
104
+ }
105
+
106
+ async fn save(&self, id: &str, messages: &[ChatMessage]) -> Result<(), SessionError> {
107
+ let now = self.tick();
108
+ let mut sessions = self.sessions.lock().unwrap();
109
+ let created = sessions.get(id).map_or(now, |s| s.created);
110
+ sessions.insert(
111
+ id.to_owned(),
112
+ Stored {
113
+ messages: messages.to_vec(),
114
+ created,
115
+ updated: now,
116
+ },
117
+ );
118
+ Ok(())
119
+ }
120
+
121
+ async fn delete(&self, id: &str) -> Result<(), SessionError> {
122
+ self.sessions.lock().unwrap().remove(id);
123
+ Ok(())
124
+ }
125
+ }
126
+
127
+ #[tokio::test]
128
+ async fn loading_an_unknown_session_is_none() {
129
+ let store = MemoryStore::default();
130
+ assert!(store.load("nope").await.unwrap().is_none());
131
+ }
132
+
133
+ #[tokio::test]
134
+ async fn save_creates_then_replaces() {
135
+ let store = MemoryStore::default();
136
+ store.save("a", &[ChatMessage::user("hi")]).await.unwrap();
137
+ store
138
+ .save(
139
+ "a",
140
+ &[ChatMessage::user("hi"), ChatMessage::assistant("yo")],
141
+ )
142
+ .await
143
+ .unwrap();
144
+
145
+ let loaded = store.load("a").await.unwrap().unwrap();
146
+ assert_eq!(loaded.len(), 2);
147
+ let listed = store.list().await.unwrap();
148
+ assert_eq!(listed.len(), 1);
149
+ assert_eq!(listed[0].message_count, 2);
150
+ assert!(listed[0].updated_at > listed[0].created_at);
151
+ }
152
+
153
+ #[tokio::test]
154
+ async fn list_is_most_recently_updated_first() {
155
+ let store = MemoryStore::default();
156
+ store.save("old", &[ChatMessage::user("1")]).await.unwrap();
157
+ store.save("new", &[ChatMessage::user("2")]).await.unwrap();
158
+ store.save("old", &[ChatMessage::user("3")]).await.unwrap();
159
+
160
+ let ids: Vec<_> = store
161
+ .list()
162
+ .await
163
+ .unwrap()
164
+ .into_iter()
165
+ .map(|m| m.id)
166
+ .collect();
167
+ assert_eq!(ids, ["old", "new"]);
168
+ }
169
+
170
+ #[tokio::test]
171
+ async fn delete_is_idempotent() {
172
+ let store = MemoryStore::default();
173
+ store.save("a", &[]).await.unwrap();
174
+ store.delete("a").await.unwrap();
175
+ store.delete("a").await.unwrap();
176
+ assert!(store.list().await.unwrap().is_empty());
177
+ }
178
+}
crates/ed-agent/src/sink.rs
+23
@@ -38,6 +38,14 @@ pub trait EventSink: Send + Sync + 'static {
38
/// pick the result up independently.
39
fn replied(&self, _reply: &ChatReply) {}
40
41
+ /// A piece of the assistant's reply, as it is generated.
42
+ ///
43
+ /// Called zero or more times during [`Ed::run`](crate::Ed::run) before
44
+ /// the final reply is reported. Concatenating the deltas gives the reply
45
+ /// text. The default does nothing, so sinks written before streaming
46
+ /// existed keep compiling and keep seeing only the finished reply.
47
+ fn text_delta(&self, _delta: &str) {}
48
+
49
fn tool_requested(&self, _call: &ToolCall) {}
50
fn approval_requested(&self, _request: &ApprovalRequest) {}
51
fn tool_started(&self, _call: &ToolCall) {}
@@ -57,3 +65,18 @@ pub use EventSink as StatusSink;
65
pub struct NoopSink;
66
67
impl EventSink for NoopSink {}
68
+
69
+#[cfg(test)]
70
+mod tests {
71
+ use super::*;
72
+
73
+ struct Bare;
74
+ impl EventSink for Bare {}
75
+
76
+ #[test]
77
+ fn text_delta_defaults_to_a_no_op() {
78
+ // A sink that predates streaming must still compile and not panic.
79
+ Bare.text_delta("hello");
80
+ NoopSink.text_delta("hello");
81
+ }
82
+}
crates/ed-agent/src/stream.rs
new
+117
@@ -0,0 +1,117 @@
1
+//! Token streaming for plain chat.
2
+
3
+use std::pin::Pin;
4
+use std::task::{Context, Poll};
5
+
6
+use futures_core::Stream;
7
+use onde::inference::types::StreamChunk;
8
+use onde::inference::InferenceError;
9
+use tokio::sync::mpsc::Receiver;
10
+
11
+/// The reply to one message, as a stream of text deltas.
12
+///
13
+/// Yields `Ok(delta)` for each piece and ends after the last one. A failure
14
+/// mid-stream is a final `Err`. Dropping the stream stops generation: `onde`
15
+/// notices the closed channel and keeps only what was delivered.
16
+///
17
+/// The finished turn is added to the engine's history when the stream ends,
18
+/// as with [`Ed::send`](crate::Ed::send).
19
+pub struct TextStream {
20
+ rx: Receiver<StreamChunk>,
21
+ finished: bool,
22
+}
23
+
24
+impl TextStream {
25
+ pub(crate) fn new(rx: Receiver<StreamChunk>) -> Self {
26
+ Self {
27
+ rx,
28
+ finished: false,
29
+ }
30
+ }
31
+}
32
+
33
+impl TextStream {
34
+ /// The next delta, `None` when the reply is complete. For callers without a stream
35
+ /// combinator library.
36
+ pub async fn next(&mut self) -> Option<Result<String, InferenceError>> {
37
+ std::future::poll_fn(|cx| Pin::new(&mut *self).poll_next(cx)).await
38
+ }
39
+}
40
+
41
+/// `onde` reports a failed stream as a final chunk whose `finish_reason`
42
+/// starts with `error`.
43
+pub(crate) fn chunk_error(chunk: &StreamChunk) -> Option<InferenceError> {
44
+ chunk
45
+ .finish_reason
46
+ .as_deref()
47
+ .filter(|reason| reason.starts_with("error"))
48
+ .map(|reason| InferenceError::Inference {
49
+ reason: reason.trim_start_matches("error:").trim().to_owned(),
50
+ })
51
+}
52
+
53
+impl Stream for TextStream {
54
+ type Item = Result<String, InferenceError>;
55
+
56
+ fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
57
+ loop {
58
+ if self.finished {
59
+ return Poll::Ready(None);
60
+ }
61
+ match self.rx.poll_recv(cx) {
62
+ Poll::Pending => return Poll::Pending,
63
+ Poll::Ready(None) => {
64
+ self.finished = true;
65
+ return Poll::Ready(None);
66
+ }
67
+ Poll::Ready(Some(chunk)) => {
68
+ if chunk.done {
69
+ self.finished = true;
70
+ return Poll::Ready(chunk_error(&chunk).map(Err));
71
+ }
72
+ if chunk.delta.is_empty() {
73
+ continue;
74
+ }
75
+ return Poll::Ready(Some(Ok(chunk.delta)));
76
+ }
77
+ }
78
+ }
79
+ }
80
+}
81
+
82
+#[cfg(test)]
83
+mod tests {
84
+ use super::*;
85
+
86
+ fn chunk(delta: &str, done: bool, reason: Option<&str>) -> StreamChunk {
87
+ StreamChunk {
88
+ delta: delta.to_owned(),
89
+ done,
90
+ finish_reason: reason.map(str::to_owned),
91
+ }
92
+ }
93
+
94
+ #[tokio::test]
95
+ async fn yields_deltas_then_ends() {
96
+ let (tx, rx) = tokio::sync::mpsc::channel(8);
97
+ let mut stream = TextStream::new(rx);
98
+ tx.send(chunk("he", false, None)).await.unwrap();
99
+ tx.send(chunk("llo", false, None)).await.unwrap();
100
+ tx.send(chunk("", true, Some("stop"))).await.unwrap();
101
+
102
+ assert_eq!(stream.next().await.unwrap().unwrap(), "he");
103
+ assert_eq!(stream.next().await.unwrap().unwrap(), "llo");
104
+ assert!(stream.next().await.is_none());
105
+ }
106
+
107
+ #[tokio::test]
108
+ async fn a_failed_stream_ends_with_an_error() {
109
+ let (tx, rx) = tokio::sync::mpsc::channel(8);
110
+ let mut stream = TextStream::new(rx);
111
+ tx.send(chunk("", true, Some("error: boom"))).await.unwrap();
112
+
113
+ let err = stream.next().await.unwrap().unwrap_err();
114
+ assert!(err.to_string().contains("boom"));
115
+ assert!(stream.next().await.is_none());
116
+ }
117
+}