Attach any file, take shares from other apps, and survive a backwards highlight

Attachments were images only. Now any file can be attached: from the file
chooser behind the "+" menu, or from Android's share sheet, which the app
is now in. An image still goes to the model as a picture; anything else is
stored under its own name (`<hex>-<name>`, cleaned by `safe_file_name`)
and the Claude driver ends the message with `Attached file: /abs/path`,
since the CLI reads files by path and a model cannot be shown a trace. The
user-message field is renamed `images` -> `attachments` on both sides,
with a serde alias reading the rows written before. A share arrives before
anyone has said which session it is for, so it is held in AppRoot with a
banner on the list until a session takes it; an open session takes it at
once. Unreadable shares are reported beside the composer, not thrown.

The tool card crashed the app when opened on a command holding a quoted
glob such as `-path '*/.git/*'`: highlights 1.1.0's shell lexer answers
`x '*/a/*'` with a span whose end is before its start, and AnnotatedString
refuses the range. Such spans are dropped; the library is the place for
the fix. The echo driver gains `/bash <command>` so a card with a given
command can be produced on the emulator.

ui-sandbox.sh's token salvage read the tokens block's close only at a line
start, ran past the compact `),],` the server writes, and copied `setups`
into the new config twice, which the server then refused.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
irisandClaude Fable 5.1 committed 2026-09-03 08:15:10 -04:00
1 parent 4bc69e8f9c
commit 6180663f14
25 files changed
+672 -165

No files matched your search

+54 -24
View File
@@ -57,7 +57,7 @@ use serde_json::{Value, json};
use tokio::io::AsyncWriteExt;
use tokio::sync::mpsc;
use super::driver::{Driver, Event, EventSink, ImageRef, SessionStatus, Unqueued};
use super::driver::{AttachmentRef, Driver, Event, EventSink, SessionStatus, Unqueued};
use super::process;
use super::transport::{Launch, Streams, Transport};
use crate::config::{ProviderConfig, SessionConfig};
@@ -133,7 +133,7 @@ struct Queue {
/// Written, not yet announced, oldest first, each with the id of the
/// `MessageQueued` that told the phone it was waiting -- so the
/// announcement can name which bubble it resolves.
awaiting: VecDeque<(String, String, Vec<ImageRef>)>,
awaiting: VecDeque<(String, String, Vec<AttachmentRef>)>,
/// The process is gone, so nothing can be taken up any more.
///
/// Needed because every other way out of a turn is an `Idle` this
@@ -550,20 +550,35 @@ impl ClaudeDriver {
}
impl Driver for ClaudeDriver {
fn send_user_message(&self, text: String, images: Vec<ImageRef>) {
fn send_user_message(&self, text: String, attachments: Vec<AttachmentRef>) {
let mut content = Vec::new();
for id in &images {
match attachment_block(&self.session_dir, id) {
Ok(block) => content.push(block),
Err(err) => {
let _ = self.sink.send(Event::Error {
message: format!("attachment {id} couldn't be sent: {err:#}"),
});
}
// An image goes into the message itself; the model looks at it. Any
// other file stays where the upload put it and the message says
// where, because the CLI can read a file by path and a model cannot
// be handed a trace, a log or a zip any other way. Named after the
// text, so the words come first, the way they were typed.
let mut files = Vec::new();
for id in &attachments {
let sent = if crate::media::media_type_for(id).is_some() {
attachment_block(&self.session_dir, id).map(|block| content.push(block))
} else {
attachment_path(&self.session_dir, id).map(|path| files.push(path))
};
if let Err(err) = sent {
let _ = self.sink.send(Event::Error {
message: format!("attachment {id} couldn't be sent: {err:#}"),
});
}
}
if !text.is_empty() {
content.push(json!({"type": "text", "text": text}));
let mut body = text.clone();
for path in files {
if !body.is_empty() {
body.push_str("\n\n");
}
body.push_str(&format!("Attached file: {}", path.display()));
}
if !body.is_empty() {
content.push(json!({"type": "text", "text": body}));
}
let line =
json!({"type": "user", "message": {"role": "user", "content": content}}).to_string();
@@ -591,9 +606,13 @@ impl Driver for ClaudeDriver {
let id = super::random_hex();
queue
.awaiting
.push_back((id.clone(), text.clone(), images.clone()));
.push_back((id.clone(), text.clone(), attachments.clone()));
drop(queue);
let _ = self.sink.send(Event::MessageQueued { id, text, images });
let _ = self.sink.send(Event::MessageQueued {
id,
text,
attachments,
});
self.send_line(line);
return;
}
@@ -605,7 +624,7 @@ impl Driver for ClaudeDriver {
let _ = self.sink.send(Event::MessageTaken {
id: None,
text,
images,
attachments,
});
let _ = self.sink.send(Event::Status {
state: SessionStatus::Running,
@@ -1087,16 +1106,16 @@ fn proves_a_turn(event: &Event) -> bool {
/// [`translate_line`], and the pair is the whole of the rule -- a steer
/// announced anywhere else lands above output that predates it.
fn announce_steers(queue: &Arc<Mutex<Queue>>, sink: &EventSink) -> bool {
let taken: Vec<(String, String, Vec<ImageRef>)> = {
let taken: Vec<(String, String, Vec<AttachmentRef>)> = {
let mut queue = queue.lock().unwrap();
queue.awaiting.drain(..).collect()
};
for (id, text, images) in taken {
for (id, text, attachments) in taken {
if sink
.send(Event::MessageTaken {
id: Some(id),
text,
images,
attachments,
})
.is_err()
{
@@ -1185,17 +1204,28 @@ pub(super) fn write_resume_token(session_dir: &Path, session_id: &str) {
}
}
/// Reads an uploaded attachment into an API image content block.
fn attachment_block(session_dir: &Path, id: &str) -> Result<Value> {
// Ids are server-generated hex (see routes::upload_attachment); the
// check keeps a crafted "id" from naming an arbitrary file.
/// Where an uploaded attachment is, as a path the CLI can be told.
///
/// Absolute, because the CLI's working directory is the session's and the
/// attachments are not in it. Refused rather than resolved when the id is
/// not one this server would have written -- see
/// `SessionManager::save_attachment` -- so a crafted id cannot name a file
/// outside the session.
fn attachment_path(session_dir: &Path, id: &str) -> Result<PathBuf> {
if !id
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '-')
.all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '-' || c == '_')
|| id.contains("..")
{
anyhow::bail!("invalid attachment id");
}
let path = session_dir.join("attachments").join(id);
std::fs::canonicalize(&path).with_context(|| format!("find {}", path.display()))
}
/// Reads an uploaded image into an API image content block.
fn attachment_block(session_dir: &Path, id: &str) -> Result<Value> {
let path = attachment_path(session_dir, id)?;
let bytes = std::fs::read(&path).with_context(|| format!("read {}", path.display()))?;
use base64::Engine;
Ok(json!({
+18 -7
View File
@@ -15,6 +15,14 @@ use tokio::sync::mpsc;
/// directions use the one id so the transcript renders them identically.
pub type ImageRef = String;
/// The name an upload from the phone is stored and served under: an image
/// is `<hex>.<extension>` and is an [`ImageRef`] like any other; any other
/// file keeps its own name after the hex, `<hex>-<name>`, because the name
/// is what the reader attached and what the session is told. The two are
/// told apart by `crate::media::media_type_for`, which knows every image
/// extension this server writes.
pub type AttachmentRef = String;
/// One choice offered in answer to a [`Event::Question`].
///
/// More than a label because the reader is deciding, not confirming: what
@@ -89,8 +97,11 @@ pub enum Event {
/// sent it -- and left the phone to decide, from nothing but
/// adjacency, which message an image belonged to. Belonging is not
/// something to infer when the sender knew.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
images: Vec<ImageRef>,
///
/// `images` on disk until 2026-09-03, when files joined them;
/// the alias reads the rows written before that.
#[serde(default, alias = "images", skip_serializing_if = "Vec::is_empty")]
attachments: Vec<AttachmentRef>,
},
/// A message accepted from the phone that the session cannot read yet.
///
@@ -111,8 +122,8 @@ pub enum Event {
/// Carried for the same reason [`Event::UserMessage`] carries it,
/// and it matters more here: a waiting message is on screen for as
/// long as the turn runs, so its attachment has nowhere else to be.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
images: Vec<ImageRef>,
#[serde(default, alias = "images", skip_serializing_if = "Vec::is_empty")]
attachments: Vec<AttachmentRef>,
},
/// A message taken out of the queue before the session read it, by
/// somebody tapping the bubble that was waiting for it.
@@ -146,8 +157,8 @@ pub enum Event {
id: Option<String>,
text: String,
/// Carried through onto the `UserMessage` with everything else.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
images: Vec<ImageRef>,
#[serde(default, alias = "images", skip_serializing_if = "Vec::is_empty")]
attachments: Vec<AttachmentRef>,
},
/// Streaming assistant text; the phone renders the concatenation as
/// markdown.
@@ -515,7 +526,7 @@ pub trait Driver: Send + Sync {
/// moment it actually starts reading it: that event is what puts the
/// message in the transcript, so a driver that never sends it drops
/// the message from the conversation entirely.
fn send_user_message(&self, text: String, images: Vec<ImageRef>);
fn send_user_message(&self, text: String, attachments: Vec<AttachmentRef>);
/// Takes back a message that is still waiting, named by the id its
/// [`Event::MessageQueued`] carried.
///
+44 -16
View File
@@ -7,6 +7,8 @@
//! A leading word asks for something more specific:
//!
//! - `/tool [input]` -- a full tool run, start through end.
//! - `/bash [command]` -- a Bash call carrying that command, for what the
//! phone's shell highlighting does to a particular line.
//! - `/tools [n] [gap]` -- n calls back to back, for what a run of them
//! looks like when a screen groups them. `gap` is seconds between one
//! call and the next, default none: it is what makes a run *grow* while
@@ -37,7 +39,7 @@
//! shape a real model's reply arrives in, and the one where the row a
//! reader is anchored to is the row that keeps changing height.
//! - `/mixed N` -- N beats of an interleaved transcript: paragraphs of
//! different lengths, single tool calls, runs of adjacent ones, images
//! different lengths, single tool calls, runs of adjacent ones, attachments
//! and a peer message. Rows of every shape and height the app draws, in
//! one session, which is what a scrolling problem needs in order to be
//! reproduced twice the same way.
@@ -55,7 +57,9 @@ use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::{Arc, Mutex};
use std::time::Duration;
use super::driver::{Driver, Event, EventSink, ImageRef, QuestionOption, SessionStatus, Unqueued};
use super::driver::{
AttachmentRef, Driver, Event, EventSink, QuestionOption, SessionStatus, Unqueued,
};
/// Delay between streamed deltas -- long enough that streaming is visibly
/// streaming in the UI, short enough that tests waiting on a full turn
@@ -94,7 +98,7 @@ pub struct EchoDriver {
/// Held messages with the id of the `MessageQueued` each one announced,
/// so the announcement can say which waiting bubble it resolves.
queued: Arc<Mutex<Vec<Held>>>,
/// Where `/mixed` writes the images it references, which is the same
/// Where `/mixed` writes the attachments it references, which is the same
/// directory the files route serves them from.
session_dir: PathBuf,
/// Ids of the questions awaiting an answer, in the order they were
@@ -249,7 +253,7 @@ impl EchoDriver {
/// transcript, and a command is not -- the manager has already
/// recorded that one was sent, and saying so twice drew the same
/// line in both colours.
fn handle(&self, text: String, images: Vec<ImageRef>, announce: bool) {
fn handle(&self, text: String, attachments: Vec<AttachmentRef>, announce: bool) {
let sink = self.sink.clone();
// Mid-turn messages are held rather than answered, the way a real
@@ -265,9 +269,13 @@ impl EchoDriver {
self.queued
.lock()
.unwrap()
.push((id.clone(), text.clone(), images.clone()));
.push((id.clone(), text.clone(), attachments.clone()));
if announce {
self.emit(Event::MessageQueued { id, text, images });
self.emit(Event::MessageQueued {
id,
text,
attachments,
});
}
return;
}
@@ -288,7 +296,7 @@ impl EchoDriver {
self.emit(Event::MessageTaken {
id: None,
text: text.clone(),
images: images.clone(),
attachments: attachments.clone(),
});
}
self.emit(Event::Status {
@@ -319,7 +327,7 @@ impl EchoDriver {
self.emit(Event::MessageTaken {
id: None,
text: text.clone(),
images: images.clone(),
attachments: attachments.clone(),
});
}
self.emit(Event::PeerMessage {
@@ -345,7 +353,7 @@ impl EchoDriver {
self.emit(Event::MessageTaken {
id: None,
text,
images,
attachments,
});
}
self.compact();
@@ -357,7 +365,7 @@ impl EchoDriver {
self.emit(Event::MessageTaken {
id: None,
text,
images,
attachments,
});
}
self.ask_user_question();
@@ -427,6 +435,9 @@ impl EchoDriver {
text.strip_prefix("/tool")
.map(|rest| rest.trim().to_string())
};
let run_bash = text
.strip_prefix("/bash")
.map(|rest| rest.trim().to_string());
// Seconds to stay running before answering, default 30. Clamped
// rather than trusted: this is a test affordance, and a session
// pinned running for an hour by a typo is a worse outcome than a
@@ -469,7 +480,7 @@ impl EchoDriver {
send(Event::MessageTaken {
id: None,
text: text.clone(),
images: images.clone(),
attachments: attachments.clone(),
});
}
send(Event::Status {
@@ -585,6 +596,23 @@ impl EchoDriver {
return;
}
if let Some(command) = run_bash {
let id = format!("b-{}", super::random_hex());
send(Event::ToolStart {
id: id.clone(),
tool: "Bash".to_string(),
input: serde_json::json!({
"command": command,
"description": "Run what /bash was given",
}),
});
tokio::time::sleep(DELTA_DELAY).await;
send(Event::ToolEnd {
id,
output: format!("ran: {command}"),
});
}
if let Some(input) = run_tool {
let id = format!("t-{}", super::random_hex());
send(Event::ToolStart {
@@ -762,7 +790,7 @@ async fn write_beat(sink: &EventSink, session_dir: &Path, beat: usize) {
/// it. All three, because all three are what the `MessageTaken` at the other
/// end owes -- named rather than written out at each of the four places that
/// mention it.
type Held = (String, String, Vec<ImageRef>);
type Held = (String, String, Vec<AttachmentRef>);
/// A markdown table [columns] wide, with cells too long for one line.
///
@@ -832,7 +860,7 @@ fn markdown_table(columns: usize) -> String {
/// of them owes the same answer.
fn finish_turn(sink: &EventSink, queued: &Mutex<Vec<Held>>, busy: &AtomicBool) {
let held = std::mem::take(&mut *queued.lock().unwrap());
for (id, text, images) in held {
for (id, text, attachments) in held {
// Announced before it is answered, in that order: a phone showing
// the message as pending needs the signal that it has been read,
// and the answer is meaningless above a message still drawn as
@@ -840,7 +868,7 @@ fn finish_turn(sink: &EventSink, queued: &Mutex<Vec<Held>>, busy: &AtomicBool) {
let _ = sink.send(Event::MessageTaken {
id: Some(id),
text: text.clone(),
images,
attachments,
});
let _ = sink.send(Event::AssistantText {
delta: format!("\n(taken from the queue) You said: {text}"),
@@ -874,14 +902,14 @@ impl Driver for EchoDriver {
Unqueued::Dropped
}
fn send_user_message(&self, text: String, images: Vec<ImageRef>) {
fn send_user_message(&self, text: String, attachments: Vec<AttachmentRef>) {
// Announced, because this is a message: every driver owes exactly
// one `MessageTaken` per message, and one that quietly vanishes
// from the transcript is the thing echo must not model. A command
// owes none -- the manager has already recorded that it was sent,
// and announcing it again drew the same line twice, once in each
// colour.
self.handle(text, images, true);
self.handle(text, attachments, true);
}
/// Echo's commands *are* its messages -- `/tool`, `/slow`, `/ask` --
+1 -1
View File
@@ -628,7 +628,7 @@ fn push_user(events: &mut Vec<Event>, content: &Value, session_dir: &std::path::
events.push(Event::UserMessage {
id: None,
text,
images: Vec::new(),
attachments: Vec::new(),
});
}
}
+16 -16
View File
@@ -33,7 +33,7 @@ use anyhow::{Context, Result, bail};
use serde::{Deserialize, Serialize};
use serde_json::json;
use super::driver::{Driver, Event, EventSink, ImageRef, SessionStatus};
use super::driver::{AttachmentRef, Driver, Event, EventSink, SessionStatus};
use super::process;
use super::transport::{Launch, Streams, Transport};
use crate::config::{ProviderConfig, SessionConfig};
@@ -322,10 +322,10 @@ fn watch(session_dir: PathBuf, sink: EventSink) {
}
impl Driver for LlamaDriver {
fn send_user_message(&self, text: String, images: Vec<ImageRef>) {
if !images.is_empty() {
fn send_user_message(&self, text: String, attachments: Vec<AttachmentRef>) {
if !attachments.is_empty() {
let _ = self.sink.send(Event::Error {
message: "this model can't be sent images".to_string(),
message: "this model can't be sent attachments or files".to_string(),
});
}
let sink = self.sink.clone();
@@ -344,9 +344,9 @@ impl Driver for LlamaDriver {
let _ = sink.send(Event::MessageTaken {
id: None,
text: text.clone(),
// Never any: this driver refuses images above, and saying
// so is what the refusal above is for.
images: Vec::new(),
// Never any: this driver refuses attachments above, and
// saying so is what the refusal above is for.
attachments: Vec::new(),
});
let _ = sink.send(Event::Status {
state: SessionStatus::Running,
@@ -649,7 +649,7 @@ mod tests {
Event::UserMessage {
id: None,
text: "hello".into(),
images: Vec::new(),
attachments: Vec::new(),
},
Event::AssistantText {
delta: "hi ".into(),
@@ -663,7 +663,7 @@ mod tests {
Event::UserMessage {
id: None,
text: "again".into(),
images: Vec::new(),
attachments: Vec::new(),
},
Event::AssistantText {
delta: "yes".into(),
@@ -695,7 +695,7 @@ mod tests {
Event::UserMessage {
id: None,
text: "count".into(),
images: Vec::new(),
attachments: Vec::new(),
},
Event::AssistantText {
delta: "one two".into(),
@@ -721,7 +721,7 @@ mod tests {
Event::UserMessage {
id: None,
text: "hello".into(),
images: Vec::new(),
attachments: Vec::new(),
},
Event::Error {
message: "something went wrong".into(),
@@ -749,7 +749,7 @@ mod tests {
Event::UserMessage {
id: None,
text: "the long expensive conversation".into(),
images: Vec::new(),
attachments: Vec::new(),
},
Event::AssistantText {
delta: "at length".into(),
@@ -758,7 +758,7 @@ mod tests {
Event::UserMessage {
id: None,
text: "a fresh start".into(),
images: Vec::new(),
attachments: Vec::new(),
},
Event::AssistantText {
delta: "cheaply".into(),
@@ -778,19 +778,19 @@ mod tests {
Event::UserMessage {
id: None,
text: "one".into(),
images: Vec::new(),
attachments: Vec::new(),
},
Event::Cleared,
Event::UserMessage {
id: None,
text: "two".into(),
images: Vec::new(),
attachments: Vec::new(),
},
Event::Cleared,
Event::UserMessage {
id: None,
text: "three".into(),
images: Vec::new(),
attachments: Vec::new(),
},
]);
let messages = conversation(&path);
+65 -11
View File
@@ -33,7 +33,7 @@ use crate::config::{
};
use claude::ClaudeDriver;
use driver::{
Driver, Event, EventSink, ImageRef, SessionCommand, SessionStatus, Unqueued, context_after,
AttachmentRef, Driver, Event, EventSink, SessionCommand, SessionStatus, Unqueued, context_after,
};
use echo::EchoDriver;
use llama::LlamaDriver;
@@ -417,14 +417,14 @@ impl LiveSession {
/// The message is deliberately not recorded here. Sent into a running
/// turn it waits, and writing it down on the way past would put it
/// above output that happened before the session ever saw it.
pub fn send_message(&self, text: String, images: Vec<ImageRef>) {
pub fn send_message(&self, text: String, attachments: Vec<AttachmentRef>) {
// The attachments ride *on* the message rather than as `Image`
// events emitted just before it. They used to be the latter, which
// drew a person's screenshot as a row floating above the bubble
// that sent it, and left the phone inferring from adjacency which
// message an image went with -- a thing the sender already knew.
self.ask("take a message", |driver| {
driver.send_user_message(text, images)
driver.send_user_message(text, attachments)
});
}
@@ -488,11 +488,24 @@ impl LiveSession {
/// Stores one uploaded attachment, returning the id `POST /message`
/// references it by. Removed with the session directory on delete --
/// the same path out as everything else in it.
pub fn save_attachment(&self, bytes: &[u8], content_type: &str) -> Result<String> {
// An unrecognized type is almost always a phone photo whose
// content type the picker didn't set; jpg is the useful guess.
let extension = crate::media::extension_for(content_type).unwrap_or("jpg");
let name = format!("{}.{extension}", random_hex());
///
/// An image is named `<hex>.<extension>` and nothing else, since the
/// model is shown the picture rather than told its name. Anything else
/// keeps the name it arrived with after the hex: the session is told
/// the path, and a trace called `trace-komodo-….perfetto-trace` says
/// more to it than `3f9a…` would. The name is cleaned to characters a
/// path and a URL both take unquoted, and the hex keeps two uploads of
/// the same name apart. `AttachmentRef` documents the two shapes.
pub fn save_attachment(
&self,
bytes: &[u8],
content_type: &str,
file_name: Option<&str>,
) -> Result<AttachmentRef> {
let name = match crate::media::extension_for(content_type) {
Some(extension) => format!("{}.{extension}", random_hex()),
None => format!("{}-{}", random_hex(), safe_file_name(file_name)),
};
let dir = self.dir().join("attachments");
wg_app_link::private::create_dir(&dir)?;
std::fs::write(dir.join(&name), bytes)
@@ -1442,14 +1455,19 @@ impl SessionManager {
/// Started before the message rather than after, because starting
/// replaces the driver and the driver that takes the message has to be
/// the one with a process behind it.
pub fn send_message(&self, id: &str, text: String, images: Vec<ImageRef>) -> Result<()> {
pub fn send_message(
&self,
id: &str,
text: String,
attachments: Vec<AttachmentRef>,
) -> Result<()> {
// Only `Exited` starts anything -- see `start_if_exited`. A session
// this cannot say has exited keeps the behaviour it always had: the
// message goes to the driver, which answers for it.
self.start_if_exited(id)?;
self.session(id)
.with_context(|| format!("no session {id}"))?
.send_message(text, images);
.send_message(text, attachments);
Ok(())
}
@@ -1767,6 +1785,34 @@ fn names<'a>(all: impl Iterator<Item = &'a str>) -> String {
/// 8 random bytes, hex -- short enough for a URL, unique enough forever at
/// this scale.
/// A file name reduced to what an attachment id may hold: letters, digits,
/// `.`, `-` and `_`, no run of dots that could read as a parent directory,
/// at most [`FILE_NAME_LIMIT`] characters keeping the tail (the extension
/// is what identifies a file), and `file` when nothing usable is left.
fn safe_file_name(name: Option<&str>) -> String {
let cleaned: String = name
.unwrap_or_default()
.chars()
.map(|c| {
if c.is_ascii_alphanumeric() || c == '.' || c == '-' || c == '_' {
c
} else {
'_'
}
})
.collect();
let cleaned = cleaned.replace("..", "_").trim_matches('.').to_string();
if cleaned.is_empty() {
return "file".to_string();
}
let excess = cleaned.chars().count().saturating_sub(FILE_NAME_LIMIT);
cleaned.chars().skip(excess).collect()
}
/// Longer than any name a person types, shorter than what a filesystem
/// refuses once the hex and a dash are in front of it.
const FILE_NAME_LIMIT: usize = 120;
pub fn random_hex() -> String {
use rand::Rng;
let mut bytes = [0u8; 8];
@@ -2232,7 +2278,15 @@ async fn pump(
// the position is the only way a reader can put it back where it
// happened -- see `Event::PeerMessage::turn_start`.
let event = match event {
Event::MessageTaken { id, text, images } => Event::UserMessage { id, text, images },
Event::MessageTaken {
id,
text,
attachments,
} => Event::UserMessage {
id,
text,
attachments,
},
Event::PeerMessage { from, text, .. } => Event::PeerMessage {
from,
text,
+1 -1
View File
@@ -636,7 +636,7 @@ mod tests {
Event::UserMessage {
id: None,
text: "hi".into(),
images: Vec::new(),
attachments: Vec::new(),
},
text("hello"),
Event::ToolStart {