-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathtranscript.rs
More file actions
289 lines (264 loc) · 10.9 KB
/
Copy pathtranscript.rs
File metadata and controls
289 lines (264 loc) · 10.9 KB
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
//! Streaming parser for Claude Code session transcripts (JSONL).
//!
//! A transcript is a newline-delimited sequence of records. The ones that matter here:
//!
//! - `{"type":"assistant","message":{"content":[{"type":"text",...},{"type":"tool_use",...}]}}`
//! - `{"type":"user","message":{"content":[{"type":"tool_result",...}]},"toolUseResult":{...}}`
//!
//! Tool results arrive on `user` records and carry two payloads: the `tool_result` content
//! block (what the model saw) and a top-level `toolUseResult` (structured metadata Claude Code
//! records alongside it). The structured payload is richer -- for `Bash` it separates stdout
//! from stderr and flags interruption -- so it is preferred when present.
//!
//! Note that exit codes are **not** recorded in the transcript. Whether a command succeeded has
//! to be recovered from its output, which is what [`crate::runners`] does.
use std::path::Path;
use anyhow::{Context, Result};
use serde_json::Value;
/// One tool invocation paired with its result, in transcript order.
#[derive(Debug, Clone)]
pub struct ToolCall {
/// Position in the event stream. Used for ordering comparisons (staleness).
pub seq: usize,
pub id: String,
pub name: String,
pub input: Value,
pub result: Option<ToolResult>,
pub timestamp: Option<String>,
}
impl ToolCall {
/// A string input field, e.g. `command` for Bash or `file_path` for Edit.
pub fn input_str(&self, key: &str) -> Option<&str> {
self.input.get(key).and_then(Value::as_str)
}
/// Combined stdout/stderr of the call, however the transcript happened to record it.
pub fn output(&self) -> String {
match &self.result {
Some(r) => r.text(),
None => String::new(),
}
}
}
#[derive(Debug, Clone, Default)]
pub struct ToolResult {
/// The `tool_result` content block as plain text.
pub content: String,
pub stdout: Option<String>,
pub stderr: Option<String>,
/// True when the user or the harness cut the command short.
pub interrupted: bool,
/// True when the tool call itself failed (blocked, denied, invalid args).
pub is_error: bool,
/// Structured `toolUseResult` payload, when present.
pub structured: Option<Value>,
}
impl ToolResult {
pub fn text(&self) -> String {
match (&self.stdout, &self.stderr) {
(Some(o), Some(e)) if !e.trim().is_empty() => format!("{o}\n{e}"),
(Some(o), _) => o.clone(),
(None, Some(e)) => e.clone(),
(None, None) => self.content.clone(),
}
}
}
/// A block of assistant prose. Claims are extracted from these.
#[derive(Debug, Clone)]
pub struct AssistantText {
pub seq: usize,
pub text: String,
pub timestamp: Option<String>,
/// True if this is the final assistant message of the session -- the summary the user reads,
/// where unsupported claims do the most damage.
pub is_last: bool,
}
#[derive(Debug, Default)]
pub struct Transcript {
pub tool_calls: Vec<ToolCall>,
pub assistant_texts: Vec<AssistantText>,
pub session_id: Option<String>,
pub cwd: Option<String>,
}
impl Transcript {
pub fn parse_file(path: &Path) -> Result<Self> {
let raw = std::fs::read_to_string(path)
.with_context(|| format!("reading transcript {}", path.display()))?;
Ok(Self::parse_str(&raw))
}
pub fn parse_str(raw: &str) -> Self {
let mut t = Transcript::default();
let mut seq = 0usize;
// tool_use id -> index into tool_calls, so results can be attached when they arrive later.
let mut by_id: std::collections::HashMap<String, usize> = std::collections::HashMap::new();
for line in raw.lines() {
let line = line.trim();
if line.is_empty() {
continue;
}
let Ok(rec) = serde_json::from_str::<Value>(line) else {
continue; // A partially written trailing line is normal for a live session.
};
if t.session_id.is_none() {
t.session_id = rec
.get("sessionId")
.or_else(|| rec.get("session_id"))
.and_then(Value::as_str)
.map(String::from);
}
if t.cwd.is_none() {
t.cwd = rec.get("cwd").and_then(Value::as_str).map(String::from);
}
let kind = rec.get("type").and_then(Value::as_str).unwrap_or("");
let timestamp = rec
.get("timestamp")
.and_then(Value::as_str)
.map(String::from);
let Some(content) = rec
.get("message")
.and_then(|m| m.get("content"))
.and_then(Value::as_array)
else {
continue;
};
for block in content {
let btype = block.get("type").and_then(Value::as_str).unwrap_or("");
match (kind, btype) {
("assistant", "text") => {
let text = block
.get("text")
.and_then(Value::as_str)
.unwrap_or_default()
.to_string();
if !text.trim().is_empty() {
seq += 1;
t.assistant_texts.push(AssistantText {
seq,
text,
timestamp: timestamp.clone(),
is_last: false,
});
}
}
("assistant", "tool_use") => {
let id = block
.get("id")
.and_then(Value::as_str)
.unwrap_or_default()
.to_string();
seq += 1;
by_id.insert(id.clone(), t.tool_calls.len());
t.tool_calls.push(ToolCall {
seq,
id,
name: block
.get("name")
.and_then(Value::as_str)
.unwrap_or_default()
.to_string(),
input: block.get("input").cloned().unwrap_or(Value::Null),
result: None,
timestamp: timestamp.clone(),
});
}
(_, "tool_result") => {
let id = block
.get("tool_use_id")
.and_then(Value::as_str)
.unwrap_or_default();
let Some(&idx) = by_id.get(id) else { continue };
let structured = rec.get("toolUseResult").cloned();
let mut res = ToolResult {
content: stringify_content(block.get("content")),
is_error: block
.get("is_error")
.and_then(Value::as_bool)
.unwrap_or(false),
..Default::default()
};
if let Some(s) = &structured {
res.stdout = s.get("stdout").and_then(Value::as_str).map(String::from);
res.stderr = s.get("stderr").and_then(Value::as_str).map(String::from);
res.interrupted = s
.get("interrupted")
.and_then(Value::as_bool)
.unwrap_or(false);
}
// Blocked or denied calls come back as a bare string, not an object.
if res.content.contains("<tool_use_error>") {
res.is_error = true;
}
res.structured = structured;
t.tool_calls[idx].result = Some(res);
}
_ => {}
}
}
}
if let Some(last) = t.assistant_texts.last_mut() {
last.is_last = true;
}
t
}
/// Tool calls of a given name, in order.
pub fn calls_named<'a>(&'a self, name: &'a str) -> impl Iterator<Item = &'a ToolCall> + 'a {
self.tool_calls.iter().filter(move |c| c.name == name)
}
/// Highest sequence number in the transcript, for "did anything happen after X" checks.
pub fn max_seq(&self) -> usize {
self.tool_calls
.last()
.map(|c| c.seq)
.max(self.assistant_texts.last().map(|a| a.seq))
.unwrap_or(0)
}
}
/// A `tool_result` content field is either a string or a list of typed blocks.
fn stringify_content(v: Option<&Value>) -> String {
match v {
Some(Value::String(s)) => s.clone(),
Some(Value::Array(items)) => items
.iter()
.filter_map(|b| b.get("text").and_then(Value::as_str))
.collect::<Vec<_>>()
.join("\n"),
_ => String::new(),
}
}
#[cfg(test)]
mod tests {
use super::*;
const SAMPLE: &str = r#"
{"type":"assistant","timestamp":"2026-01-01T00:00:00Z","cwd":"/repo","message":{"content":[{"type":"tool_use","id":"t1","name":"Bash","input":{"command":"pytest -q"}}]}}
{"type":"user","toolUseResult":{"stdout":"3 passed in 0.1s","stderr":"","interrupted":false},"message":{"content":[{"type":"tool_result","tool_use_id":"t1","content":"3 passed in 0.1s"}]}}
{"type":"assistant","message":{"content":[{"type":"text","text":"All tests pass."}]}}
"#;
#[test]
fn pairs_tool_calls_with_results() {
let t = Transcript::parse_str(SAMPLE);
assert_eq!(t.tool_calls.len(), 1);
let call = &t.tool_calls[0];
assert_eq!(call.name, "Bash");
assert_eq!(call.input_str("command"), Some("pytest -q"));
assert!(call.output().contains("3 passed"));
}
#[test]
fn marks_final_assistant_message() {
let t = Transcript::parse_str(SAMPLE);
assert_eq!(t.assistant_texts.len(), 1);
assert!(t.assistant_texts[0].is_last);
}
#[test]
fn tolerates_truncated_trailing_line() {
let t = Transcript::parse_str(&format!("{SAMPLE}\n{{\"type\":\"assis"));
assert_eq!(t.tool_calls.len(), 1);
}
#[test]
fn detects_blocked_tool_call() {
let raw = r#"
{"type":"assistant","message":{"content":[{"type":"tool_use","id":"t1","name":"Bash","input":{"command":"rm -rf /"}}]}}
{"type":"user","message":{"content":[{"type":"tool_result","tool_use_id":"t1","content":"<tool_use_error>Blocked</tool_use_error>"}]}}
"#;
let t = Transcript::parse_str(raw);
assert!(t.tool_calls[0].result.as_ref().unwrap().is_error);
}
}