Put a messageId on every message chunk
Agent message and thought chunks now carry a messageId, so a client can tell where one message ends and the next begins instead of guessing. Each inference round is one message: its reply text and its reasoning share an id, and the round after a tool call starts a new one. A notice sent on its own gets its own id, and replayed user and agent messages get fresh ones on session/load. Ids are random, so nothing new has to be stored in the history. Closes #195 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Seto Elkahfi committed
Oct 9, 2026 at 00:16 UTC
40a5a746928c364852812b279c393fe4b001f8c0
3 files changed
+172
-15
.agents/AGENTS.md
+4
@@ -118,6 +118,10 @@ feeds results back. Neither the loop nor ACP/TUI surfaces depend on a concrete b
118
`SessionState::remote`), so its turn can wait on the endpoint while another thread is
119
installed. Unknown session ids are rejected instead of silently borrowing the active thread's
120
cwd. Prompt cancellation takes no lock, which lets a client cancel a turn whatever it holds.
121
+ Message chunks carry a `messageId`: each inference round is one message, so every chunk of
122
+ it, reasoning included, shares `StreamedReply::message_id` and a tool round starts a new id.
123
+ A standalone notice (`send_assistant_message`) gets an id of its own, and replayed messages
124
+ get fresh ones; ids are random, so none is stored in history.
125
`session/load` and `session/resume` share `restore_session`; the only difference is that
126
resume must not replay the history as `session/update`. `session/close` is the one place a
127
`SessionState` is dropped. It runs in two halves: `begin_close` signals the session's turn
src/main.rs
+82
-15
@@ -81,7 +81,7 @@ use agent_client_protocol::schema::v1::{
81
DeleteSessionResponse, Diff, EmbeddedResourceResource, ForkSessionRequest, ForkSessionResponse,
82
Implementation, InitializeRequest, InitializeResponse, KillTerminalRequest,
83
ListSessionsRequest, ListSessionsResponse, LoadSessionRequest, LoadSessionResponse,
84
- LogoutCapabilities, LogoutRequest, LogoutResponse, McpCapabilities, McpServer, Meta,
84
+ LogoutCapabilities, LogoutRequest, LogoutResponse, McpCapabilities, McpServer, MessageId, Meta,
85
NewSessionRequest, NewSessionResponse, PermissionOption, PermissionOptionKind, Plan, PlanEntry,
86
PlanEntryPriority, PlanEntryStatus, PromptCapabilities, PromptRequest, PromptResponse,
87
ReadTextFileRequest, ReleaseTerminalRequest, RequestPermissionOutcome,
@@ -375,6 +375,10 @@ struct StreamedReply {
375
streamed_any: bool,
376
/// Whether the next visible text should open a new paragraph.
377
paragraph_pending: bool,
378
+ /// The ACP `messageId` of the model message being streamed. Each
379
+ /// inference round is its own message in history, so a tool round
380
+ /// interrupting the prose starts a new one.
381
+ message_id: Option<MessageId>,
382
}
383
384
/// What a streamed fragment newly revealed once `<think>` blocks were split:
@@ -392,6 +396,13 @@ impl StreamedReply {
396
/// no sentence to break away from, hence the `streamed_any` guard.
397
fn interrupt(&mut self) {
398
self.paragraph_pending = self.streamed_any;
399
+ self.message_id = None;
400
+ }
401
+
402
+ /// The id every chunk of the current model message carries, its reply
403
+ /// text and its reasoning alike.
404
+ fn message_id(&mut self) -> MessageId {
405
+ self.message_id.get_or_insert_with(new_message_id).clone()
406
}
407
408
/// Fold one streamed fragment into the reply and return the text it newly
@@ -449,6 +460,12 @@ impl StreamedReply {
460
}
461
}
462
463
+/// A fresh ACP `messageId`. Ids are opaque and only have to be unique within
464
+/// a session, so a random one serves both live chunks and replayed ones.
465
+fn new_message_id() -> MessageId {
466
+ MessageId::new(format!("msg_{}", uuid::Uuid::new_v4().simple()))
467
+}
468
+
469
#[derive(Default)]
470
struct PromptCancellation {
471
cancelled: AtomicBool,
@@ -671,17 +688,17 @@ fn history_replay_updates(history: &[serde_json::Value]) -> Vec<SessionUpdate> {
688
"user" => {
689
let text = history_message_text(message);
690
if !text.trim().is_empty() {
674
- updates.push(SessionUpdate::UserMessageChunk(ContentChunk::new(
675
- ContentBlock::from(text),
676
- )));
691
+ updates.push(SessionUpdate::UserMessageChunk(
692
+ ContentChunk::new(ContentBlock::from(text)).message_id(new_message_id()),
693
+ ));
694
}
695
}
696
"assistant" => {
697
let (_think, visible) = chat::strip_think_blocks(&history_message_text(message));
698
if !visible.trim().is_empty() {
682
- updates.push(SessionUpdate::AgentMessageChunk(ContentChunk::new(
683
- ContentBlock::from(visible),
684
- )));
699
+ updates.push(SessionUpdate::AgentMessageChunk(
700
+ ContentChunk::new(ContentBlock::from(visible)).message_id(new_message_id()),
701
+ ));
702
}
703
for call in message["tool_calls"].as_array().into_iter().flatten() {
704
let name = call["function"]["name"].as_str().unwrap_or("tool");
@@ -1569,29 +1586,50 @@ impl SiGitAgent {
1586
Ok(())
1587
}
1588
1589
+ /// Send a message of its own: a notice, a slash command's answer. It gets
1590
+ /// a fresh `messageId`, so a client never folds it into the reply around
1591
+ /// it. Text that belongs to the model's reply goes through
1592
+ /// [`Self::send_reply_chunk`] instead.
1593
fn send_assistant_message(
1594
&self,
1595
cx: &ConnectionTo<Client>,
1596
session_id: SessionId,
1597
text: impl Into<String>,
1598
+ ) -> agent_client_protocol::Result<()> {
1599
+ self.send_reply_chunk(cx, session_id, new_message_id(), text)
1600
+ }
1601
+
1602
+ /// Send one chunk of the model message `message_id`.
1603
+ fn send_reply_chunk(
1604
+ &self,
1605
+ cx: &ConnectionTo<Client>,
1606
+ session_id: SessionId,
1607
+ message_id: MessageId,
1608
+ text: impl Into<String>,
1609
) -> agent_client_protocol::Result<()> {
1610
cx.send_notification(SessionNotification::new(
1611
session_id,
1580
- SessionUpdate::AgentMessageChunk(ContentChunk::new(ContentBlock::from(text.into()))),
1612
+ SessionUpdate::AgentMessageChunk(
1613
+ ContentChunk::new(ContentBlock::from(text.into())).message_id(message_id),
1614
+ ),
1615
))
1616
}
1617
1618
/// Stream a fragment of the model's reasoning as a thought chunk. Clients
1619
/// render these in a collapsed "thinking" section, separate from the reply.
1620
+ /// It carries the id of the model message it came with.
1621
fn send_agent_thought(
1622
&self,
1623
cx: &ConnectionTo<Client>,
1624
session_id: SessionId,
1625
+ message_id: MessageId,
1626
text: impl Into<String>,
1627
) -> agent_client_protocol::Result<()> {
1628
cx.send_notification(SessionNotification::new(
1629
session_id,
1594
- SessionUpdate::AgentThoughtChunk(ContentChunk::new(ContentBlock::from(text.into()))),
1630
+ SessionUpdate::AgentThoughtChunk(
1631
+ ContentChunk::new(ContentBlock::from(text.into())).message_id(message_id),
1632
+ ),
1633
))
1634
}
1635
@@ -1671,16 +1709,20 @@ impl SiGitAgent {
1709
) {
1710
match chunk {
1711
TokenChunk::Reasoning(text) => {
1674
- self.send_agent_thought(cx, session_id.clone(), text).ok();
1712
+ let message_id = reply.message_id();
1713
+ self.send_agent_thought(cx, session_id.clone(), message_id, text)
1714
+ .ok();
1715
}
1716
TokenChunk::Visible(piece) => {
1717
let revealed = reply.push(&piece);
1718
if let Some(reasoning) = revealed.reasoning {
1679
- self.send_agent_thought(cx, session_id.clone(), reasoning)
1719
+ let message_id = reply.message_id();
1720
+ self.send_agent_thought(cx, session_id.clone(), message_id, reasoning)
1721
.ok();
1722
}
1723
if let Some(visible) = revealed.visible {
1683
- self.send_assistant_message(cx, session_id.clone(), visible)
1724
+ let message_id = reply.message_id();
1725
+ self.send_reply_chunk(cx, session_id.clone(), message_id, visible)
1726
.ok();
1727
}
1728
}
@@ -3894,7 +3936,8 @@ impl SiGitAgent {
3936
};
3937
3938
if !final_text.is_empty() {
3897
- self.send_assistant_message(cx, session_id.clone(), final_text)
3939
+ let message_id = reply.message_id();
3940
+ self.send_reply_chunk(cx, session_id.clone(), message_id, final_text)
3941
.ok();
3942
}
3943
} else if round > 0 && !round_cap_reached && reply.sent.len() == sent_before_last_round {
@@ -3907,9 +3950,11 @@ impl SiGitAgent {
3950
session_id,
3951
round
3952
);
3910
- self.send_assistant_message(
3953
+ let message_id = reply.message_id();
3954
+ self.send_reply_chunk(
3955
cx,
3956
session_id.clone(),
3957
+ message_id,
3958
format!(
3959
"{PARAGRAPH_BREAK}{}",
3960
silent_stop_message(stopped_repeating.as_deref(), round)
@@ -3924,9 +3969,11 @@ impl SiGitAgent {
3969
session_id,
3970
max_tool_rounds
3971
);
3927
- self.send_assistant_message(
3972
+ let message_id = reply.message_id();
3973
+ self.send_reply_chunk(
3974
cx,
3975
session_id.clone(),
3976
+ message_id,
3977
format!(
3978
"{PARAGRAPH_BREAK}{}",
3979
round_cap_stop_message(max_tool_rounds)
@@ -7186,6 +7233,26 @@ mod tests {
7233
other => panic!("expected the tool call, got {other:?}"),
7234
}
7235
assert!(matches!(updates[3], SessionUpdate::AgentMessageChunk(_)));
7236
+
7237
+ // Each replayed message has an id of its own.
7238
+ let ids: Vec<_> = updates
7239
+ .iter()
7240
+ .filter_map(|update| match update {
7241
+ SessionUpdate::UserMessageChunk(chunk)
7242
+ | SessionUpdate::AgentMessageChunk(chunk) => Some(
7243
+ chunk
7244
+ .message_id
7245
+ .clone()
7246
+ .expect("replayed chunk has a messageId"),
7247
+ ),
7248
+ _ => None,
7249
+ })
7250
+ .collect();
7251
+ assert_eq!(ids.len(), 3);
7252
+ assert!(
7253
+ ids[0] != ids[1] && ids[1] != ids[2] && ids[0] != ids[2],
7254
+ "{ids:?}"
7255
+ );
7256
}
7257
7258
#[test]
tests/acp_permissions.rs
+86
@@ -1139,6 +1139,92 @@ fn text_from_two_tool_rounds_reaches_the_client_as_separate_paragraphs() {
1139
let _ = std::fs::remove_dir_all(&scratch);
1140
}
1141
1142
+/// Every chunk of one model message carries the same `messageId`, its
1143
+/// reasoning included, and the message after a tool round gets a new one
1144
+/// (issue #195).
1145
+#[test]
1146
+fn chunks_of_one_model_message_share_a_message_id() {
1147
+ let endpoint = start_fake_endpoint(vec![
1148
+ sse_body(&[
1149
+ json!({"choices": [{"delta": {"reasoning_content": "Where is it?"}}]}),
1150
+ json!({"choices": [{"delta": {"content": "I'll look "}}]}),
1151
+ json!({"choices": [{"delta": {"content": "around."}}]}),
1152
+ json!({
1153
+ "choices": [{"delta": {"tool_calls": [{
1154
+ "index": 0,
1155
+ "id": "call_1",
1156
+ "function": {"name": "list_directory", "arguments": r#"{"path":"."}"#},
1157
+ }]}}]
1158
+ }),
1159
+ ]),
1160
+ sse_text("Found it."),
1161
+ ]);
1162
+
1163
+ let scratch = std::env::temp_dir().join(format!("sigit_acp_msg_ids_{}", std::process::id()));
1164
+ let config_dir = scratch.join("config");
1165
+ let cwd = scratch.join("cwd");
1166
+ std::fs::create_dir_all(&config_dir).unwrap();
1167
+ std::fs::create_dir_all(&cwd).unwrap();
1168
+
1169
+ let mut agent = spawn_agent(endpoint.port, &config_dir);
1170
+
1171
+ let id = agent.request(
1172
+ "initialize",
1173
+ json!({"protocolVersion": 1, "clientCapabilities": {}}),
1174
+ );
1175
+ agent.wait_for_response(id);
1176
+
1177
+ let id = agent.request("session/new", json!({"cwd": cwd, "mcpServers": []}));
1178
+ let session_id = agent.wait_for_response(id)["result"]["sessionId"]
1179
+ .as_str()
1180
+ .expect("session id")
1181
+ .to_string();
1182
+
1183
+ let prompt_id = agent.request(
1184
+ "session/prompt",
1185
+ json!({
1186
+ "sessionId": session_id,
1187
+ "prompt": [{"type": "text", "text": "find it"}],
1188
+ }),
1189
+ );
1190
+ let (_, updates) = agent.wait_for_response_with_updates(prompt_id);
1191
+
1192
+ let chunks: Vec<(&str, &str)> = updates
1193
+ .iter()
1194
+ .filter(|update| {
1195
+ update["sessionUpdate"] == "agent_message_chunk"
1196
+ || update["sessionUpdate"] == "agent_thought_chunk"
1197
+ })
1198
+ .map(|update| {
1199
+ (
1200
+ update["content"]["text"].as_str().unwrap_or_default(),
1201
+ update["messageId"]
1202
+ .as_str()
1203
+ .unwrap_or_else(|| panic!("chunk without a messageId: {update}")),
1204
+ )
1205
+ })
1206
+ .collect();
1207
+ let id_of = |text: &str| {
1208
+ chunks
1209
+ .iter()
1210
+ .find(|(chunk, _)| chunk.contains(text))
1211
+ .unwrap_or_else(|| panic!("no chunk with {text:?} in {chunks:?}"))
1212
+ .1
1213
+ };
1214
+
1215
+ let first = id_of("Where is it?");
1216
+ assert_eq!(id_of("I'll look"), first, "{chunks:?}");
1217
+ assert_eq!(id_of("around."), first, "{chunks:?}");
1218
+ assert_ne!(
1219
+ id_of("Found it."),
1220
+ first,
1221
+ "the round after the tool call is a new message: {chunks:?}"
1222
+ );
1223
+
1224
+ drop(agent);
1225
+ let _ = std::fs::remove_dir_all(&scratch);
1226
+}
1227
+
1228
/// A model that writes its tool call out as literal `<tool_call>` text instead
1229
/// of using the structured field must still drive the loop. Before recovery
1230
/// the tag was streamed to the client as prose and the turn ended with no tool