|
1 | | -//! What each stream-json event means for the chat and the bot's state |
| 1 | +//! Claude Code's stream-json, decoded into the chat (spec 8.1, 30): what each |
| 2 | +//! event means for the chat and the bot's state |
2 | 3 | //! (spec 8.1). Fields are read defensively: the format is not documented |
3 | 4 | //! and grows between Claude Code versions. |
4 | 5 |
|
5 | | -use botloft_core::chat::{TOOL_OUTPUT_MAX, clip, tool_file, tool_input_max, tool_summary}; |
6 | | -use botloft_core::command::tool_explanation; |
7 | 6 | use botloft_core::ids::BotId; |
8 | | -use botloft_core::protocol::{ChatBody, ReplyItem, TokenUsage, ToolItem, ToolStatus, TurnItem}; |
| 7 | +use botloft_core::protocol::TokenUsage; |
9 | 8 | use serde_json::Value; |
10 | 9 | use tracing::{debug, warn}; |
11 | 10 |
|
12 | | -use super::{account, control, items}; |
| 11 | +use super::{account, control, sink}; |
13 | 12 | use crate::state::{Daemon, Event}; |
14 | 13 | use crate::{context, routines, service}; |
15 | 14 |
|
16 | | -pub(super) fn apply(daemon: &Daemon, bot: &BotId, generation: u64, event: &Value) { |
| 15 | +pub(crate) fn apply(daemon: &Daemon, bot: &BotId, generation: u64, event: &Value) { |
17 | 16 | // Lines from a process that was replaced say nothing about the new one. |
18 | 17 | if !daemon.supervisor.is_current(bot, generation) { |
19 | 18 | return; |
@@ -74,7 +73,7 @@ fn init(daemon: &Daemon, bot: &BotId, event: &Value) { |
74 | 73 |
|
75 | 74 | /// The piece of reply text a `stream_event` carries, if that is what it is |
76 | 75 | /// (spec 8.3). A subagent's text stays out of the chat. |
77 | | -pub(super) fn live_text(event: &Value) -> Option<&str> { |
| 76 | +pub(crate) fn live_text(event: &Value) -> Option<&str> { |
78 | 77 | if event["type"] != "stream_event" || !event["parent_tool_use_id"].is_null() { |
79 | 78 | return None; |
80 | 79 | } |
@@ -105,36 +104,14 @@ fn assistant(daemon: &Daemon, bot: &BotId, generation: u64, event: &Value) { |
105 | 104 | context::used(daemon, bot, &event["message"]); |
106 | 105 | for block in blocks(event) { |
107 | 106 | match block["type"].as_str() { |
108 | | - Some("text") => { |
109 | | - let text = block["text"].as_str().unwrap_or_default().trim(); |
110 | | - if !text.is_empty() { |
111 | | - items::add( |
112 | | - daemon, |
113 | | - bot, |
114 | | - ChatBody::Reply(ReplyItem { |
115 | | - text: text.to_owned(), |
116 | | - }), |
117 | | - ); |
118 | | - } |
119 | | - } |
120 | | - Some("tool_use") => { |
121 | | - let name = block["name"].as_str().unwrap_or("tool"); |
122 | | - let input = &block["input"]; |
123 | | - items::add( |
124 | | - daemon, |
125 | | - bot, |
126 | | - ChatBody::Tool(ToolItem { |
127 | | - tool_use_id: block["id"].as_str().unwrap_or_default().to_owned(), |
128 | | - name: name.to_owned(), |
129 | | - summary: tool_summary(name, input), |
130 | | - explanation: tool_explanation(name, input), |
131 | | - input: clip(&input.to_string(), tool_input_max(name)), |
132 | | - status: ToolStatus::Running, |
133 | | - output: None, |
134 | | - file: tool_file(name, input), |
135 | | - }), |
136 | | - ); |
137 | | - } |
| 107 | + Some("text") => sink::reply(daemon, bot, block["text"].as_str().unwrap_or_default()), |
| 108 | + Some("tool_use") => sink::tool_started( |
| 109 | + daemon, |
| 110 | + bot, |
| 111 | + block["id"].as_str().unwrap_or_default(), |
| 112 | + block["name"].as_str().unwrap_or("tool"), |
| 113 | + &block["input"], |
| 114 | + ), |
138 | 115 | _ => {} |
139 | 116 | } |
140 | 117 | } |
@@ -167,25 +144,8 @@ fn tool_results(daemon: &Daemon, bot: &BotId, event: &Value) { |
167 | 144 | continue; |
168 | 145 | }; |
169 | 146 | crate::screens::tool_done(daemon, bot, id); |
170 | | - let found = daemon.store().tool_item(bot, id); |
171 | | - let Ok(Some(item)) = found else { |
172 | | - continue; |
173 | | - }; |
174 | | - let ChatBody::Tool(tool) = item.body else { |
175 | | - continue; |
176 | | - }; |
177 | 147 | let failed = block["is_error"].as_bool() == Some(true); |
178 | | - let output = result_text(&block["content"]); |
179 | | - let body = ChatBody::Tool(ToolItem { |
180 | | - status: if failed { |
181 | | - ToolStatus::Failed |
182 | | - } else { |
183 | | - ToolStatus::Done |
184 | | - }, |
185 | | - output: (!output.is_empty()).then(|| clip(&output, TOOL_OUTPUT_MAX)), |
186 | | - ..tool |
187 | | - }); |
188 | | - items::update(daemon, &item.id, body); |
| 148 | + sink::tool_finished(daemon, bot, id, failed, &result_text(&block["content"])); |
189 | 149 | } |
190 | 150 | } |
191 | 151 |
|
@@ -221,14 +181,12 @@ fn result(daemon: &Daemon, bot: &BotId, generation: u64, event: &Value) { |
221 | 181 | }); |
222 | 182 | // A compaction ends like a turn; its notice already says what happened. |
223 | 183 | if !context::turn_ended(daemon, bot, event) { |
224 | | - items::add( |
| 184 | + sink::turn_finished( |
225 | 185 | daemon, |
226 | 186 | bot, |
227 | | - ChatBody::Turn(TurnItem { |
228 | | - duration_ms: event["duration_ms"].as_u64().unwrap_or_default(), |
229 | | - tokens: tokens(&event["usage"], reloaded), |
230 | | - error, |
231 | | - }), |
| 187 | + event["duration_ms"].as_u64().unwrap_or_default(), |
| 188 | + tokens(&event["usage"], reloaded), |
| 189 | + error, |
232 | 190 | ); |
233 | 191 | } |
234 | 192 | routines::turn_ended(daemon, bot, failed); |
|
0 commit comments