From 4c587bbca2feee74320e3d081f37d8007c140bd1 Mon Sep 17 00:00:00 2001 From: Auceptin Date: Mon, 19 Oct 2026 22:22:24 -0700 Subject: [PATCH 01/25] feat(usage)!: read conversation entries and show each event in full - Conversation reads `entries` (role, text, call_id, name, item_id) in place of the retired `messages`; default titles take the first user entry. - The trajectory shows every entry each event carries, in order: messages with their reported role, tool calls and results from their entries with their call id. Requests no longer hide history their stream has seen, and tool results are neither de-duplicated nor attached to their call. - Trajectory tests become two case tables plus a few focused tests. Co-Authored-By: Claude Opus 5.5 --- .../src/storage/sqlite/session_titles.rs | 6 +- .../abyss-backend/src/usage/conversation.rs | 52 +- .../abyss-backend/src/usage/session_title.rs | 33 +- crates/abyss-backend/src/usage/trajectory.rs | 1105 ++++------------- .../src/usage/trajectory/conversation.rs | 142 +-- scripts/tests/session_titles_api.py | 2 +- 6 files changed, 361 insertions(+), 979 deletions(-) diff --git a/crates/abyss-backend/src/storage/sqlite/session_titles.rs b/crates/abyss-backend/src/storage/sqlite/session_titles.rs index 86ec7e8..909e6d6 100644 --- a/crates/abyss-backend/src/storage/sqlite/session_titles.rs +++ b/crates/abyss-backend/src/storage/sqlite/session_titles.rs @@ -184,7 +184,7 @@ mod tests { database } - /// A request whose collector conversation holds one user message. + /// A request whose collector conversation holds one user entry. fn request(agent: &str, session: &str, id: &str, at: &str, input: &str) -> Value { event( agent, @@ -193,7 +193,7 @@ mod tests { at, "request", json!({"conversation": { - "messages": [{"role": "user", "text": input}], + "entries": [{"role": "user", "text": input}], "completeness": "complete" }}), ) @@ -346,7 +346,7 @@ mod tests { "2026-09-30T00:00:01Z", "response", json!({"conversation": { - "messages": [{"role": "assistant", "text": "{\"title\":\"Quoted\"}"}], + "entries": [{"role": "assistant", "text": "{\"title\":\"Quoted\"}"}], "completeness": "complete" }}), ); diff --git a/crates/abyss-backend/src/usage/conversation.rs b/crates/abyss-backend/src/usage/conversation.rs index 9814625..abe6c48 100644 --- a/crates/abyss-backend/src/usage/conversation.rs +++ b/crates/abyss-backend/src/usage/conversation.rs @@ -5,11 +5,8 @@ use serde_json::Value; #[derive(Deserialize)] pub struct Conversation { - pub messages: Vec, - /// Whether the first message is the request's own instructions, such as - /// a system prompt or OpenAI `instructions`; absent means none. - #[serde(default)] - pub instructions_present: bool, + /// One entry per protocol block or item, in provider order. + pub entries: Vec, completeness: Completeness, } @@ -21,13 +18,22 @@ enum Completeness { Unsupported, } -/// One message as the collector reports it; its role is kept as reported. +/// One entry as the collector reports it; its role is kept as reported. #[derive(Clone, Deserialize)] -pub struct Message { - #[serde(default)] - pub id: Option, +pub struct Entry { pub role: String, + /// Visible text; a tool call's input, a tool result's output. pub text: String, + /// Identifier pairing a tool call with its result; tool entries only. + #[serde(default)] + pub call_id: Option, + /// Tool name; tool calls only. + #[serde(default)] + pub name: Option, + /// Identity of the item the entry belongs to, as the traffic carries it. + #[serde(default)] + #[expect(dead_code, reason = "read once streams are linked by item identity")] + pub item_id: Option, } impl Conversation { @@ -47,35 +53,37 @@ mod tests { #[test] fn roles_are_kept_as_reported() { let metadata = serde_json::json!({"conversation": { - "messages": [ - {"role": "reasoning", "text": "plan"}, + "entries": [ + {"role": "reasoning", "text": "plan", "item_id": "rs_1"}, {"role": "some_new_role", "text": "other"}, + {"role": "tool_call", "text": "{}", "call_id": "call_1", "name": "exec"}, {"role": "user", "text": "hello"} ], - "instructions_present": false, "completeness": "complete" }}); let conversation = Conversation::read(&metadata).expect("complete conversation"); assert_eq!( conversation - .messages + .entries .iter() - .map(|message| message.role.as_str()) + .map(|entry| entry.role.as_str()) .collect::>(), - ["reasoning", "some_new_role", "user"] + ["reasoning", "some_new_role", "tool_call", "user"] + ); + let call = &conversation.entries[2]; + assert_eq!( + (call.call_id.as_deref(), call.name.as_deref()), + (Some("call_1"), Some("exec")) ); } #[test] fn conversations_outside_the_contract_are_not_read() { - let messages = serde_json::json!([{"role": "user", "text": "hello"}]); + let entries = serde_json::json!([{"role": "user", "text": "hello"}]); for conversation in [ - serde_json::json!({"messages": messages, "instructions_present": false}), - serde_json::json!({ - "messages": messages, - "instructions_present": false, - "completeness": "partial" - }), + serde_json::json!({"entries": entries}), + serde_json::json!({"entries": entries, "completeness": "partial"}), + serde_json::json!({"messages": entries, "completeness": "complete"}), ] { let metadata = serde_json::json!({"conversation": conversation}); assert!(Conversation::read(&metadata).is_none()); diff --git a/crates/abyss-backend/src/usage/session_title.rs b/crates/abyss-backend/src/usage/session_title.rs index 733062f..30062e1 100644 --- a/crates/abyss-backend/src/usage/session_title.rs +++ b/crates/abyss-backend/src/usage/session_title.rs @@ -31,7 +31,7 @@ pub enum TitleContribution<'a> { } impl<'a> TitleContribution<'a> { - /// Classifies an event from collector fields without reading its messages. + /// Classifies an event from collector fields without reading its entries. /// Requests the Agent made for its own purposes, such as naming calls, are /// not user input. pub fn of(event_type: &str, metadata: &'a Value, naming_thread: bool) -> Option { @@ -51,9 +51,9 @@ impl<'a> TitleContribution<'a> { /// Default name: the first non-empty user input, on one line, at most 80 chars. pub fn default_title(metadata: &Value) -> Option { let input = Conversation::read(metadata)? - .messages + .entries .into_iter() - .find(|message| message.role == "user" && !message.text.trim().is_empty())? + .find(|entry| entry.role == "user" && !entry.text.trim().is_empty())? .text; let input = input.split_whitespace().collect::>().join(" "); let mut chars = input.chars(); @@ -80,8 +80,8 @@ mod tests { json!({"conversation": conversation}) } - fn default_of(messages: &Value) -> Option { - default_title(&request(&json!({"messages": messages}))) + fn default_of(entries: &Value) -> Option { + default_title(&request(&json!({"entries": entries}))) } #[test] @@ -90,7 +90,7 @@ mod tests { default_of(&json!([ {"role":"system","text":"Instructions"}, {"role":"assistant","text":"Previous reply"}, - {"role":"tool","text":"Tool output"}, + {"role":"tool_result","text":"Tool output","call_id":"call_1"}, {"role":"user","text":" "}, {"role":"user","text":" 修复\n 登录问题 "}, {"role":"user","text":"Second request"} @@ -116,13 +116,13 @@ mod tests { #[test] fn default_title_requires_readable_user_input() { - for messages in [ + for entries in [ json!([]), json!([{"role":"system","text":"Instructions"}]), json!([{"role":"user","text":" \n "}]), - json!([{"role":"tool","text":"Output"}]), + json!([{"role":"tool_result","text":"Output","call_id":"call_1"}]), ] { - assert_eq!(default_of(&messages), None); + assert_eq!(default_of(&entries), None); } assert_eq!(default_title(&json!({"text":"No role"})), None); } @@ -141,7 +141,7 @@ mod tests { assert_eq!( TitleContribution::of( "response", - &request(&json!({"messages":[ + &request(&json!({"entries":[ {"role":"assistant","text":"{\"title\":\"Quoted\"}"} ]})), false @@ -152,7 +152,7 @@ mod tests { #[test] fn naming_requests_are_not_user_input() { - let mut metadata = request(&json!({"messages":[{"role":"user","text":"…"}]})); + let mut metadata = request(&json!({"entries":[{"role":"user","text":"…"}]})); metadata["purpose"] = json!("session_title"); assert_eq!(TitleContribution::of("request", &metadata, false), None); } @@ -160,7 +160,7 @@ mod tests { #[test] fn naming_threads_are_marked_from_the_collector_flag() { let metadata = - request(&json!({"messages":[{"role":"user","text":"Generate a concise title"}]})); + request(&json!({"entries":[{"role":"user","text":"Generate a concise title"}]})); assert_eq!( TitleContribution::of("request", &metadata, true), Some(TitleContribution::TitleThread) @@ -174,17 +174,16 @@ mod tests { #[test] fn incomplete_conversations_give_no_default() { for status in ["partial", "unsupported"] { - let metadata = request( - &json!({"completeness":status,"messages":[{"role":"user","text":"hello"}]}), - ); + let metadata = + request(&json!({"completeness":status,"entries":[{"role":"user","text":"hello"}]})); assert_eq!(default_title(&metadata), None); } } #[test] - fn content_outside_collector_messages_is_never_interpreted() { + fn content_outside_collector_entries_is_never_interpreted() { let metadata = json!({"raw_body":{"parts":[{"content_base64":"e30="}]}, - "conversation":{"messages":[], "completeness":"complete", "provider":{"input":"secret"}}}); + "conversation":{"entries":[], "completeness":"complete", "provider":{"input":"secret"}}}); assert_eq!(default_title(&metadata), None); } } diff --git a/crates/abyss-backend/src/usage/trajectory.rs b/crates/abyss-backend/src/usage/trajectory.rs index 9329c9a..9999436 100644 --- a/crates/abyss-backend/src/usage/trajectory.rs +++ b/crates/abyss-backend/src/usage/trajectory.rs @@ -1,18 +1,15 @@ //! Merged session trajectory built from accumulated request/response snapshots. //! -//! Collectors record each provider request verbatim, so every request repeats -//! the conversation so far. The trajectory keeps only what each event adds: -//! role-aware messages, first-seen tool results, and response text and tool -//! calls, with each tool call paired to its result by call identifier. +//! Every event shows the conversation entries it carries, in order: messages +//! with the role the collector reported, and tool calls and results with +//! their call identifier. A request carries its whole history and shows all +//! of it. mod conversation; -use conversation::{ConversationProjection, Placement}; +use conversation::ConversationProjection; -use std::{ - collections::{HashMap, HashSet, hash_map::DefaultHasher}, - hash::{Hash, Hasher}, -}; +use std::collections::HashMap; use chrono::{DateTime, Utc}; use serde::Serialize; @@ -94,14 +91,14 @@ impl From<&UsageEventResponse> for TrajectoryEvent { #[serde(rename_all = "snake_case")] /// Kind of one trajectory item. pub enum TrajectoryItemKind { - /// A conversation message not seen earlier in its stream, with the role - /// the collector reported. + /// A conversation entry the event carries, with the role the collector + /// reported. Message, /// Event text of an event whose conversation could not be read. Text, - /// Response tool call, with its result once one arrives. + /// A tool call entry: its name, call identifier, and input. ToolCall, - /// Request tool result whose call was not observed. + /// A tool result entry: its call identifier and output. ToolResult, /// Context compaction: the new user input of the compaction request as /// `input` and the summary its response returned as `output`. @@ -144,7 +141,7 @@ pub struct TrajectoryItem { /// Tool result output. #[serde(skip_serializing_if = "Option::is_none")] pub output: Option, - /// Event that first carried the tool result. + /// Event that carried the tool result or compaction summary. #[serde(skip_serializing_if = "Option::is_none")] pub output_event_pk: Option, } @@ -195,9 +192,6 @@ pub fn build(timeline: SessionTimelineResponse) -> SessionTrajectoryResponse { struct Merger { conversation: ConversationProjection, items: Vec, - seen_results: HashSet, - /// Tool call item index by call identifier. - calls: HashMap, /// Compaction item index by the response id of its exchange, until the /// response carrying the summary arrives. compactions: HashMap, @@ -206,56 +200,25 @@ struct Merger { impl Merger { fn request(&mut self, event: &UsageEventResponse) { let first_new = self.items.len(); - let placement = self.conversation.place(event); - if let Some(items) = self.conversation.project(event, placement) { + let stream = self.conversation.place(event); + if let Some(items) = ConversationProjection::project(event, stream) { self.items.extend(items); } else { - self.unclassified_request(event, placement); + self.unclassified_request(event, stream); } if is_compaction(event) { - self.compaction(event, placement.stream, first_new); + self.compaction(event, stream, first_new); } - self.request_tools(event, placement.stream); } - fn unclassified_request(&mut self, event: &UsageEventResponse, placement: Placement) { + fn unclassified_request(&mut self, event: &UsageEventResponse, stream: usize) { if let Some(text) = event.text.as_ref().filter(|text| !text.is_empty()) { - let mut item = TrajectoryItem::new(TrajectoryItemKind::Text, placement.stream, event); + let mut item = TrajectoryItem::new(TrajectoryItemKind::Text, stream, event); item.text = Some(text.clone()); self.items.push(item); } } - fn request_tools(&mut self, event: &UsageEventResponse, stream: usize) { - for segment in content_segments(&event.metadata) { - if segment_str(segment, "type") != Some("tool_result") { - continue; - } - let call_id = segment_str(segment, "call_id").map(str::to_owned); - let output = segment_str(segment, "output").map(str::to_owned); - let result_key = call_id.clone().unwrap_or_else(|| { - format!( - "output:{:x}", - hash_str(output.as_deref().unwrap_or_default()) - ) - }); - if !self.seen_results.insert(result_key) { - continue; - } - if let Some(&index) = call_id.as_ref().and_then(|id| self.calls.get(id)) { - let call = &mut self.items[index]; - call.output = output; - call.output_event_pk = Some(event.id); - } else { - let mut item = TrajectoryItem::new(TrajectoryItemKind::ToolResult, stream, event); - item.call_id = call_id; - item.output = output; - item.output_event_pk = Some(event.id); - self.items.push(item); - } - } - } - /// Merges the new user input of a compaction request into one compaction /// item, which the response of the same exchange completes with its /// summary. @@ -280,9 +243,8 @@ impl Merger { } fn response(&mut self, event: &UsageEventResponse) { - let placement = self.conversation.place(event); - let stream = placement.stream; - let projected = self.conversation.project(event, placement); + let stream = self.conversation.place(event); + let projected = ConversationProjection::project(event, stream); let compaction = event .response_id .as_ref() @@ -303,19 +265,6 @@ impl Merger { item.text = Some(text.to_owned()); self.items.push(item); } - for segment in content_segments(&event.metadata) { - if segment_str(segment, "type") != Some("tool_call") { - continue; - } - let mut item = TrajectoryItem::new(TrajectoryItemKind::ToolCall, stream, event); - item.name = segment_str(segment, "name").map(str::to_owned); - item.call_id = segment_str(segment, "call_id").map(str::to_owned); - item.input = segment_str(segment, "input").map(str::to_owned); - if let Some(call_id) = &item.call_id { - self.calls.insert(call_id.clone(), self.items.len()); - } - self.items.push(item); - } } } @@ -323,25 +272,12 @@ fn is_compaction(event: &UsageEventResponse) -> bool { event.metadata.get("purpose").and_then(Value::as_str) == Some(COMPACTION_PURPOSE) } -fn hash_str(value: &str) -> u64 { - let mut hasher = DefaultHasher::new(); - value.hash(&mut hasher); - hasher.finish() -} - -fn content_segments(metadata: &Value) -> &[Value] { - metadata - .get("content_segments") - .and_then(Value::as_array) - .map_or(&[], Vec::as_slice) -} - -fn segment_str<'a>(segment: &'a Value, key: &str) -> Option<&'a str> { - segment.get(key).and_then(Value::as_str) -} - #[cfg(test)] mod tests { + use serde_json::json; + + use super::*; + use crate::usage::TurnTimeline; /// The role of a message item, else the item kind, as the API reports it. fn shown(item: &TrajectoryItem) -> &str { @@ -354,838 +290,335 @@ mod tests { }) } + /// Each item's role or kind, stream, and text, tool input, or tool output. + fn texts(items: &[TrajectoryItem]) -> Vec<(&str, usize, &str)> { + items + .iter() + .map(|i| { + let text = i.text.as_ref().or(i.input.as_ref()).or(i.output.as_ref()); + (shown(i), i.stream, text.map_or("", String::as_str)) + }) + .collect() + } + + fn tool_call(call_id: &str, input: &str) -> Value { + json!({"role": "tool_call", "text": input, "call_id": call_id, "name": "Bash"}) + } + + fn tool_result(call_id: &str, output: &str) -> Value { + json!({"role": "tool_result", "text": output, "call_id": call_id}) + } + + fn entry(role: &str, text: &str) -> Value { + json!({"role": role, "text": text}) + } + + /// An event whose complete conversation holds `entries`. + fn conversation(side: &str, entries: Value) -> UsageEventResponse { + let mut conversation = json!({"completeness": "complete"}); + conversation["entries"] = entries; + event( + side, + "flattened text", + json!({"conversation": conversation}), + ) + } + + /// An event with the stream and response identities its exchange declares. + fn declared( + mut event: UsageEventResponse, + stream: Option<&str>, + previous: Option<&str>, + response: Option<&str>, + ) -> UsageEventResponse { + event.stream_id = stream.map(str::to_owned); + event.previous_response_id = previous.map(str::to_owned); + event.response_id = response.map(str::to_owned); + event + } + + fn user_request(texts: &[&str]) -> UsageEventResponse { + conversation( + "request", + texts.iter().map(|text| entry("user", text)).collect(), + ) + } + #[test] - fn usage_retains_events_without_new_content_and_counts_each_event_once() { - let body = r#"{"messages":[{"id":null,"role":"user","text":"hello"}],"instructions_present":false}"#; - let mut request = structured_event("request", body); + fn every_event_is_listed_once_with_its_usage() { + let mut request = user_request(&["hello"]); request.input_tokens = 2; request.cache_read_tokens = 77_000; request.cache_write_tokens = 400; request.total_tokens = 77_402; - let repeated = structured_event("request", body); + let unreadable = event("request", "", json!({})); let mut response = event("response", "", json!({})); response.llm_model = "other-model".to_owned(); response.output_tokens = 150; response.reasoning_tokens = 40; response.total_tokens = 150; - let expected_ids = vec![request.id, repeated.id, response.id]; + let mut cached = event("request", "", json!({})); + cached.cache_read_tokens = 10; + let ids = [request.id, unreadable.id, response.id, cached.id]; - let trajectory = build(timeline(vec![request, repeated, response])); + let trajectory = build(timeline(vec![request, unreadable, response, cached])); assert_eq!(trajectory.items.len(), 1); + let events = &trajectory.events; + assert_eq!(events.iter().map(|e| e.event_pk).collect::>(), ids); assert_eq!( - trajectory - .events + events .iter() - .map(|event| event.event_pk) + .map(|e| e.token_usage_available) .collect::>(), - expected_ids + [true, false, true, true] ); - assert_eq!(trajectory.events[0].total_tokens, 77_402); - assert_eq!(trajectory.events[0].cache_read_tokens, 77_000); - assert_eq!(trajectory.events[0].cache_write_tokens, 400); - assert!(trajectory.events[0].token_usage_available); - assert!(!trajectory.events[1].token_usage_available); - assert_eq!(trajectory.events[2].llm_model, "other-model"); - assert_eq!(trajectory.events[2].output_tokens, 150); - assert_eq!(trajectory.events[2].reasoning_tokens, 40); - assert!(trajectory.events[2].token_usage_available); - let serialized = serde_json::to_value(&trajectory.events).unwrap(); - assert!(serialized[0].get("text").is_none()); - assert!(serialized[0].get("metadata").is_none()); - } - - #[test] - fn usage_is_available_when_any_counter_is_non_zero() { - let zero = event("response", "", json!({})); - let mut cached = event("request", "", json!({})); - cached.cache_read_tokens = 10; - let trajectory = build(timeline(vec![zero, cached])); - assert_eq!(trajectory.events.len(), 2); - assert!(!trajectory.events[0].token_usage_available); - assert!(trajectory.events[1].token_usage_available); - let serialized = serde_json::to_value(&trajectory.events).unwrap(); - assert!(serialized[0].get("token_usage_estimated").is_none()); - } - - use serde_json::json; - - use super::*; - use crate::usage::TurnTimeline; - - #[test] - fn unclassified_requests_keep_every_observation_and_pair_tool_results() { - let system = "system: header cch=1\nYou are an agent."; - let events = vec![ - event("request", &format!("{system}\n\nfirst prompt"), json!({})), - event( - "response", - "", - json!({"content_segments": [ - {"type": "tool_call", "call_id": "c1", "name": "Bash", "input": "ls"} - ]}), - ), - event( - "request", - "system: header cch=2\nYou are an agent.\n\nfirst prompt", - json!({"content_segments": [ - {"type": "tool_result", "call_id": "c1", "output": "README.md"} - ]}), - ), - event("response", "done", json!({})), - event( - "request", - "system: header cch=3\nYou are an agent.\n\nfirst prompt\n\nsecond prompt", - json!({"content_segments": [ - {"type": "tool_result", "call_id": "c1", "output": "README.md"} - ]}), - ), - ]; - - let items = build(timeline(events)).items; - - let kinds = items.iter().map(shown).collect::>(); - assert_eq!(kinds, ["text", "tool_call", "text", "text", "text",]); - assert_eq!(items[1].output.as_deref(), Some("README.md")); assert_eq!( - items[4].text.as_deref(), - Some("system: header cch=3\nYou are an agent.\n\nfirst prompt\n\nsecond prompt") - ); - assert!(items.iter().all(|item| item.stream == 0)); - } - - #[test] - fn unclassified_requests_are_not_split_trimmed_or_grouped_by_text() { - let transcript = "x".repeat(300); - let events = vec![ - event("request", "system: main\nMain agent.\n\nhello", json!({})), - event( - "request", - &format!("system: classifier\nJudge.\n\n{transcript}"), - json!({}), + ( + events[0].total_tokens, + events[0].cache_read_tokens, + events[0].cache_write_tokens ), - event( - "request", - &format!("system: classifier\nJudge.\n\n{transcript} more"), - json!({}), - ), - ]; - - let items = build(timeline(events)).items; - - assert_eq!(items.len(), 3); - assert!(items.iter().all(|i| shown(i) == "text" && !i.continued)); + (77_402, 77_000, 400) + ); + assert_eq!(events[2].llm_model, "other-model"); assert_eq!( - items[2].text, - Some(format!( - "system: classifier\nJudge.\n\n{transcript} more" - )) + (events[2].output_tokens, events[2].reasoning_tokens), + (150, 40) ); - } - - fn user_request(texts: &[&str]) -> UsageEventResponse { - structured_event("request", &json!({"messages": texts.iter().map(|text| json!({"role":"user", "text":text})).collect::>()}).to_string()) + let serialized = serde_json::to_value(events).unwrap(); + for field in ["text", "metadata", "token_usage_estimated"] { + assert!(serialized[0].get(field).is_none(), "{field}"); + } } #[test] - fn unavailable_roles_never_become_system_or_user_by_text_guessing() { + fn events_without_a_readable_conversation_show_their_text_verbatim() { let text = "system: instructions\n\n用户内容\n\n用户内容"; for metadata in [ json!({}), - json!({"conversation":{"completeness":"partial","messages":[]}}), - json!({"conversation":{"messages":"invalid"}}), - json!({"conversation":{"messages":[],"completeness":"complete"}}), + json!({"conversation":{"completeness":"partial","entries":[]}}), + json!({"conversation":{"entries":"invalid"}}), + json!({"conversation":{"entries":[],"completeness":"complete"}}), // Without completeness the conversation is outside the contract. - json!({"conversation":{"messages":[{"role":"user","text":"用户内容"}]}}), + json!({"conversation":{"entries":[{"role":"user","text":"用户内容"}]}}), + // The retired field is not read. + json!({"conversation":{"messages":[{"role":"user","text":"用户内容"}],"completeness":"complete"}}), ] { let items = build(timeline(vec![event("request", text, metadata)])).items; - assert_eq!(items.len(), 1); - assert_eq!(shown(&items[0]), "text"); - assert_eq!(items[0].text.as_deref(), Some(text)); + assert_eq!(texts(&items), [("text", 0, text)]); assert!(!items[0].continued); } - let items = build(timeline(vec![structured_event("request", r#"{"messages":[{"id":null,"role":"unknown","text":"system: unknown"},{"id":null,"role":"unrecognized","text":"hello"},{"id":null,"role":"user","text":"system: quoted text"}],"instructions_present":false}"#)])).items; - assert_eq!( - items.iter().map(shown).collect::>(), - ["unknown", "unrecognized", "user"] - ); - } - - /// Every conversation in the contract declares completeness; default it to complete. - fn structured_event(side: &str, body: &str) -> UsageEventResponse { - let mut conversation = serde_json::from_str::(body).unwrap(); - conversation - .as_object_mut() - .unwrap() - .entry("completeness") - .or_insert_with(|| json!("complete")); - event( - side, - "flattened text", - json!({"conversation": conversation}), - ) - } - - #[test] - fn structured_projection_keeps_roles_and_repeated_user_occurrences() { - let first = r#"{"messages":[{"role":"developer","text":"system instructions"},{"id":null,"role":"user","text":"继续"}],"instructions_present":true}"#; - let response = r#"{"messages":[{"id":"a1","role":"assistant","text":"working"}],"instructions_present":false}"#; - let replay = r#"{"messages":[{"role":"developer","text":"system instructions"},{"id":null,"role":"user","text":"继续"},{"id":"a1","role":"assistant","text":"working"},{"id":null,"role":"user","text":"继续"}],"instructions_present":true}"#; - let events = vec![ - structured_event("request", first), - structured_event("response", response), - structured_event("request", replay), - structured_event("request", replay), - ]; - let metadata_before = events - .iter() - .map(|e| e.metadata.clone()) - .collect::>(); - let mut merger = Merger::default(); - for e in &events { - if e.event_type == "request" { - merger.request(e); - } else { - merger.response(e); - } - } - assert_eq!( - merger.items.iter().map(shown).collect::>(), - ["developer", "user", "assistant", "user"] - ); - assert_eq!(merger.items[1].text.as_deref(), Some("继续")); - assert_eq!(merger.items[3].text.as_deref(), Some("继续")); - assert_eq!( - metadata_before, - events - .iter() - .map(|e| e.metadata.clone()) - .collect::>() + // A failed response with neither a conversation nor text adds nothing. + let failed = event( + "response", + "", + json!({"conversation":{"entries":[],"completeness":"unsupported"}}), ); + assert!(build(timeline(vec![failed])).items.is_empty()); } #[test] - fn streamed_anthropic_response_is_not_replayed_as_user_input() { - let response = r#"{"messages":[{"role":"assistant","text":"hi"}]}"#; - let items = build(timeline(vec![ - structured_event("request", r#"{"messages":[{"role":"system","text":"instructions"},{"id":null,"role":"user","text":"hello"}],"instructions_present":true}"#), - structured_event("response", response), - structured_event("request", r#"{"messages":[{"role":"system","text":"instructions"},{"id":null,"role":"user","text":"hello"},{"id":null,"role":"assistant","text":"hi"},{"id":null,"role":"user","text":"hello"}],"instructions_present":true}"#), - ])).items; - assert_eq!( - items.iter().map(shown).collect::>(), - ["system", "user", "assistant", "user"] + fn tool_items_keep_their_name_and_call_id() { + let request = conversation( + "request", + json!([tool_call("c1", "ls"), tool_result("c1", "README.md")]), ); - assert_eq!(items[2].text.as_deref(), Some("hi")); - } - - #[test] - fn anthropic_reasoning_is_shown_once_with_its_assistant_message() { - let first = r#"{"messages":[{"id":null,"role":"system","text":"instructions"},{"id":null,"role":"user","text":"hello"}],"instructions_present":true,"completeness":"complete"}"#; - let response = r#"{"messages":[{"id":"msg_1","role":"reasoning","text":"thinking it over"},{"id":"msg_1","role":"assistant","text":"hi"}],"instructions_present":false,"completeness":"complete"}"#; - let replay = r#"{"messages":[{"id":null,"role":"system","text":"instructions"},{"id":null,"role":"user","text":"hello"},{"id":null,"role":"reasoning","text":"thinking it over"},{"id":null,"role":"assistant","text":"hi"},{"id":null,"role":"user","text":"next"}],"instructions_present":true,"completeness":"complete"}"#; - let items = build(timeline(vec![ - structured_event("request", first), - structured_event("response", response), - structured_event("request", replay), - ])) - .items; + let id = request.id; + let items = build(timeline(vec![request])).items; assert_eq!( items .iter() - .map(|i| (shown(i), i.text.as_deref().unwrap_or_default())) + .map(|i| (i.kind, i.name.as_deref(), i.call_id.as_deref())) .collect::>(), [ - ("system", "instructions"), - ("user", "hello"), - ("reasoning", "thinking it over"), - ("assistant", "hi"), - ("user", "next"), - ] - ); - assert!(items.iter().all(|i| i.stream == 0)); - } - - #[test] - fn incremental_requests_keep_identical_new_user_messages() { - let mut first = structured_event( - "request", - r#"{"messages":[{"role":"developer","text":"instructions"},{"id":null,"role":"user","text":"continue"}]}"#, - ); - first.response_id = Some("r1".to_owned()); - let mut response = structured_event( - "response", - r#"{"messages":[{"id":null,"role":"assistant","text":"working"}]}"#, - ); - response.response_id = Some("r1".to_owned()); - let mut next = structured_event( - "request", - r#"{"messages":[{"id":null,"role":"user","text":"continue"}]}"#, - ); - next.previous_response_id = Some("r1".to_owned()); - let items = build(timeline(vec![first, response, next])).items; - assert_eq!( - items.iter().map(shown).collect::>(), - ["developer", "user", "assistant", "user"] - ); - assert!(items.iter().all(|i| i.stream == 0)); - } - - #[test] - fn a_continuation_shows_new_developer_messages_but_not_its_instructions() { - let mut first = structured_event( - "request", - r#"{"messages":[{"role":"developer","text":"instructions"},{"role":"user","text":"start"}],"instructions_present":true}"#, - ); - first.response_id = Some("r1".to_owned()); - let mut response = structured_event( - "response", - r#"{"messages":[{"role":"assistant","text":"working"}]}"#, - ); - response.response_id = Some("r1".to_owned()); - // The continuation repeats its instructions, then adds a developer - // notice and the next user input. - let mut next = structured_event( - "request", - r#"{"messages":[{"role":"developer","text":"instructions"},{"role":"developer","text":"permissions changed"},{"role":"user","text":"go on"}],"instructions_present":true}"#, - ); - next.previous_response_id = Some("r1".to_owned()); - let items = build(timeline(vec![first, response, next])).items; - assert_eq!( - texts(&items), - [ - ("developer", 0, "instructions"), - ("user", 0, "start"), - ("assistant", 0, "working"), - ("developer", 0, "permissions changed"), - ("user", 0, "go on"), + (TrajectoryItemKind::ToolCall, Some("Bash"), Some("c1")), + (TrajectoryItemKind::ToolResult, None, Some("c1")), ] ); + assert_eq!(items[1].output_event_pk, Some(id)); } #[test] - fn structured_request_retains_system_and_assistant_roles() { - let body = r#"{"messages":[{"role":"system","text":"instructions"},{"id":null,"role":"user","text":"hello"},{"id":null,"role":"assistant","text":"hi"},{"id":null,"role":"user","text":"hello"}],"instructions_present":true}"#; - let items = build(timeline(vec![ - structured_event("request", body), - structured_event("request", body), - ])) - .items; - assert_eq!( - items.iter().map(shown).collect::>(), - ["system", "user", "assistant", "user"] - ); - } - - /// Claude Code requests as the collector projects them: a `create` - /// carries the whole system prompt and history, a `continue` only the - /// billing header and the messages added since the response it names. - fn claude_request( - stream: Option<&str>, - previous: Option<&str>, - response: Option<&str>, - system: &[&str], - messages: Value, - ) -> UsageEventResponse { - let system = std::iter::once( - "x-anthropic-billing-header: cc_version=2.1.293.af3; cc_entrypoint=cli; cch=3ceb5;", - ) - .chain(system.iter().copied()) - .collect::>() - .join("\n"); - let mut conversation = json!({ - "messages": [{"id": null, "role": "system", "text": system}], - "instructions_present": true, - }); - conversation["messages"] - .as_array_mut() - .unwrap() - .extend(serde_json::from_value::>(messages).unwrap()); - let mut request = structured_event("request", &conversation.to_string()); - request.stream_id = stream.map(str::to_owned); - request.previous_response_id = previous.map(str::to_owned); - request.response_id = response.map(str::to_owned); - request - } - - /// A main-loop request with its whole history; `response` names the - /// response its exchange produced. - fn create(response: &str, messages: Value) -> UsageEventResponse { - claude_request( - Some("main"), - None, - Some(response), - &["You are Claude Code.", "Main loop instructions."], - messages, - ) - } - - /// A main-loop request continuing after `previous`. - fn continue_from( - previous: &str, - response: Option<&str>, - messages: Value, - ) -> UsageEventResponse { - claude_request(Some("main"), Some(previous), response, &[], messages) - } - - fn user(text: &str) -> Value { - json!({"id": null, "role": "user", "text": text}) - } - - fn budget() -> Value { - json!({"id": null, "role": "system", "text": "15000000 tokens left"}) - } - - fn tool_result() -> Value { - json!({"id": null, "role": "tool", "text": ""}) - } - - /// The replayed form of a response: thinking is redacted. - fn replayed(text: &str) -> [Value; 3] { - [ - json!({"id": null, "role": "reasoning", "text": ""}), - json!({"id": null, "role": "assistant", "text": text}), - json!({"id": null, "role": "tool", "text": ""}), - ] - } - - /// A response found only by the response id its exchange declares. - fn streamed(id: &str, text: &str) -> UsageEventResponse { - let conversation = json!({ - "messages": [ - {"id": id, "role": "reasoning", "text": ""}, - {"id": id, "role": "assistant", "text": text}, - {"id": null, "role": "tool", "text": ""}, - ], - }); - let mut response = structured_event("response", &conversation.to_string()); - response.stream_id = None; - response.response_id = Some(id.to_owned()); - response - } - - /// A `thread_not_found` error carries no messages and no response id. - fn thread_not_found() -> UsageEventResponse { - let mut response = structured_event( - "response", - r#"{"messages":[],"completeness":"unsupported"}"#, - ); - response.stream_id = None; - response.text = None; - response - } - - fn texts(items: &[TrajectoryItem]) -> Vec<(&str, usize, &str)> { - items - .iter() - .map(|i| (shown(i), i.stream, i.text.as_deref().unwrap_or_default())) - .collect() - } - - const MAIN_CONTEXT: &str = "x-anthropic-billing-header: cc_version=2.1.293.af3; \ - cc_entrypoint=cli; cch=3ceb5;\nYou are Claude Code.\nMain loop \ - instructions."; - - #[test] - fn a_continuation_appends_new_messages_in_the_stream_of_its_response() { - let search = claude_request( - None, - None, - Some("msg_side"), - &[ - "You are Claude Code.", - "You are an assistant for performing a web search tool use", - ], - json!([user("search the web")]), - ); - let events = vec![ - create("msg_1", json!([user("hello"), budget()])), - streamed("msg_1", "looking"), - search, - streamed("msg_side", "results"), - continue_from("msg_1", Some("msg_2"), json!([tool_result(), budget()])), - streamed("msg_2", "found it"), - continue_from( - "msg_2", - Some("msg_3"), - json!([tool_result(), user("thanks"), budget()]), + fn every_event_shows_every_entry_it_carries() { + let request = |entries: Value| conversation("request", entries); + let response = |text: &str| conversation("response", json!([entry("assistant", text)])); + let cases = [ + ( + "roles are kept as reported; messages without text are left out", + vec![request(json!([ + entry("unknown", "a"), + entry("unrecognized", "b"), + entry("user", "system: quoted"), + entry("reasoning", ""), + tool_call("c1", ""), + tool_result("c1", "ok"), + ]))], + vec![ + ("unknown", 0, "a"), + ("unrecognized", 0, "b"), + ("user", 0, "system: quoted"), + ("tool_call", 0, ""), + ("tool_result", 0, "ok"), + ], + ), + ( + "tool calls and results show wherever an event carries them", + vec![ + conversation("response", json!([tool_call("c1", "ls")])), + request(json!([ + entry("user", "go"), + tool_call("c1", "ls"), + tool_result("c1", "README.md"), + ])), + ], + vec![ + ("tool_call", 0, "ls"), + ("user", 0, "go"), + ("tool_call", 0, "ls"), + ("tool_result", 0, "README.md"), + ], + ), + ( + "a full request shows its whole history again", + vec![ + request(json!([entry("system", "rules"), entry("user", "hi")])), + response("ok"), + request(json!([ + entry("system", "rules"), + entry("user", "hi"), + entry("assistant", "ok"), + entry("user", "hi"), + ])), + ], + vec![ + ("system", 0, "rules"), + ("user", 0, "hi"), + ("assistant", 0, "ok"), + ("system", 0, "rules"), + ("user", 0, "hi"), + ("assistant", 0, "ok"), + ("user", 0, "hi"), + ], ), ]; - let items = build(timeline(events)).items; - - assert_eq!( - texts(&items), - [ - ("system", 0, MAIN_CONTEXT), - ("user", 0, "hello"), - ( - "system", - 0, - "15000000 tokens left" - ), - ("assistant", 0, "looking"), - ( - "system", - 1, - "x-anthropic-billing-header: cc_version=2.1.293.af3; cc_entrypoint=cli; \ - cch=3ceb5;\nYou are Claude Code.\nYou are an assistant for performing a web \ - search tool use" - ), - ("user", 1, "search the web"), - ("assistant", 1, "results"), - ( - "system", - 0, - "15000000 tokens left" - ), - ("assistant", 0, "found it"), - ("user", 0, "thanks"), - ( - "system", - 0, - "15000000 tokens left" - ), - ] - ); - } - - #[test] - fn a_replay_after_a_failed_continuation_adds_no_duplicates() { - let first = user("hello"); - let retry = user("try the wire"); - let [looking_thinking, looking, looking_tool] = replayed("looking"); - let [found_thinking, found, found_tool] = replayed("found it"); - let replay = json!([ - first, - budget(), - looking_thinking, - looking, - looking_tool, - tool_result(), - budget(), - found_thinking, - found, - found_tool, - retry, - budget(), - ]); - let events = vec![ - create("msg_1", json!([first, budget()])), - streamed("msg_1", "looking"), - continue_from("msg_1", Some("msg_2"), json!([tool_result(), budget()])), - streamed("msg_2", "found it"), - continue_from("msg_2", None, json!([retry, budget()])), - thread_not_found(), - create("msg_3", replay), - streamed("msg_3", "no wire port"), - continue_from("msg_3", Some("msg_4"), json!([tool_result(), budget()])), - streamed("msg_4", "compressed"), - ]; - let trajectory = build(timeline(events)); - - assert_eq!( - texts(&trajectory.items), - [ - ("system", 0, MAIN_CONTEXT), - ("user", 0, "hello"), - ( - "system", - 0, - "15000000 tokens left" - ), - ("assistant", 0, "looking"), - ( - "system", - 0, - "15000000 tokens left" - ), - ("assistant", 0, "found it"), - ("user", 0, "try the wire"), - ( - "system", - 0, - "15000000 tokens left" - ), - ("assistant", 0, "no wire port"), - ( - "system", - 0, - "15000000 tokens left" - ), - ("assistant", 0, "compressed"), - ] - ); - assert_eq!(trajectory.events.len(), 10); - } - - #[test] - fn a_continuation_of_an_unseen_response_uses_its_stream_id_or_opens_a_stream() { - let title = || { - claude_request( - None, - None, - Some("msg_title"), - &["You are a title generator."], - json!([user("name it")]), - ) - }; - let events = vec![ - create("msg_1", json!([user("hello"), budget()])), - title(), - continue_from("msg_uncaptured", None, json!([user("next"), budget()])), - ]; - let items = build(timeline(events)).items; - assert_eq!( - texts(&items)[items.len() - 2..], - [ - ("user", 0, "next"), - ( - "system", - 0, - "15000000 tokens left" - ), - ] - ); - - // Without a stream id the continuation opens a stream of its own and - // shows only the messages it carries. - let mut orphan = continue_from("msg_uncaptured", None, json!([user("next")])); - orphan.stream_id = None; - let items = build(timeline(vec![title(), orphan])).items; - assert_eq!(texts(&items)[2..], [("user", 1, "next")]); + for (name, events, expected) in cases { + assert_eq!(texts(&build(timeline(events)).items), expected, "{name}"); + } } #[test] - fn declared_stream_ids_keep_each_thread_apart() { - let request = |stream: &str, messages: &str| { - let mut event = structured_event( - "request", - &format!( - r#"{{"messages":[{{"role":"developer","text":"{stream} rules"}},{messages}]}}"# - ), - ); - event.stream_id = Some(stream.to_owned()); - event - }; - let items = build(timeline(vec![ - request("main", r#"{"role":"user","text":"fix it"}"#), - request("guardian", r#"{"role":"user","text":"review the action"}"#), - request( - "main", - r#"{"role":"user","text":"fix it"},{"role":"user","text":"and test"}"#, + fn events_are_placed_by_the_identities_their_exchange_declares() { + fn ask( + texts: &[&str], + stream: Option<&str>, + previous: Option<&str>, + response: Option<&str>, + ) -> UsageEventResponse { + declared(user_request(texts), stream, previous, response) + } + fn answer(text: &str, response: &str) -> UsageEventResponse { + let event = conversation("response", json!([entry("assistant", text)])); + declared(event, None, None, Some(response)) + } + let cases = [ + ( + "declared stream ids keep each thread apart", + vec![ + ask(&["fix it"], Some("main"), None, None), + ask(&["review"], Some("guardian"), None, None), + ask(&["fix it", "and test"], Some("main"), None, None), + ], + vec![ + ("user", 0, "fix it"), + ("user", 1, "review"), + ("user", 0, "fix it"), + ("user", 0, "and test"), + ], ), - request( - "guardian", - r#"{"role":"user","text":"review the action"},{"role":"user","text":"next action"}"#, + ( + "requests without a stream id each open a stream", + vec![ + ask(&["recap"], None, None, None), + ask(&["recap"], None, None, None), + ], + vec![("user", 0, "recap"), ("user", 1, "recap")], ), - ])) - .items; - - assert_eq!( - texts(&items), - [ - ("developer", 0, "main rules"), - ("user", 0, "fix it"), - ("developer", 1, "guardian rules"), - ("user", 1, "review the action"), - ("user", 0, "and test"), - ("user", 1, "next action"), - ] - ); - } - - #[test] - fn requests_without_a_stream_id_each_open_a_stream_and_repeat_their_history() { - let recap = || { - let mut event = structured_event( - "request", - r#"{"messages":[{"role":"system","text":"rules"},{"role":"user","text":"hello"},{"role":"user","text":"recap"}]}"#, - ); - event.stream_id = None; - event - }; - let items = build(timeline(vec![recap(), recap()])).items; - - assert_eq!( - texts(&items), - [ - ("system", 0, "rules"), - ("user", 0, "hello"), - ("user", 0, "recap"), - ("system", 1, "rules"), - ("user", 1, "hello"), - ("user", 1, "recap"), - ] - ); - } - - #[test] - fn a_response_joins_the_stream_of_the_request_with_its_response_id() { - let mut main = create("msg_main", json!([user("hello")])); - main.stream_id = Some("main".to_owned()); - let side = claude_request( - None, - None, - Some("msg_side"), - &["You are a summarizer."], - json!([user("summarize")]), - ); - // The main exchange answers after the side request was observed. - let items = build(timeline(vec![ - main, - side, - streamed("msg_main", "hi"), - streamed("msg_side", "summary"), - streamed("msg_unpaired", "orphan"), - ])) - .items; - - assert_eq!( - texts(&items)[2..], - [ - ( - "system", - 1, - "x-anthropic-billing-header: cc_version=2.1.293.af3; cc_entrypoint=cli; \ - cch=3ceb5;\nYou are a summarizer." - ), - ("user", 1, "summarize"), - ("assistant", 0, "hi"), - ("assistant", 1, "summary"), - ("assistant", 2, "orphan"), - ] - ); - } - - #[test] - fn opening_instructions_show_only_where_they_change() { - let request = |rules: &str, messages: &str| { - structured_event( - "request", - &format!( - r#"{{"messages":[{{"role":"system","text":"base"}},{{"role":"developer","text":"{rules}"}},{messages}],"instructions_present":true}}"# - ), - ) - }; - let items = build(timeline(vec![ - request("rules v1", r#"{"role":"user","text":"hello"}"#), - request( - "rules v1", - r#"{"role":"user","text":"hello"},{"role":"user","text":"again"}"#, + ( + "responses and continuations join the stream of the response id", + vec![ + ask(&["hello"], Some("main"), None, Some("r_main")), + ask(&["summarize"], None, None, Some("r_side")), + answer("hi", "r_main"), + answer("summary", "r_side"), + answer("orphan", "r_unpaired"), + ask(&["thanks"], None, Some("r_main"), None), + ], + vec![ + ("user", 0, "hello"), + ("user", 1, "summarize"), + ("assistant", 0, "hi"), + ("assistant", 1, "summary"), + ("assistant", 2, "orphan"), + ("user", 0, "thanks"), + ], ), - request( - "rules v2", - r#"{"role":"user","text":"hello"},{"role":"user","text":"again"},{"role":"user","text":"more"}"#, + ( + "a continuation of an unseen response uses its stream id, else opens a stream", + vec![ + ask(&["hello"], Some("main"), None, None), + ask(&["next"], Some("main"), Some("r_unseen"), None), + ask(&["lost"], None, Some("r_unseen"), None), + ], + vec![ + ("user", 0, "hello"), + ("user", 0, "next"), + ("user", 1, "lost"), + ], ), - ])) - .items; - - assert_eq!( - texts(&items), - [ - ("system", 0, "base"), - ("developer", 0, "rules v1"), - ("user", 0, "hello"), - ("user", 0, "again"), - ("developer", 0, "rules v2"), - ("user", 0, "more"), - ] - ); - } - - #[test] - fn system_messages_inside_the_conversation_keep_one_stream() { - let first = r#"{"messages":[{"role":"system","text":"instructions"},{"id":null,"role":"user","text":"hello"},{"id":null,"role":"system","text":"reminder one"}],"instructions_present":true}"#; - let second = r#"{"messages":[{"role":"system","text":"instructions"},{"id":null,"role":"user","text":"hello"},{"id":null,"role":"system","text":"reminder one"},{"id":null,"role":"assistant","text":"hi"},{"id":null,"role":"system","text":"reminder two"},{"id":null,"role":"user","text":"next"}],"instructions_present":true}"#; - let items = build(timeline(vec![ - structured_event("request", first), - structured_event("request", second), - ])) - .items; - - assert_eq!( - items - .iter() - .map(|i| (shown(i), i.text.as_deref().unwrap_or_default())) - .collect::>(), - [ - ("system", "instructions"), - ("user", "hello"), - ("system", "reminder one"), - ("assistant", "hi"), - ("system", "reminder two"), - ("user", "next"), - ] - ); - assert!(items.iter().all(|i| i.stream == 0)); + ]; + for (name, events, expected) in cases { + assert_eq!(texts(&build(timeline(events)).items), expected, "{name}"); + } } const PROMPT: &str = "You are performing a CONTEXT CHECKPOINT COMPACTION. Create a \ handoff summary for another LLM that will resume the task."; const SUMMARY: &str = "## Goal\n\nFinish the report."; - /// One exchange of a call the collector declares as a compaction. - fn compaction(texts: &[&str], response_id: &str) -> [UsageEventResponse; 2] { - let mut request = user_request(texts); + #[test] + fn a_compaction_exchange_forms_one_item_from_its_user_input_and_summary() { + let mut request = user_request(&["write the report", PROMPT]); request.metadata["purpose"] = json!(COMPACTION_PURPOSE); - request.response_id = Some(response_id.to_owned()); + request.response_id = Some("resp_compact".to_owned()); let mut response = event("response", SUMMARY, json!({"purpose": COMPACTION_PURPOSE})); - response.response_id = Some(response_id.to_owned()); - [request, response] - } - - #[test] - fn a_compaction_exchange_forms_one_item_and_its_replay_is_ordinary_input() { - let [request, response] = compaction( - &[ - "write the report", - "date", - PROMPT, - ], - "resp_compact", - ); + response.response_id = Some("resp_compact".to_owned()); let replay = format!("Here is the summary of the earlier conversation:\n{SUMMARY}"); let events = vec![ user_request(&["write the report"]), event("response", "working", json!({})), request, response, - user_request(&["cwd", &replay]), - event("response", "continuing", json!({})), + user_request(&[&replay]), + // Prompt text alone does not make a compaction. + user_request(&[PROMPT]), + event("response", SUMMARY, json!({})), ]; let items = build(timeline(events)).items; assert_eq!( items.iter().map(shown).collect::>(), - ["user", "text", "compaction", "user", "user", "text",] + ["user", "text", "compaction", "user", "user", "text"] ); - // The new input of the compaction request is the item's input. assert_eq!( items[2].input.as_deref(), - Some(&*format!( - "date\n\n{PROMPT}" - )) + Some(&*format!("write the report\n\n{PROMPT}")) ); // The summary comes from the response of the same exchange. assert_eq!(items[2].output.as_deref(), Some(SUMMARY)); assert!(items[2].output_event_pk.is_some()); assert_ne!(items[2].output_event_pk, Some(items[2].event_pk)); - // The replayed summary is shown like any other new input. - assert_eq!(items[4].text.as_deref(), Some(&*replay)); - } - - #[test] - fn compaction_prompt_text_alone_is_ordinary_input() { - let items = build(timeline(vec![ - user_request(&[PROMPT]), - event("response", SUMMARY, json!({})), - ])) - .items; - assert_eq!( - items.iter().map(shown).collect::>(), - ["user", "text"] - ); + assert_eq!(items[3].text.as_deref(), Some(&*replay)); } fn timeline(events: Vec) -> SessionTimelineResponse { diff --git a/crates/abyss-backend/src/usage/trajectory/conversation.rs b/crates/abyss-backend/src/usage/trajectory/conversation.rs index a97815d..23a282f 100644 --- a/crates/abyss-backend/src/usage/trajectory/conversation.rs +++ b/crates/abyss-backend/src/usage/trajectory/conversation.rs @@ -1,4 +1,4 @@ -//! Session trajectory projection from collector-produced structured messages. +//! Session trajectory projection from collector-produced conversation entries. //! //! Each exchange is placed in a stream by the identities the collector //! declares, compared only for equality: a request continuing a known @@ -9,50 +9,22 @@ use std::collections::HashMap; use super::{TrajectoryItem, TrajectoryItemKind, UsageEventResponse}; -use crate::usage::conversation::{Conversation, Message}; - -impl Message { - fn matches(&self, other: &Self) -> bool { - self.role == other.role - && self.text == other.text - && match (&self.id, &other.id) { - (Some(left), Some(right)) => left == right, - _ => true, - } - } -} - -/// The stream an event belongs to. -#[derive(Clone, Copy)] -pub(super) struct Placement { - pub(super) stream: usize, - /// The request carries only the messages added since the response it - /// continues. - incremental: bool, -} +use crate::usage::conversation::Conversation; #[derive(Default)] pub(super) struct ConversationProjection { - streams: Vec, + /// Number of streams opened so far. + streams: usize, /// Stream of each declared stream id. keys: HashMap, /// Stream of each exchange by the response id it declares. responses: HashMap, } -#[derive(Default)] -struct StreamHistory { - /// Instructions the latest full request opened with. - opening: Vec, - /// Messages after the opening, as the stream has seen them so far. - history: Vec, -} - impl ConversationProjection { - /// Places an event by the identities its exchange declares. - pub(super) fn place(&mut self, event: &UsageEventResponse) -> Placement { - let request = event.event_type == "request"; - let continued = if request { + /// The stream of an event, by the identities its exchange declares. + pub(super) fn place(&mut self, event: &UsageEventResponse) -> usize { + let continued = if event.event_type == "request" { event .previous_response_id .as_ref() @@ -80,79 +52,49 @@ impl ConversationProjection { if let Some(id) = &event.response_id { self.responses.entry(id.clone()).or_insert(stream); } - Placement { - stream, - incremental: request && event.previous_response_id.is_some(), - } + stream } - fn open(&mut self) -> usize { - self.streams.push(StreamHistory::default()); - self.streams.len().saturating_sub(1) + const fn open(&mut self) -> usize { + self.streams = self.streams.saturating_add(1); + self.streams.saturating_sub(1) } + /// Every entry the event carries, in order: tool calls and results as + /// tool items, every other entry with text as a message. pub(super) fn project( - &mut self, event: &UsageEventResponse, - placement: Placement, + stream: usize, ) -> Option> { - let conversation = Conversation::read(&event.metadata)?; - let request = event.event_type == "request"; - // A request that declares its own instructions carries them as its - // first message. Messages without text, such as tool items whose - // content the tool fields carry, take no part in the trajectory. - let mut messages = conversation.messages; - let opening_len = usize::from(request && conversation.instructions_present); - let rest = messages - .split_off(opening_len.min(messages.len())) + let items = Conversation::read(&event.metadata)? + .entries .into_iter() - .filter(|message| !message.text.is_empty()) + .filter_map(|entry| { + let kind = match entry.role.as_str() { + "tool_call" => TrajectoryItemKind::ToolCall, + "tool_result" => TrajectoryItemKind::ToolResult, + _ if entry.text.is_empty() => return None, + _ => TrajectoryItemKind::Message, + }; + let mut item = TrajectoryItem::new(kind, stream, event); + item.call_id = entry.call_id; + match kind { + TrajectoryItemKind::ToolCall => { + item.name = entry.name; + item.input = Some(entry.text); + } + TrajectoryItemKind::ToolResult => { + item.output = Some(entry.text); + item.output_event_pk = Some(event.id); + } + _ => { + item.role = Some(entry.role); + item.text = Some(entry.text); + } + } + Some(item) + }) .collect::>(); - let (opening, messages) = (messages, rest); - if opening.is_empty() && messages.is_empty() { - return None; - } - let stream = placement.stream; - let state = self.streams.get_mut(stream)?; - let item = |message: &Message| { - let mut item = TrajectoryItem::new(TrajectoryItemKind::Message, stream, event); - item.role = Some(message.role.clone()); - item.text = Some(message.text.clone()); - item - }; - let mut added = Vec::new(); - // A response and a continuation carry only messages added since the - // response they continue, so each one is new; the history keeps them - // for a later full request that replays them. A continuation does not - // repeat the conversation's instructions. - if !request || placement.incremental { - for message in messages { - added.push(item(&message)); - state.history.push(message); - } - return Some(added); - } - - // A full request shows its instructions only where they changed, and - // of its history only what the stream has not seen in that order. - for message in &opening { - if !message.text.is_empty() && !state.opening.iter().any(|old| old.matches(message)) { - added.push(item(message)); - } - } - state.opening = opening; - - let mut cursor = 0; - for message in &messages { - let found = state.history[cursor..] - .iter() - .position(|old| old.matches(message)); - match found { - Some(offset) => cursor = cursor.saturating_add(offset).saturating_add(1), - None => added.push(item(message)), - } - } - state.history = messages; - Some(added) + (!items.is_empty()).then_some(items) } } diff --git a/scripts/tests/session_titles_api.py b/scripts/tests/session_titles_api.py index 0cc9bba..5438ff6 100644 --- a/scripts/tests/session_titles_api.py +++ b/scripts/tests/session_titles_api.py @@ -23,7 +23,7 @@ def make(session, kind, text, *, naming_thread=False, agent="codex", metadata=No value["naming_thread"] = True if kind == "request": value["metadata"]["conversation"] = { - "messages": [{"role": "user", "text": text}], "completeness": "complete", + "entries": [{"role": "user", "text": text}], "completeness": "complete", } return value From f3c2da6e5d1a763af87e9e8e25c2b412fe706ef4 Mon Sep 17 00:00:00 2001 From: Auceptin Date: Tue, 20 Oct 2026 02:25:17 -0700 Subject: [PATCH 02/25] feat(usage): store stream facts read from each event at ingest - New `llm_usage_event_stream_facts` table, one row per new event, written in the ingest transaction; the event table keeps its columns. - A request keeps where its new part starts, whether it opens a turn, the last identity of its last run and the roles of the model-side entries after it, and, without model output, the call ids of its tool results. A response keeps the identities it emitted and its output skeleton. - Facts come only from a completely decoded conversation. Co-Authored-By: Claude Opus 5.5 --- .../down.sql | 1 + .../20261019120000_event_stream_facts/up.sql | 19 + .../down.sql | 1 + .../20261019120000_event_stream_facts/up.sql | 18 + crates/abyss-backend/src/db/models.rs | 25 +- crates/abyss-backend/src/db/schema.rs | 17 + .../src/storage/postgres/repository.rs | 47 ++- .../src/storage/sqlite/models.rs | 55 ++- .../src/storage/sqlite/repository.rs | 109 ++++- .../src/storage/sqlite/schema.rs | 17 + .../abyss-backend/src/usage/conversation.rs | 1 - crates/abyss-backend/src/usage/mod.rs | 2 + crates/abyss-backend/src/usage/streams.rs | 374 ++++++++++++++++++ 13 files changed, 673 insertions(+), 13 deletions(-) create mode 100644 crates/abyss-backend/migrations-sqlite/20261019120000_event_stream_facts/down.sql create mode 100644 crates/abyss-backend/migrations-sqlite/20261019120000_event_stream_facts/up.sql create mode 100644 crates/abyss-backend/migrations/20261019120000_event_stream_facts/down.sql create mode 100644 crates/abyss-backend/migrations/20261019120000_event_stream_facts/up.sql create mode 100644 crates/abyss-backend/src/usage/streams.rs diff --git a/crates/abyss-backend/migrations-sqlite/20261019120000_event_stream_facts/down.sql b/crates/abyss-backend/migrations-sqlite/20261019120000_event_stream_facts/down.sql new file mode 100644 index 0000000..94d536d --- /dev/null +++ b/crates/abyss-backend/migrations-sqlite/20261019120000_event_stream_facts/down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS llm_usage_event_stream_facts; diff --git a/crates/abyss-backend/migrations-sqlite/20261019120000_event_stream_facts/up.sql b/crates/abyss-backend/migrations-sqlite/20261019120000_event_stream_facts/up.sql new file mode 100644 index 0000000..9e906f7 --- /dev/null +++ b/crates/abyss-backend/migrations-sqlite/20261019120000_event_stream_facts/up.sql @@ -0,0 +1,19 @@ +-- Stream facts read at ingest from one event's own conversation entries, +-- kept beside the event so a session's streams read them without walking +-- the overflow pages of event metadata. A request has new_from, +-- starts_turn, last_identity, trailing, and result_ids; a response has +-- emitted_ids and output_skeleton; the other side's columns stay NULL. +-- trailing is a JSON array of roles, id lists JSON arrays of strings, and +-- output_skeleton a JSON array of [role, key] pairs, the key being the call +-- id, else the item id. Events stored before this migration have no facts +-- row. +CREATE TABLE llm_usage_event_stream_facts ( + event_pk TEXT PRIMARY KEY REFERENCES llm_usage_events(id) ON DELETE CASCADE, + new_from INTEGER, + starts_turn INTEGER, + last_identity TEXT, + trailing TEXT CHECK (json_valid(trailing)), + result_ids TEXT CHECK (json_valid(result_ids)), + emitted_ids TEXT CHECK (json_valid(emitted_ids)), + output_skeleton TEXT CHECK (json_valid(output_skeleton)) +) STRICT; diff --git a/crates/abyss-backend/migrations/20261019120000_event_stream_facts/down.sql b/crates/abyss-backend/migrations/20261019120000_event_stream_facts/down.sql new file mode 100644 index 0000000..94d536d --- /dev/null +++ b/crates/abyss-backend/migrations/20261019120000_event_stream_facts/down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS llm_usage_event_stream_facts; diff --git a/crates/abyss-backend/migrations/20261019120000_event_stream_facts/up.sql b/crates/abyss-backend/migrations/20261019120000_event_stream_facts/up.sql new file mode 100644 index 0000000..2cad9c0 --- /dev/null +++ b/crates/abyss-backend/migrations/20261019120000_event_stream_facts/up.sql @@ -0,0 +1,18 @@ +-- Stream facts read at ingest from one event's own conversation entries, +-- kept beside the event so a session's streams read them without touching +-- event metadata. A request has new_from, starts_turn, last_identity, +-- trailing, and result_ids; a response has emitted_ids and output_skeleton; +-- the other side's columns stay NULL. trailing is a JSON array of roles, id +-- lists JSON arrays of strings, and output_skeleton a JSON array of +-- [role, key] pairs, the key being the call id, else the item id. Events +-- stored before this migration have no facts row. +CREATE TABLE llm_usage_event_stream_facts ( + event_pk uuid PRIMARY KEY REFERENCES llm_usage_events(id) ON DELETE CASCADE, + new_from integer, + starts_turn boolean, + last_identity text, + trailing jsonb, + result_ids jsonb, + emitted_ids jsonb, + output_skeleton jsonb +); diff --git a/crates/abyss-backend/src/db/models.rs b/crates/abyss-backend/src/db/models.rs index 74cb971..4842b42 100644 --- a/crates/abyss-backend/src/db/models.rs +++ b/crates/abyss-backend/src/db/models.rs @@ -11,7 +11,8 @@ use uuid::Uuid; use super::schema::{ agent_diagnostic_capture_events, agent_diagnostic_captures, agent_sessions, agent_turns, - devices, llm_usage_event_attachments, llm_usage_events, search_outbox, + devices, llm_usage_event_attachments, llm_usage_event_stream_facts, llm_usage_events, + search_outbox, }; #[derive(Clone, Debug, Queryable, Selectable)] @@ -353,6 +354,28 @@ pub struct NewUsageEvent { pub previous_response_id: Option, } +#[derive(Debug, Default, Insertable)] +#[diesel(table_name = llm_usage_event_stream_facts)] +/// Stream facts of a new event; the other side's columns stay NULL. +pub struct NewStreamFacts { + /// Event the facts were read from. + pub event_pk: Uuid, + /// Request: index where the new part begins. + pub new_from: Option, + /// Request: whether the new part opens a turn. + pub starts_turn: Option, + /// Request: last identity of the last run. + pub last_identity: Option, + /// Request: roles of the model-side entries after it. + pub trailing: Option, + /// Request without model output: call ids of its tool results. + pub result_ids: Option, + /// Response: identities of its entries. + pub emitted_ids: Option, + /// Response: skeleton of its entries. + pub output_skeleton: Option, +} + #[derive(Insertable)] #[diesel(table_name = llm_usage_event_attachments)] /// Validated values used to create one event attachment. diff --git a/crates/abyss-backend/src/db/schema.rs b/crates/abyss-backend/src/db/schema.rs index 5cbd236..bb1f458 100644 --- a/crates/abyss-backend/src/db/schema.rs +++ b/crates/abyss-backend/src/db/schema.rs @@ -95,6 +95,21 @@ diesel::table! { } } +diesel::table! { + use diesel::sql_types::{Bool, Integer, Jsonb, Nullable, Text, Uuid}; + + llm_usage_event_stream_facts (event_pk) { + event_pk -> Uuid, + new_from -> Nullable, + starts_turn -> Nullable, + last_identity -> Nullable, + trailing -> Nullable, + result_ids -> Nullable, + emitted_ids -> Nullable, + output_skeleton -> Nullable, + } +} + diesel::table! { use diesel::sql_types::{BigInt, Bytea, Integer, Nullable, Text, Timestamptz, Uuid}; @@ -182,6 +197,7 @@ diesel::joinable!(llm_usage_events -> app_users (user_id)); diesel::joinable!(llm_usage_events -> devices (device_context_id)); diesel::joinable!(llm_usage_event_attachments -> app_users (user_id)); diesel::joinable!(llm_usage_event_attachments -> llm_usage_events (event_pk)); +diesel::joinable!(llm_usage_event_stream_facts -> llm_usage_events (event_pk)); diesel::joinable!(agent_diagnostic_capture_events -> agent_diagnostic_captures (capture_pk)); diesel::joinable!(agent_diagnostic_capture_events -> llm_usage_events (event_pk)); diesel::joinable!(agent_diagnostic_captures -> agent_sessions (session_pk)); @@ -198,6 +214,7 @@ diesel::allow_tables_to_appear_in_same_query!( devices, llm_usage_events, llm_usage_event_attachments, + llm_usage_event_stream_facts, search_outbox, session_titles, ); diff --git a/crates/abyss-backend/src/storage/postgres/repository.rs b/crates/abyss-backend/src/storage/postgres/repository.rs index a63169e..c51453d 100644 --- a/crates/abyss-backend/src/storage/postgres/repository.rs +++ b/crates/abyss-backend/src/storage/postgres/repository.rs @@ -21,6 +21,8 @@ use diesel::{ }, upsert::excluded, }; +use serde::Serialize; +use serde_json::Value; use uuid::Uuid; use crate::{ @@ -28,12 +30,13 @@ use crate::{ models::{ AgentSession, AgentTurn, Device, NewAgentDiagnosticCapture, NewAgentDiagnosticCaptureEvent, NewAgentSession, NewAgentTurn, NewDevice, - NewSearchOutboxTask, NewUsageEvent, NewUsageEventAttachment, UsageEvent, - UsageEventAttachment, + NewSearchOutboxTask, NewStreamFacts, NewUsageEvent, NewUsageEventAttachment, + UsageEvent, UsageEventAttachment, }, schema::{ agent_diagnostic_capture_events, agent_diagnostic_captures, agent_sessions, - agent_turns, devices, llm_usage_event_attachments, llm_usage_events, search_outbox, + agent_turns, devices, llm_usage_event_attachments, llm_usage_event_stream_facts, + llm_usage_events, search_outbox, }, }, error::AppError, @@ -41,7 +44,7 @@ use crate::{ IngestEventsRequest, IngestEventsResponse, IngestUsageEvent, OUTLINE_PREVIEW_CHARS, RawEventsQuery, RawEventsResponse, SessionInfo, SessionOutlineResponse, SessionTimelineResponse, SummaryQuery, SummaryResponse, SummaryRow, TurnTimeline, - UsageEventOutline, UsageEventResponse, + UsageEventOutline, UsageEventResponse, UsageEventType, attachments::{ ImageMediaType, StoredImageAttachment, UsageEventAttachmentResponse, ValidatedImageAttachment, @@ -53,6 +56,7 @@ use crate::{ normalize_limit, normalize_text, parse_group_by, sha256_bytes, unix_epoch, validate_batch, validate_event_type, }, + streams::{RequestFacts, ResponseFacts}, }, }; @@ -659,6 +663,38 @@ pub fn image_attachment( }) } +fn json_value(value: &T, field: &str) -> Result { + serde_json::to_value(value) + .map_err(|error| AppError::internal(format!("serialize {field}: {error}"))) +} + +/// Stream fact columns of one ingested event; the other side's stay NULL. +fn stream_facts(event_pk: Uuid, event: &IngestUsageEvent) -> Result { + Ok(match event.event_type { + UsageEventType::Request => { + let facts = RequestFacts::read(&event.metadata); + NewStreamFacts { + event_pk, + new_from: Some(facts.new_from), + starts_turn: Some(facts.starts_turn), + last_identity: facts.last_identity, + trailing: Some(json_value(&facts.trailing, "trailing roles")?), + result_ids: Some(json_value(&facts.result_ids, "result ids")?), + ..NewStreamFacts::default() + } + } + UsageEventType::Response => { + let facts = ResponseFacts::read(&event.metadata); + NewStreamFacts { + event_pk, + emitted_ids: Some(json_value(&facts.emitted_ids, "emitted ids")?), + output_skeleton: Some(json_value(&facts.output_skeleton, "output skeleton")?), + ..NewStreamFacts::default() + } + } + }) +} + fn ingest_one_event( connection: &mut PgConnection, user_id: Uuid, @@ -742,6 +778,9 @@ fn ingest_one_event( // The enclosing transaction commits the event and its projection task // together, providing at-least-once delivery to Elasticsearch. if inserted == 1 { + diesel::insert_into(llm_usage_event_stream_facts::table) + .values(stream_facts(usage_event_id, event)?) + .execute(connection)?; diesel::insert_into(search_outbox::table) .values(NewSearchOutboxTask { event_pk: usage_event_id, diff --git a/crates/abyss-backend/src/storage/sqlite/models.rs b/crates/abyss-backend/src/storage/sqlite/models.rs index 0bc692f..6a2390a 100644 --- a/crates/abyss-backend/src/storage/sqlite/models.rs +++ b/crates/abyss-backend/src/storage/sqlite/models.rs @@ -2,14 +2,21 @@ use chrono::{DateTime, Utc}; use diesel::{Insertable, Queryable, Selectable}; +use serde::Serialize; use serde_json::Value; use uuid::Uuid; -use crate::{error::AppError, usage::event_order::TimelineEvent}; +use crate::{ + error::AppError, + usage::{ + event_order::TimelineEvent, + streams::{RequestFacts, ResponseFacts}, + }, +}; use super::schema::{ agent_diagnostic_capture_events, agent_diagnostic_captures, agent_sessions, agent_turns, - devices, llm_usage_event_attachments, llm_usage_events, + devices, llm_usage_event_attachments, llm_usage_event_stream_facts, llm_usage_events, }; #[derive(Queryable, Selectable)] @@ -168,6 +175,50 @@ pub(super) struct NewEvent { pub(super) previous_response_id: Option, } +/// Stream facts of a new event; the other side's columns stay NULL. +#[derive(Default, Insertable)] +#[diesel(table_name = llm_usage_event_stream_facts)] +pub(super) struct NewStreamFacts { + pub(super) event_pk: String, + pub(super) new_from: Option, + pub(super) starts_turn: Option, + pub(super) last_identity: Option, + pub(super) trailing: Option, + pub(super) result_ids: Option, + pub(super) emitted_ids: Option, + pub(super) output_skeleton: Option, +} + +impl NewStreamFacts { + /// Columns of a request's facts. + pub(super) fn request(event_pk: String, facts: &RequestFacts) -> Result { + Ok(Self { + event_pk, + new_from: Some(facts.new_from), + starts_turn: Some(facts.starts_turn), + last_identity: facts.last_identity.clone(), + trailing: Some(json_text(&facts.trailing, "trailing roles")?), + result_ids: Some(json_text(&facts.result_ids, "result ids")?), + ..Self::default() + }) + } + + /// Columns of a response's facts. + pub(super) fn response(event_pk: String, facts: &ResponseFacts) -> Result { + Ok(Self { + event_pk, + emitted_ids: Some(json_text(&facts.emitted_ids, "emitted ids")?), + output_skeleton: Some(json_text(&facts.output_skeleton, "output skeleton")?), + ..Self::default() + }) + } +} + +fn json_text(value: &T, field: &str) -> Result { + serde_json::to_string(value) + .map_err(|error| AppError::internal(format!("serialize {field}: {error}"))) +} + #[derive(Queryable, Selectable)] #[diesel(table_name = llm_usage_event_attachments)] #[diesel(check_for_backend(diesel::sqlite::Sqlite))] diff --git a/crates/abyss-backend/src/storage/sqlite/repository.rs b/crates/abyss-backend/src/storage/sqlite/repository.rs index 33690c4..86c9919 100644 --- a/crates/abyss-backend/src/storage/sqlite/repository.rs +++ b/crates/abyss-backend/src/storage/sqlite/repository.rs @@ -20,7 +20,7 @@ use crate::{ IngestEventsRequest, IngestEventsResponse, IngestUsageEvent, OUTLINE_PREVIEW_CHARS, RawEventsQuery, RawEventsResponse, SessionInfo, SessionOutlineResponse, SessionTimelineResponse, SummaryQuery, SummaryResponse, SummaryRow, TurnTimeline, - UsageEventOutline, UsageEventResponse, + UsageEventOutline, UsageEventResponse, UsageEventType, attachments::{ ImageMediaType, StoredImageAttachment, UsageEventAttachmentResponse, ValidatedImageAttachment, @@ -31,18 +31,20 @@ use crate::{ exact_values_filter, non_empty, normalize_limit, normalize_text, parse_group_by, sha256_bytes, validate_batch, validate_event_type, }, + streams::{RequestFacts, ResponseFacts}, }, }; use super::{ models::{ AttachmentRow, DeviceRecord, DeviceRow, EventRecord, EventRow, NewAttachment, NewDevice, - NewDiagnosticCapture, NewDiagnosticCaptureEvent, NewEvent, NewSession, NewTurn, - SessionRecord, SessionRow, TurnRecord, TurnRow, parse_uuid, timestamp_from_micros, + NewDiagnosticCapture, NewDiagnosticCaptureEvent, NewEvent, NewSession, NewStreamFacts, + NewTurn, SessionRecord, SessionRow, TurnRecord, TurnRow, parse_uuid, timestamp_from_micros, }, schema::{ agent_diagnostic_capture_events, agent_diagnostic_captures, agent_sessions, agent_turns, - app_users, devices, llm_usage_event_attachments, llm_usage_events, + app_users, devices, llm_usage_event_attachments, llm_usage_event_stream_facts, + llm_usage_events, }, session_titles, }; @@ -173,6 +175,7 @@ fn ingest_one_event( return Ok(false); } + insert_stream_facts(connection, event_pk, event)?; insert_attachments(connection, user_id, event_pk, attachments, now)?; insert_search_projection(connection, event_pk, user_id, session_pk, event)?; session_titles::capture( @@ -511,6 +514,27 @@ fn insert_search_projection( Ok(()) } +/// Stores the stream facts read from one new event's own entries. +fn insert_stream_facts( + connection: &mut SqliteConnection, + event_pk: Uuid, + event: &IngestUsageEvent, +) -> Result<(), AppError> { + let event_pk = event_pk.to_string(); + let facts = match event.event_type { + UsageEventType::Request => { + NewStreamFacts::request(event_pk, &RequestFacts::read(&event.metadata))? + } + UsageEventType::Response => { + NewStreamFacts::response(event_pk, &ResponseFacts::read(&event.metadata))? + } + }; + diesel::insert_into(llm_usage_event_stream_facts::table) + .values(facts) + .execute(connection)?; + Ok(()) +} + fn ingest_one_diagnostic_capture( connection: &mut SqliteConnection, user_id: Uuid, @@ -1424,7 +1448,7 @@ mod tests { use super::{ super::{ migrations, - schema::{agent_turns, llm_usage_events}, + schema::{agent_turns, llm_usage_event_stream_facts, llm_usage_events}, search, }, ingest_events, raw_events, session_outline, usage_summary, @@ -1611,6 +1635,81 @@ mod tests { assert_eq!(empty.text.as_deref(), Some("")); } + #[test] + fn ingest_stores_the_stream_facts_of_each_new_event_beside_it() { + type Facts = ( + String, + Option, + Option, + Option, + Option, + Option, + ); + let mut database = database(); + let event = |id: &str, event_type: &str, entries: serde_json::Value| { + json!({ + "event_id": id, + "observed_at": "2026-10-19T00:00:00Z", + "device": {"host_name": "local", "platform": "macos"}, + "agent": {"name": "codex"}, + "session_id": "sqlite-session", + "llm": {"provider": "openai", "model": "gpt-5"}, + "event_type": event_type, + "metadata": {"conversation": {"entries": entries, "completeness": "complete"}} + }) + }; + let request: IngestEventsRequest = serde_json::from_value(json!({"events": [ + event("ask", "request", json!([ + {"role": "user", "text": "fix it"}, + {"role": "tool_call", "text": "{}", "call_id": "a", "item_id": "fc_1"}, + {"role": "tool_result", "text": "ok", "call_id": "a"} + ])), + event("answer", "response", json!([ + {"role": "assistant", "text": "done", "item_id": "msg_1"} + ])) + ]})) + .expect("test ingest request should deserialize"); + for _ in 0_u8..2 { + ingest_events(&mut database, &request, OWNER_ID, 100).expect("ingest should succeed"); + } + + let facts = llm_usage_event_stream_facts::table + .inner_join(llm_usage_events::table) + .order(llm_usage_events::event_id.desc()) + .select(( + llm_usage_events::event_id, + llm_usage_event_stream_facts::new_from, + llm_usage_event_stream_facts::starts_turn, + llm_usage_event_stream_facts::last_identity, + llm_usage_event_stream_facts::trailing, + llm_usage_event_stream_facts::emitted_ids, + )) + .load::(&mut database) + .expect("stream facts should load"); + assert_eq!( + facts, + [ + ( + "ask".to_owned(), + Some(2_i32), + Some(false), + Some("a".to_owned()), + Some("[]".to_owned()), + None + ), + ( + "answer".to_owned(), + None, + None, + None, + None, + Some(r#"["item:msg_1"]"#.to_owned()) + ), + ], + "one row per event, written once, the other side's columns NULL" + ); + } + #[test] fn event_list_leaves_out_content_but_keeps_what_a_list_shows() { let mut database = summary_seeded(); diff --git a/crates/abyss-backend/src/storage/sqlite/schema.rs b/crates/abyss-backend/src/storage/sqlite/schema.rs index 45eaec8..68ad125 100644 --- a/crates/abyss-backend/src/storage/sqlite/schema.rs +++ b/crates/abyss-backend/src/storage/sqlite/schema.rs @@ -95,6 +95,21 @@ diesel::table! { } } +diesel::table! { + use diesel::sql_types::{Bool, Integer, Nullable, Text}; + + llm_usage_event_stream_facts (event_pk) { + event_pk -> Text, + new_from -> Nullable, + starts_turn -> Nullable, + last_identity -> Nullable, + trailing -> Nullable, + result_ids -> Nullable, + emitted_ids -> Nullable, + output_skeleton -> Nullable, + } +} + diesel::table! { use diesel::sql_types::{BigInt, Binary, Integer, Nullable, Text}; @@ -163,6 +178,7 @@ diesel::joinable!(llm_usage_events -> app_users (user_id)); diesel::joinable!(llm_usage_events -> devices (device_context_id)); diesel::joinable!(llm_usage_event_attachments -> app_users (user_id)); diesel::joinable!(llm_usage_event_attachments -> llm_usage_events (event_pk)); +diesel::joinable!(llm_usage_event_stream_facts -> llm_usage_events (event_pk)); diesel::joinable!(agent_diagnostic_capture_events -> agent_diagnostic_captures (capture_pk)); diesel::joinable!(agent_diagnostic_capture_events -> llm_usage_events (event_pk)); diesel::joinable!(agent_diagnostic_captures -> agent_sessions (session_pk)); @@ -179,5 +195,6 @@ diesel::allow_tables_to_appear_in_same_query!( devices, llm_usage_events, llm_usage_event_attachments, + llm_usage_event_stream_facts, session_titles, ); diff --git a/crates/abyss-backend/src/usage/conversation.rs b/crates/abyss-backend/src/usage/conversation.rs index abe6c48..7fa5232 100644 --- a/crates/abyss-backend/src/usage/conversation.rs +++ b/crates/abyss-backend/src/usage/conversation.rs @@ -32,7 +32,6 @@ pub struct Entry { pub name: Option, /// Identity of the item the entry belongs to, as the traffic carries it. #[serde(default)] - #[expect(dead_code, reason = "read once streams are linked by item identity")] pub item_id: Option, } diff --git a/crates/abyss-backend/src/usage/mod.rs b/crates/abyss-backend/src/usage/mod.rs index a71b0c2..f25714f 100644 --- a/crates/abyss-backend/src/usage/mod.rs +++ b/crates/abyss-backend/src/usage/mod.rs @@ -15,6 +15,8 @@ pub mod persistence; /// Owner-scoped session index contracts. pub mod session_list; pub mod session_title; +/// Stream facts read from each event at ingest. +pub mod streams; pub mod trajectory; use chrono::{DateTime, Utc}; diff --git a/crates/abyss-backend/src/usage/streams.rs b/crates/abyss-backend/src/usage/streams.rs new file mode 100644 index 0000000..83891a2 --- /dev/null +++ b/crates/abyss-backend/src/usage/streams.rs @@ -0,0 +1,374 @@ +//! Stream facts ingest reads from each event's own conversation entries. +//! +//! A stream is one line of the model's context. Every request has at most one +//! base, the exchange whose output its context continues. Ingest reads the +//! roles, call ids, and item ids of one event's conversation entries, plus +//! whether a user entry has text, and never looks at another event, so a +//! query can find each base by key lookups within the session. + +use serde_json::Value; + +use super::conversation::{Conversation, Entry}; + +/// Roles of the entries the model produced. +const MODEL_ROLES: [&str; 3] = ["assistant", "reasoning", "tool_call"]; + +/// Prefix that keeps item ids apart from call ids among entry identities. +const ITEM_PREFIX: &str = "item:"; + +impl Entry { + fn is_model_side(&self) -> bool { + MODEL_ROLES.contains(&self.role.as_str()) + } + + /// The item id of a model-side entry, with [`ITEM_PREFIX`]. + fn item(&self) -> Option { + self.item_id + .as_deref() + .filter(|_| self.is_model_side()) + .map(|item_id| format!("{ITEM_PREFIX}{item_id}")) + } + + /// The provider-made ids of the entry: its item, then the call id of a + /// `tool_call` entry. + fn identities(&self) -> impl Iterator { + let call = self.call_id.clone().filter(|_| self.role == "tool_call"); + self.item().into_iter().chain(call) + } + + /// The entry's key in a skeleton: its call id, else its item. + fn key(&self) -> Option { + self.call_id.clone().or_else(|| self.item()) + } +} + +/// The entries of the event's conversation; none when it is not completely +/// decoded. +fn conversation_entries(metadata: &Value) -> Vec { + Conversation::read(metadata) + .map(|conversation| conversation.entries) + .unwrap_or_default() +} + +/// One skeleton entry: an entry's role and key, its call id, else its +/// `item:`-prefixed item id. +pub type SkeletonEntry = (String, Option); + +/// The `(role, key)` of each entry in order. +fn skeleton(entries: &[Entry]) -> Vec { + entries + .iter() + .map(|entry| (entry.role.clone(), entry.key())) + .collect() +} + +/// Call ids of the `tool_result` entries, in order. +fn result_ids(entries: &[Entry]) -> Vec { + entries + .iter() + .filter(|entry| entry.role == "tool_result") + .filter_map(|entry| entry.call_id.clone()) + .collect() +} + +/// Identities of the entries, in order. +fn identities(entries: &[Entry]) -> Vec { + entries.iter().flat_map(Entry::identities).collect() +} + +#[derive(Clone, Debug, Default, PartialEq, Eq)] +/// Facts of one request event, read from its conversation entries alone. +pub struct RequestFacts { + /// Index after the last model-side entry, or 0 without one; the entries + /// from here on are the request's new part. + pub new_from: i32, + /// The last identity in the last run, the run reaching from the last + /// model-side entry back through model-side and `tool_result` entries to + /// any other role or the start: the call id of a `tool_call` entry, else + /// the `item:`-prefixed item id of a model-side entry. + pub last_identity: Option, + /// Roles of the model-side entries after the one holding + /// `last_identity`, in order; none of them has an identity. Empty without + /// `last_identity`. + pub trailing: Vec, + /// Without model-side entries, the call ids of every `tool_result` + /// entry; otherwise empty. + pub result_ids: Vec, + /// Whether the new part holds a `user` entry with text and no + /// `tool_result` entry. + pub starts_turn: bool, +} + +impl RequestFacts { + /// Reads the facts of a request event from its metadata. + /// core logic of connect exchanges. + pub fn read(metadata: &Value) -> Self { + let entries = conversation_entries(metadata); + let Some(last) = entries.iter().rposition(Entry::is_model_side) else { + return Self { + new_from: 0, + last_identity: None, + trailing: Vec::new(), + result_ids: result_ids(&entries), + starts_turn: starts_turn(&entries), + }; + }; + let start = entries[..last] + .iter() + .rposition(|entry| !entry.is_model_side() && entry.role != "tool_result") + .map_or(0, |other| other.saturating_add(1)); + let run = &entries[start..=last]; + let held = run + .iter() + .rposition(|entry| entry.identities().next().is_some()); + let last_identity = held.and_then(|held| run[held].identities().last()); + let trailing = held.map_or_else(Vec::new, |held| { + run[held.saturating_add(1)..] + .iter() + .filter(|entry| entry.is_model_side()) + .map(|entry| entry.role.clone()) + .collect() + }); + let new_from = last.saturating_add(1); + Self { + new_from: i32::try_from(new_from).unwrap_or(i32::MAX), + last_identity, + trailing, + result_ids: Vec::new(), + starts_turn: starts_turn(&entries[new_from..]), + } + } +} + +/// Whether a new part opens a turn: it has user text and no tool result. +fn starts_turn(new_part: &[Entry]) -> bool { + new_part + .iter() + .any(|entry| entry.role == "user" && !entry.text.is_empty()) + && !new_part.iter().any(|entry| entry.role == "tool_result") +} + +#[derive(Clone, Debug, Default, PartialEq, Eq)] +/// Facts of one response event, read from its conversation entries alone. +pub struct ResponseFacts { + /// Identities of every entry, in order: for each entry, the + /// `item:`-prefixed item id of a model-side entry, then the call id of a + /// `tool_call` entry. + pub emitted_ids: Vec, + /// Skeleton of every entry. + pub output_skeleton: Vec, +} + +impl ResponseFacts { + /// Reads the facts of a response event from its metadata. + pub fn read(metadata: &Value) -> Self { + let entries = conversation_entries(metadata); + Self { + emitted_ids: identities(&entries), + output_skeleton: skeleton(&entries), + } + } +} + +#[cfg(test)] +mod tests { + use serde_json::{Value, json}; + + use super::{RequestFacts, ResponseFacts}; + + fn conversation(entries: &Value) -> Value { + json!({"conversation": {"entries": entries, "completeness": "complete"}}) + } + + fn user(text: &str) -> Value { + json!({"role": "user", "text": text}) + } + + fn system(text: &str) -> Value { + json!({"role": "system", "text": text}) + } + + fn assistant(text: &str) -> Value { + json!({"role": "assistant", "text": text}) + } + + fn reasoning() -> Value { + json!({"role": "reasoning", "text": ""}) + } + + fn call(id: &str) -> Value { + json!({"role": "tool_call", "text": "{}", "call_id": id, "name": "Bash"}) + } + + fn result(id: &str) -> Value { + json!({"role": "tool_result", "text": "ok", "call_id": id}) + } + + /// `entry` as part of the model output item `id`. + fn item(mut entry: Value, id: &str) -> Value { + entry["item_id"] = json!(id); + entry + } + + fn skeleton(entries: &[(&str, Option<&str>)]) -> Vec<(String, Option)> { + entries + .iter() + .map(|(role, id)| ((*role).to_owned(), id.map(str::to_owned))) + .collect() + } + + fn ids(values: &[&str]) -> Vec { + values.iter().map(|value| (*value).to_owned()).collect() + } + + #[test] + fn request_facts_follow_the_last_run_and_the_new_part() { + // A tool loop resent with its history: the last run walks back + // through the client's tool result to the user entry. + let facts = RequestFacts::read(&conversation(&json!([ + system("rules"), + user("fix it"), + reasoning(), + call("a"), + result("a"), + reasoning(), + call("b"), + result("b") + ]))); + assert_eq!( + facts, + RequestFacts { + new_from: 7, + last_identity: Some("b".to_owned()), + trailing: Vec::new(), + result_ids: Vec::new(), + starts_turn: false, + } + ); + // New user text after the last answer opens a turn; the answer has no + // identity, so it trails the last call. + let facts = RequestFacts::read(&conversation(&json!([ + user("fix it"), + call("a"), + result("a"), + assistant("fixed"), + system(""), + user("next") + ]))); + assert_eq!(facts.new_from, 4_i32); + assert_eq!(facts.last_identity.as_deref(), Some("a")); + assert_eq!(facts.trailing, ["assistant"]); + assert!(facts.starts_turn); + assert!(facts.result_ids.is_empty()); + // Without model output, every entry is new and tool results answer + // calls that live elsewhere. + assert_eq!( + RequestFacts::read(&conversation(&json!([ + result("a"), + result("b"), + user("go") + ]))), + RequestFacts { + new_from: 0, + last_identity: None, + trailing: Vec::new(), + result_ids: ids(&["a", "b"]), + starts_turn: false, + } + ); + // A run without identities has nothing to align on and nothing + // trailing; the run stops at the user entry before it. + let facts = RequestFacts::read(&conversation(&json!([ + call("a"), + result("a"), + user("thanks"), + assistant("sure") + ]))); + assert_eq!((facts.last_identity, facts.trailing), (None, Vec::new())); + // Empty user text opens no turn; a request without a complete + // conversation has no facts. + assert!(!RequestFacts::read(&conversation(&json!([assistant("a"), user("")]))).starts_turn); + assert_eq!(RequestFacts::read(&json!({})), RequestFacts::default()); + assert_eq!( + RequestFacts::read(&json!({"conversation": { + "entries": [assistant("a"), user("next")], + "completeness": "partial" + }})), + RequestFacts::default() + ); + } + + #[test] + fn response_facts_list_emitted_calls_and_the_output_skeleton() { + assert_eq!( + ResponseFacts::read(&conversation(&json!([ + reasoning(), + assistant("let me look"), + call("a"), + call("b"), + {"role": "tool_result", "text": "found", "call_id": "s"} + ]))), + ResponseFacts { + emitted_ids: ids(&["a", "b"]), + output_skeleton: skeleton(&[ + ("reasoning", None), + ("assistant", None), + ("tool_call", Some("a")), + ("tool_call", Some("b")), + ("tool_result", Some("s")) + ]), + } + ); + assert_eq!( + ResponseFacts::read(&json!({"conversation": {}})), + ResponseFacts::default() + ); + } + + #[test] + fn identities_are_call_ids_and_prefixed_item_ids_of_model_output() { + // OpenAI Responses: every output item carries its id, and a function + // call its call id as well. + let output = [ + item(reasoning(), "rs_1"), + item(assistant("let me look"), "msg_1"), + item(call("a"), "fc_1"), + ]; + let facts = ResponseFacts::read(&conversation(&json!(output))); + assert_eq!( + facts.emitted_ids, + ids(&["item:rs_1", "item:msg_1", "item:fc_1", "a"]) + ); + assert_eq!( + facts.output_skeleton, + skeleton(&[ + ("reasoning", Some("item:rs_1")), + ("assistant", Some("item:msg_1")), + ("tool_call", Some("a")) + ]), + "a skeleton keys entries by call id, else by item id" + ); + // Entries of one item share its id; item ids of other roles are no + // identities; a call's last identity is its call id. + let mut request = vec![item(user("go"), "u_1")]; + request.extend(output.clone()); + request.extend([ + item(result("a"), "fco_1"), + item(assistant("done,"), "msg_2"), + item(assistant("really"), "msg_2"), + ]); + let facts = RequestFacts::read(&conversation(&json!(request))); + assert_eq!(facts.last_identity.as_deref(), Some("item:msg_2")); + assert!(facts.trailing.is_empty()); + let mut request = vec![user("go")]; + request.extend(output); + request.extend([result("a"), reasoning(), assistant("done")]); + let facts = RequestFacts::read(&conversation(&json!(request))); + assert_eq!(facts.last_identity.as_deref(), Some("a")); + assert_eq!( + facts.trailing, + ["reasoning", "assistant"], + "trailing roles keep reasoning and leave out tool results" + ); + } +} From b1f96f94daf272bf9f8d784c20cf7ef46b8b2e26 Mon Sep 17 00:00:00 2001 From: Auceptin Date: Tue, 20 Oct 2026 04:47:40 -0700 Subject: [PATCH 03/25] feat(usage): derive session streams from stored stream facts Add GET /v1/agent-usage/sessions/{session_pk}/streams. A query groups the session's events into exchanges, finds each request's base (pointer, last output, results only) or why it has none, and cuts segments at roots, turns, and branches. It returns structure only: ids, bases, rules, new part offsets, and segments. Co-Authored-By: Claude Opus 5.5 --- crates/abyss-backend/src/api.rs | 17 + crates/abyss-backend/src/db/models.rs | 32 + crates/abyss-backend/src/storage/mod.rs | 9 + crates/abyss-backend/src/storage/postgres.rs | 14 + .../src/storage/postgres/repository.rs | 105 ++- .../abyss-backend/src/storage/sqlite/mod.rs | 14 + .../src/storage/sqlite/models.rs | 64 +- .../src/storage/sqlite/repository.rs | 121 ++- crates/abyss-backend/src/usage/streams.rs | 693 +++++++++++++++++- 9 files changed, 1052 insertions(+), 17 deletions(-) diff --git a/crates/abyss-backend/src/api.rs b/crates/abyss-backend/src/api.rs index 8e1b355..a2b21c0 100644 --- a/crates/abyss-backend/src/api.rs +++ b/crates/abyss-backend/src/api.rs @@ -29,6 +29,7 @@ use crate::{ SessionOutlineResponse, SessionTimelineResponse, SummaryFields, SummaryQuery, TokenUsageSummaryResponse, UsageEventResponse, session_list::{SessionListQuery, SessionListResponse}, + streams::SessionStreamsResponse, trajectory::{self, SessionTrajectoryResponse}, }, }; @@ -87,6 +88,10 @@ pub fn router(state: AppState) -> Router { "/v1/agent-usage/sessions/{session_pk}/trajectory", get(session_trajectory), ) + .route( + "/v1/agent-usage/sessions/{session_pk}/streams", + get(session_streams), + ) .layer(TraceLayer::new_for_http()) .with_state(state) } @@ -199,6 +204,18 @@ async fn session_trajectory( Ok(Json(trajectory::build(timeline))) } +/// Exchanges and segments of one session, without content. +async fn session_streams( + State(state): State, + headers: HeaderMap, + Path(session_pk): Path, +) -> Result, AppError> { + let user_id = state.identity.authenticate(&headers)?; + Ok(Json( + state.storage.session_streams(user_id, session_pk).await?, + )) +} + async fn usage_event( State(state): State, headers: HeaderMap, diff --git a/crates/abyss-backend/src/db/models.rs b/crates/abyss-backend/src/db/models.rs index 4842b42..c211564 100644 --- a/crates/abyss-backend/src/db/models.rs +++ b/crates/abyss-backend/src/db/models.rs @@ -354,6 +354,38 @@ pub struct NewUsageEvent { pub previous_response_id: Option, } +#[derive(Queryable)] +/// The columns of one session event that its streams read; the facts are +/// NULL for an event stored before they were read. +pub struct StreamEventRow { + /// Event primary key. + pub id: Uuid, + /// Collector-generated idempotency key. + pub event_id: String, + /// Canonical request or response value. + pub event_type: String, + /// Collector observation time. + pub observed_at: DateTime, + /// Provider identity of the response the exchange produced. + pub response_id: Option, + /// Response whose conversation the exchange's request continues. + pub previous_response_id: Option, + /// Request: index where the new part begins. + pub new_from: Option, + /// Request: whether the new part opens a turn. + pub starts_turn: Option, + /// Request: last identity of the last run. + pub last_identity: Option, + /// Request: roles of the model-side entries after it. + pub trailing: Option, + /// Request without model output: call ids of its tool results. + pub result_ids: Option, + /// Response: identities of its entries. + pub emitted_ids: Option, + /// Response: skeleton of its entries. + pub output_skeleton: Option, +} + #[derive(Debug, Default, Insertable)] #[diesel(table_name = llm_usage_event_stream_facts)] /// Stream facts of a new event; the other side's columns stay NULL. diff --git a/crates/abyss-backend/src/storage/mod.rs b/crates/abyss-backend/src/storage/mod.rs index 3f846cc..bdb8ead 100644 --- a/crates/abyss-backend/src/storage/mod.rs +++ b/crates/abyss-backend/src/storage/mod.rs @@ -24,6 +24,7 @@ use crate::{ UsageEventResponse, attachments::StoredImageAttachment, session_list::{SessionListResponse, ValidatedSessionListQuery}, + streams::SessionStreamsResponse, }, }; @@ -80,6 +81,14 @@ pub trait StorageBackend: Send + Sync { session_pk: Uuid, ) -> BoxedFuture<'_, Result>; + /// Derives the exchanges and segments of an owner-scoped session; not + /// found when the owner has no event in that session. + fn session_streams( + &self, + user_id: Uuid, + session_pk: Uuid, + ) -> BoxedFuture<'_, Result>; + /// Lists one page of owner-scoped sessions, most recently active first. fn list_sessions( &self, diff --git a/crates/abyss-backend/src/storage/postgres.rs b/crates/abyss-backend/src/storage/postgres.rs index 70b592d..043d39c 100644 --- a/crates/abyss-backend/src/storage/postgres.rs +++ b/crates/abyss-backend/src/storage/postgres.rs @@ -23,6 +23,7 @@ use crate::{ UsageEventResponse, attachments::StoredImageAttachment, session_list::{SessionListResponse, ValidatedSessionListQuery}, + streams::SessionStreamsResponse, }, }; @@ -169,6 +170,19 @@ impl StorageBackend for PostgresEsBackend { }) } + fn session_streams( + &self, + user_id: Uuid, + session_pk: Uuid, + ) -> BoxedFuture<'_, Result> { + Box::pin(async move { + self.run_db(move |connection| { + repository::session_streams(connection, user_id, session_pk) + }) + .await + }) + } + fn list_sessions( &self, user_id: Uuid, diff --git a/crates/abyss-backend/src/storage/postgres/repository.rs b/crates/abyss-backend/src/storage/postgres/repository.rs index c51453d..da9c8c1 100644 --- a/crates/abyss-backend/src/storage/postgres/repository.rs +++ b/crates/abyss-backend/src/storage/postgres/repository.rs @@ -10,8 +10,8 @@ use std::collections::{HashMap, HashSet}; use chrono::{DateTime, Utc}; use diesel::{ - Connection, ExpressionMethods, IntoSql, PgConnection, QueryDsl, QueryableByName, RunQueryDsl, - SelectableHelper, define_sql_function, + Connection, ExpressionMethods, IntoSql, NullableExpressionMethods, PgConnection, QueryDsl, + QueryableByName, RunQueryDsl, SelectableHelper, define_sql_function, dsl::{count_star, max, sql}, result::OptionalExtension, sql_query, @@ -21,7 +21,7 @@ use diesel::{ }, upsert::excluded, }; -use serde::Serialize; +use serde::{Serialize, de::DeserializeOwned}; use serde_json::Value; use uuid::Uuid; @@ -31,7 +31,7 @@ use crate::{ AgentSession, AgentTurn, Device, NewAgentDiagnosticCapture, NewAgentDiagnosticCaptureEvent, NewAgentSession, NewAgentTurn, NewDevice, NewSearchOutboxTask, NewStreamFacts, NewUsageEvent, NewUsageEventAttachment, - UsageEvent, UsageEventAttachment, + StreamEventRow, UsageEvent, UsageEventAttachment, }, schema::{ agent_diagnostic_capture_events, agent_diagnostic_captures, agent_sessions, @@ -56,7 +56,10 @@ use crate::{ normalize_limit, normalize_text, parse_group_by, sha256_bytes, unix_epoch, validate_batch, validate_event_type, }, - streams::{RequestFacts, ResponseFacts}, + streams::{ + EventFacts, RequestFacts, ResponseFacts, SessionStreamsResponse, StreamEvent, + session_streams as streams_of, + }, }, }; @@ -663,6 +666,98 @@ pub fn image_attachment( }) } +/// Derives the streams of one session's events from their stream facts, +/// without reading any request body. +pub fn session_streams( + connection: &mut PgConnection, + user_id: Uuid, + session_pk: Uuid, +) -> Result { + let events = stream_events(connection, user_id, session_pk)?; + if events.is_empty() { + return Err(AppError::not_found(format!( + "session not found: {session_pk}" + ))); + } + Ok(streams_of(session_pk, events)) +} + +/// The events of one session in session order, with their stream facts and +/// without any request body. +fn stream_events( + connection: &mut PgConnection, + user_id: Uuid, + session_pk: Uuid, +) -> Result, AppError> { + llm_usage_events::table + .left_join(llm_usage_event_stream_facts::table) + .filter(llm_usage_events::user_id.eq(user_id)) + .filter(llm_usage_events::session_pk.eq(session_pk)) + .order(( + llm_usage_events::observed_at.asc(), + llm_usage_events::event_type.asc(), + llm_usage_events::id.asc(), + )) + .select(( + llm_usage_events::id, + llm_usage_events::event_id, + llm_usage_events::event_type, + llm_usage_events::observed_at, + llm_usage_events::response_id, + llm_usage_events::previous_response_id, + llm_usage_event_stream_facts::new_from.nullable(), + llm_usage_event_stream_facts::starts_turn.nullable(), + llm_usage_event_stream_facts::last_identity.nullable(), + llm_usage_event_stream_facts::trailing.nullable(), + llm_usage_event_stream_facts::result_ids.nullable(), + llm_usage_event_stream_facts::emitted_ids.nullable(), + llm_usage_event_stream_facts::output_skeleton.nullable(), + )) + .load::(connection)? + .into_iter() + .map(stream_event) + .collect() +} + +fn stream_event(row: StreamEventRow) -> Result { + let facts = if row.event_type == UsageEventType::Request.as_str() { + EventFacts::Request(RequestFacts { + new_from: row.new_from.unwrap_or_default(), + starts_turn: row.starts_turn.unwrap_or_default(), + last_identity: row.last_identity, + trailing: json_list(row.trailing, "trailing roles")?, + result_ids: json_list(row.result_ids, "result ids")?, + }) + } else { + EventFacts::Response(ResponseFacts { + emitted_ids: json_list(row.emitted_ids, "emitted ids")?, + output_skeleton: json_list(row.output_skeleton, "output skeleton")?, + }) + }; + Ok(StreamEvent { + pk: row.id, + event_id: row.event_id, + observed_at: row.observed_at, + response_id: row.response_id, + previous_response_id: row.previous_response_id, + facts, + }) +} + +/// A stored JSON list; absent reads as empty. +fn json_list( + value: Option, + field: &str, +) -> Result { + value.map_or_else( + || Ok(T::default()), + |value| { + serde_json::from_value(value) + .map_err(|error| AppError::internal(format!("stored {field} are invalid: {error}"))) + }, + ) +} + fn json_value(value: &T, field: &str) -> Result { serde_json::to_value(value) .map_err(|error| AppError::internal(format!("serialize {field}: {error}"))) diff --git a/crates/abyss-backend/src/storage/sqlite/mod.rs b/crates/abyss-backend/src/storage/sqlite/mod.rs index 11f2f31..00e4294 100644 --- a/crates/abyss-backend/src/storage/sqlite/mod.rs +++ b/crates/abyss-backend/src/storage/sqlite/mod.rs @@ -23,6 +23,7 @@ use crate::{ UsageEventResponse, attachments::StoredImageAttachment, session_list::{SessionListResponse, ValidatedSessionListQuery}, + streams::SessionStreamsResponse, }, }; @@ -153,6 +154,19 @@ impl StorageBackend for SqliteFtsBackend { }) } + fn session_streams( + &self, + user_id: Uuid, + session_pk: Uuid, + ) -> BoxedFuture<'_, Result> { + Box::pin(async move { + self.run_db(move |connection| { + repository::session_streams(connection, user_id, session_pk) + }) + .await + }) + } + fn list_sessions( &self, user_id: Uuid, diff --git a/crates/abyss-backend/src/storage/sqlite/models.rs b/crates/abyss-backend/src/storage/sqlite/models.rs index 6a2390a..2297180 100644 --- a/crates/abyss-backend/src/storage/sqlite/models.rs +++ b/crates/abyss-backend/src/storage/sqlite/models.rs @@ -2,7 +2,7 @@ use chrono::{DateTime, Utc}; use diesel::{Insertable, Queryable, Selectable}; -use serde::Serialize; +use serde::{Serialize, de::DeserializeOwned}; use serde_json::Value; use uuid::Uuid; @@ -10,7 +10,7 @@ use crate::{ error::AppError, usage::{ event_order::TimelineEvent, - streams::{RequestFacts, ResponseFacts}, + streams::{EventFacts, RequestFacts, ResponseFacts, StreamEvent}, }, }; @@ -214,6 +214,66 @@ impl NewStreamFacts { } } +/// The columns of one session event that its streams read; the facts are +/// NULL for an event stored before they were read. +#[derive(Queryable)] +pub(super) struct StreamEventRow { + pub(super) id: String, + pub(super) event_id: String, + pub(super) event_type: String, + pub(super) observed_at: i64, + pub(super) response_id: Option, + pub(super) previous_response_id: Option, + pub(super) new_from: Option, + pub(super) starts_turn: Option, + pub(super) last_identity: Option, + pub(super) trailing: Option, + pub(super) result_ids: Option, + pub(super) emitted_ids: Option, + pub(super) output_skeleton: Option, +} + +impl StreamEventRow { + pub(super) fn into_stream_event(self) -> Result { + let facts = if self.event_type == "request" { + EventFacts::Request(RequestFacts { + new_from: self.new_from.unwrap_or_default(), + starts_turn: self.starts_turn.unwrap_or_default(), + last_identity: self.last_identity, + trailing: json_list(self.trailing.as_deref(), "trailing roles")?, + result_ids: json_list(self.result_ids.as_deref(), "result ids")?, + }) + } else { + EventFacts::Response(ResponseFacts { + emitted_ids: json_list(self.emitted_ids.as_deref(), "emitted ids")?, + output_skeleton: json_list(self.output_skeleton.as_deref(), "output skeleton")?, + }) + }; + Ok(StreamEvent { + pk: parse_uuid(&self.id, "event id")?, + event_id: self.event_id, + observed_at: timestamp_from_micros(self.observed_at)?, + response_id: self.response_id, + previous_response_id: self.previous_response_id, + facts, + }) + } +} + +/// A stored JSON list; absent reads as empty. +fn json_list( + text: Option<&str>, + field: &str, +) -> Result { + text.map_or_else( + || Ok(T::default()), + |text| { + serde_json::from_str(text) + .map_err(|error| AppError::internal(format!("stored {field} are invalid: {error}"))) + }, + ) +} + fn json_text(value: &T, field: &str) -> Result { serde_json::to_string(value) .map_err(|error| AppError::internal(format!("serialize {field}: {error}"))) diff --git a/crates/abyss-backend/src/storage/sqlite/repository.rs b/crates/abyss-backend/src/storage/sqlite/repository.rs index 86c9919..8fc8c01 100644 --- a/crates/abyss-backend/src/storage/sqlite/repository.rs +++ b/crates/abyss-backend/src/storage/sqlite/repository.rs @@ -4,8 +4,8 @@ use std::collections::{HashMap, HashSet}; use chrono::{DateTime, Utc}; use diesel::{ - ExpressionMethods, IntoSql, QueryDsl, QueryableByName, RunQueryDsl, SelectableHelper, - SqliteConnection, + ExpressionMethods, IntoSql, NullableExpressionMethods, QueryDsl, QueryableByName, RunQueryDsl, + SelectableHelper, SqliteConnection, dsl::{count_star, max, sql}, result::OptionalExtension, sql_query, @@ -31,7 +31,10 @@ use crate::{ exact_values_filter, non_empty, normalize_limit, normalize_text, parse_group_by, sha256_bytes, validate_batch, validate_event_type, }, - streams::{RequestFacts, ResponseFacts}, + streams::{ + RequestFacts, ResponseFacts, SessionStreamsResponse, StreamEvent, + session_streams as streams_of, + }, }, }; @@ -39,7 +42,8 @@ use super::{ models::{ AttachmentRow, DeviceRecord, DeviceRow, EventRecord, EventRow, NewAttachment, NewDevice, NewDiagnosticCapture, NewDiagnosticCaptureEvent, NewEvent, NewSession, NewStreamFacts, - NewTurn, SessionRecord, SessionRow, TurnRecord, TurnRow, parse_uuid, timestamp_from_micros, + NewTurn, SessionRecord, SessionRow, StreamEventRow, TurnRecord, TurnRow, parse_uuid, + timestamp_from_micros, }, schema::{ agent_diagnostic_capture_events, agent_diagnostic_captures, agent_sessions, agent_turns, @@ -514,6 +518,59 @@ fn insert_search_projection( Ok(()) } +/// Derives the streams of one session's events from their stream facts, +/// without reading any request body. +pub(super) fn session_streams( + connection: &mut SqliteConnection, + user_id: Uuid, + session_pk: Uuid, +) -> Result { + let events = stream_events(connection, user_id, session_pk)?; + if events.is_empty() { + return Err(AppError::not_found(format!( + "session not found: {session_pk}" + ))); + } + Ok(streams_of(session_pk, events)) +} + +/// The events of one session in session order, with their stream facts and +/// without any request body. +fn stream_events( + connection: &mut SqliteConnection, + user_id: Uuid, + session_pk: Uuid, +) -> Result, AppError> { + llm_usage_events::table + .left_join(llm_usage_event_stream_facts::table) + .filter(llm_usage_events::user_id.eq(user_id.to_string())) + .filter(llm_usage_events::session_pk.eq(session_pk.to_string())) + .order_by(( + llm_usage_events::observed_at.asc(), + llm_usage_events::event_type.asc(), + llm_usage_events::id.asc(), + )) + .select(( + llm_usage_events::id, + llm_usage_events::event_id, + llm_usage_events::event_type, + llm_usage_events::observed_at, + llm_usage_events::response_id, + llm_usage_events::previous_response_id, + llm_usage_event_stream_facts::new_from.nullable(), + llm_usage_event_stream_facts::starts_turn.nullable(), + llm_usage_event_stream_facts::last_identity.nullable(), + llm_usage_event_stream_facts::trailing.nullable(), + llm_usage_event_stream_facts::result_ids.nullable(), + llm_usage_event_stream_facts::emitted_ids.nullable(), + llm_usage_event_stream_facts::output_skeleton.nullable(), + )) + .load::(connection)? + .into_iter() + .map(StreamEventRow::into_stream_event) + .collect() +} + /// Stores the stream facts read from one new event's own entries. fn insert_stream_facts( connection: &mut SqliteConnection, @@ -1448,10 +1505,10 @@ mod tests { use super::{ super::{ migrations, - schema::{agent_turns, llm_usage_event_stream_facts, llm_usage_events}, + schema::{agent_sessions, agent_turns, llm_usage_event_stream_facts, llm_usage_events}, search, }, - ingest_events, raw_events, session_outline, usage_summary, + ingest_events, raw_events, session_outline, session_streams, usage_summary, }; const OWNER_ID: Uuid = Uuid::from_u128(1); @@ -1710,6 +1767,58 @@ mod tests { ); } + #[test] + fn session_streams_link_stored_exchanges() { + let mut database = database(); + let event = |id: &str, second: u8, event_type: &str, entries: serde_json::Value| { + let response = if second < 3 { "r1" } else { "r2" }; + json!({ + "event_id": id, + "observed_at": format!("2026-10-20T00:00:0{second}Z"), + "device": {"host_name": "local", "platform": "macos"}, + "agent": {"name": "codex"}, + "session_id": "streams-session", + "llm": {"provider": "openai", "model": "gpt-5"}, + "event_type": event_type, + "response_id": response, + "metadata": {"conversation": {"entries": entries, "completeness": "complete"}} + }) + }; + let request: IngestEventsRequest = serde_json::from_value(json!({"events": [ + event("a", 1, "request", json!([{"role": "user", "text": "fix the bug"}])), + event("ab", 2, "response", json!([ + {"role": "tool_call", "text": "{}", "call_id": "c1"} + ])), + event("abc", 3, "request", json!([ + {"role": "user", "text": "fix the bug"}, + {"role": "tool_call", "text": "{}", "call_id": "c1"}, + {"role": "tool_result", "text": "ok", "call_id": "c1"} + ])), + event("abcd", 4, "response", json!([{"role": "assistant", "text": "fixed"}])) + ]})) + .expect("test ingest request should deserialize"); + ingest_events(&mut database, &request, OWNER_ID, 100).expect("ingest should succeed"); + let session_pk = agent_sessions::table + .select(agent_sessions::id) + .first::(&mut database) + .expect("the session is stored"); + let session_pk = Uuid::parse_str(&session_pk).expect("a UUID"); + + let streams = session_streams(&mut database, OWNER_ID, session_pk) + .expect("the session's streams derive"); + let bases = streams + .exchanges + .iter() + .map(|exchange| (exchange.id.as_str(), exchange.base.as_deref())) + .collect::>(); + assert_eq!(bases, [("r1", None), ("r2", Some("r1"))]); + assert_eq!(streams.segments.len(), 1); + assert!( + session_streams(&mut database, OWNER_ID, Uuid::nil()).is_err(), + "a session without events is not found" + ); + } + #[test] fn event_list_leaves_out_content_but_keeps_what_a_list_shows() { let mut database = summary_seeded(); diff --git a/crates/abyss-backend/src/usage/streams.rs b/crates/abyss-backend/src/usage/streams.rs index 83891a2..572a94d 100644 --- a/crates/abyss-backend/src/usage/streams.rs +++ b/crates/abyss-backend/src/usage/streams.rs @@ -1,12 +1,19 @@ -//! Stream facts ingest reads from each event's own conversation entries. +//! Streams of one session: the facts ingest reads from each event, and the +//! exchanges, bases, and segments a query derives from them. //! //! A stream is one line of the model's context. Every request has at most one //! base, the exchange whose output its context continues. Ingest reads the //! roles, call ids, and item ids of one event's conversation entries, plus -//! whether a user entry has text, and never looks at another event, so a -//! query can find each base by key lookups within the session. +//! whether a user entry has text, and never looks at another event. A query +//! groups one session's events into exchanges and finds each base by key +//! lookups within the session, without comparing content. +use std::collections::HashMap; + +use chrono::{DateTime, Utc}; +use serde::Serialize; use serde_json::Value; +use uuid::Uuid; use super::conversation::{Conversation, Entry}; @@ -170,11 +177,426 @@ impl ResponseFacts { } } +#[derive(Clone, Debug, PartialEq, Eq)] +/// Stored stream facts of one event, by its side of the exchange. +pub enum EventFacts { + /// The event is a request. + Request(RequestFacts), + /// The event is a response. + Response(ResponseFacts), +} + +#[derive(Clone, Debug)] +/// One session event with the stored columns streams are derived from. +pub struct StreamEvent { + /// Event primary key. + pub pk: Uuid, + /// Collector-generated idempotency key. + pub event_id: String, + /// Collector observation time. + pub observed_at: DateTime, + /// Shared by the request and response events of one exchange. + pub response_id: Option, + /// Response the request names as the one it continues. + pub previous_response_id: Option, + /// Facts read at ingest; an event stored before they were read has the + /// default facts of its side. + pub facts: EventFacts, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +/// Rule that found a request's base. +pub enum BaseRule { + /// The request names a response of the session in `previous_response_id`. + Pointer, + /// The exchange that emitted the request's last identity said every + /// model-side entry that trails it. + LastOutput, + /// The request has no model output, and one exchange emitted every call + /// its tool results answer. + ResultsOnly, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +/// Why a request has no base. +pub enum NoBaseReason { + /// The last model output has no identity, or model output its emitter + /// did not say trails the last identity. + TextOnly, + /// The request has neither model output nor tool results. + NoOutput, + /// No other exchange of the session emitted the ids. + UnknownIds, + /// The ids come from more than one exchange. + MultipleResponses, + /// The exchange has no request event. + MissingRequest, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +/// Why a segment begins. +pub enum SegmentKind { + /// Its first exchange has no base. + Root, + /// Its first exchange continues a turn-opening request. + Turn, + /// Its first exchange continues a fork point; every branch is a new stream. + Branch, +} + +#[derive(Debug, Serialize)] +/// Exchanges and segments of one session, without any content. +pub struct SessionStreamsResponse { + /// Session primary key. + pub session_pk: Uuid, + /// Exchanges by request time. + pub exchanges: Vec, + /// Segments by their first exchange. + pub segments: Vec, +} + +#[derive(Debug, Serialize)] +/// One request and its response. +pub struct StreamExchange { + /// Shared `response_id`, or the `event_id` of an event without one. + pub id: String, + /// Request event primary key. + pub request_pk: Option, + /// Response event primary key. + pub response_pk: Option, + /// Exchange the request's context continues. + pub base: Option, + /// Rule that found the base. + pub rule: Option, + /// Why there is no base. + pub no_base_reason: Option, + /// Index of the request's entries where its new part begins. + pub new_from: Option, + /// Index of the exchange's segment. + pub segment: usize, +} + +#[derive(Debug, Serialize)] +/// A run of exchanges along one stream. +pub struct StreamSegment { + /// Why the segment begins. + pub kind: SegmentKind, + /// Segment of the first exchange's base. + pub parent: Option, + /// Exchange ids in order. + pub exchanges: Vec, +} + +/// One side of an exchange as streams use it. +struct Side { + pk: Uuid, + previous_response_id: Option, + facts: F, +} + +struct Exchange { + id: String, + response_id: Option, + /// Request time, else response time. + at: DateTime, + request: Option>, + response: Option>, +} + +impl Exchange { + /// Adds `event` unless the exchange already has an event of its side. + fn add(&mut self, event: StreamEvent) { + let (pk, previous_response_id) = (event.pk, event.previous_response_id); + match event.facts { + EventFacts::Request(facts) if self.request.is_none() => { + self.at = event.observed_at; + self.request = Some(Side { + pk, + previous_response_id, + facts, + }); + } + EventFacts::Response(facts) if self.response.is_none() => { + self.response = Some(Side { + pk, + previous_response_id, + facts, + }); + } + EventFacts::Request(_) | EventFacts::Response(_) => {} + } + } + + fn starts_turn(&self) -> bool { + self.request + .as_ref() + .is_some_and(|request| request.facts.starts_turn) + } +} + +/// Pairs events into exchanges by `response_id`, ordered by request time; +/// an event without a `response_id` is its own exchange. +fn group_exchanges(events: Vec) -> Vec { + let mut exchanges: Vec = Vec::new(); + let mut by_response_id: HashMap = HashMap::new(); + for event in events { + let known = event + .response_id + .as_ref() + .and_then(|response_id| by_response_id.get(response_id).copied()); + let index = known.unwrap_or_else(|| { + let id = event + .response_id + .clone() + .unwrap_or_else(|| event.event_id.clone()); + if let Some(response_id) = &event.response_id { + by_response_id.insert(response_id.clone(), exchanges.len()); + } + exchanges.push(Exchange { + id, + response_id: event.response_id.clone(), + at: event.observed_at, + request: None, + response: None, + }); + exchanges.len().saturating_sub(1) + }); + exchanges[index].add(event); + } + exchanges.sort_by_key(|exchange| exchange.at); + exchanges +} + +/// Exchanges whose response emitted each identity, in exchange order. +fn emitters(exchanges: &[Exchange]) -> HashMap<&str, Vec> { + let mut by_id: HashMap<&str, Vec> = HashMap::new(); + for (index, exchange) in exchanges.iter().enumerate() { + let Some(response) = &exchange.response else { + continue; + }; + for id in &response.facts.emitted_ids { + let list = by_id.entry(id.as_str()).or_default(); + if list.last() != Some(&index) { + list.push(index); + } + } + } + by_id +} + +/// The one exchange other than `own` that emitted any of `ids`. +fn only_emitter<'a>( + ids: impl IntoIterator, + own: usize, + by_id: &HashMap<&str, Vec>, +) -> Result { + let mut found = None; + for id in ids { + for &emitter in by_id.get(id).into_iter().flatten() { + if emitter == own || found == Some(emitter) { + continue; + } + if found.is_some() { + return Err(NoBaseReason::MultipleResponses); + } + found = Some(emitter); + } + } + found.ok_or(NoBaseReason::UnknownIds) +} + +/// Whether `output` said every trailing role after its entry keyed by +/// `last`, in order: a client may drop entries of an output, but entries it +/// did not say come from a later reply. +fn said_after(output: &[SkeletonEntry], last: &str, trailing: &[String]) -> bool { + let is_model_side = |role: &str| MODEL_ROLES.contains(&role); + let Some(held) = output + .iter() + .rposition(|(role, key)| is_model_side(role) && key.as_deref() == Some(last)) + else { + return false; + }; + let mut said = output[held.saturating_add(1)..] + .iter() + .map(|(role, _)| role) + .filter(|role| is_model_side(role)); + trailing + .iter() + .all(|role| said.any(|spoken| spoken == role)) +} + +/// The base of exchange `index` and the rule that found it: the response +/// its pointer names; else the one other exchange that emitted its last +/// identity, when that exchange said everything trailing it; else, without +/// model output, the one exchange that emitted the calls its tool results +/// answer. +fn find_base( + index: usize, + exchanges: &[Exchange], + by_response_id: &HashMap<&str, usize>, + by_id: &HashMap<&str, Vec>, +) -> Result<(usize, BaseRule), NoBaseReason> { + let request = exchanges[index] + .request + .as_ref() + .ok_or(NoBaseReason::MissingRequest)?; + if let Some(&base) = request + .previous_response_id + .as_deref() + .and_then(|response_id| by_response_id.get(response_id)) + { + return Ok((base, BaseRule::Pointer)); + } + let facts = &request.facts; + if let Some(last) = facts.last_identity.as_deref() { + let emitter = only_emitter([last], index, by_id)?; + let output = exchanges[emitter] + .response + .as_ref() + .map(|response| response.facts.output_skeleton.as_slice()) + .unwrap_or_default(); + return if said_after(output, last, &facts.trailing) { + Ok((emitter, BaseRule::LastOutput)) + } else { + Err(NoBaseReason::TextOnly) + }; + } + // The last run has model output without any identity. + if facts.new_from > 0_i32 { + return Err(NoBaseReason::TextOnly); + } + if facts.result_ids.is_empty() { + return Err(NoBaseReason::NoOutput); + } + only_emitter(facts.result_ids.iter().map(String::as_str), index, by_id) + .map(|base| (base, BaseRule::ResultsOnly)) +} + +/// Derives the exchanges and segments of one session from its events. +/// +/// `events` are the session's events in session order: observation time, +/// request before response, then primary key. +pub fn session_streams(session_pk: Uuid, events: Vec) -> SessionStreamsResponse { + let exchanges = group_exchanges(events); + let by_response_id = exchanges + .iter() + .enumerate() + .filter_map(|(index, exchange)| Some((exchange.response_id.as_deref()?, index))) + .collect::>(); + let by_id = emitters(&exchanges); + let bases = (0..exchanges.len()) + .map(|index| find_base(index, &exchanges, &by_response_id, &by_id)) + .collect::>(); + let segmentation = Segmentation::cut(&exchanges, &bases); + let segments = segmentation + .segments + .into_iter() + .map(|segment| StreamSegment { + kind: segment.kind, + parent: segment.parent, + exchanges: segment + .exchanges + .iter() + .map(|&exchange| exchanges[exchange].id.clone()) + .collect(), + }) + .collect(); + let exchanges = exchanges + .iter() + .zip(&bases) + .zip(&segmentation.segment_of) + .map(|((exchange, base), &segment)| StreamExchange { + id: exchange.id.clone(), + request_pk: exchange.request.as_ref().map(|request| request.pk), + response_pk: exchange.response.as_ref().map(|response| response.pk), + base: base.ok().map(|(base, _)| exchanges[base].id.clone()), + rule: base.ok().map(|(_, rule)| rule), + no_base_reason: base.err(), + new_from: exchange + .request + .as_ref() + .map(|request| request.facts.new_from), + segment, + }) + .collect(); + SessionStreamsResponse { + session_pk, + exchanges, + segments, + } +} + +struct Segment { + kind: SegmentKind, + parent: Option, + exchanges: Vec, +} + +struct Segmentation { + segments: Vec, + /// Segment of each exchange. + segment_of: Vec, +} + +impl Segmentation { + /// Walks the exchanges by request time. A segment begins at an exchange + /// without a base, at one whose base is a fork point, the base of more + /// than one exchange, and at one whose request opens a turn; any other + /// exchange joins its base's segment. + fn cut(exchanges: &[Exchange], bases: &[Result<(usize, BaseRule), NoBaseReason>]) -> Self { + let mut children = vec![0_usize; exchanges.len()]; + for &(base, _) in bases.iter().flatten() { + children[base] = children[base].saturating_add(1); + } + let mut segments: Vec = Vec::new(); + let mut placed: Vec> = vec![None; exchanges.len()]; + for (index, exchange) in exchanges.iter().enumerate() { + let base = bases[index].ok().map(|(base, _)| base); + let base_segment = base.and_then(|base| placed[base]).zip(base); + let segment = match base_segment { + Some((segment, base)) if children[base] <= 1 && !exchange.starts_turn() => segment, + Some((segment, base)) => { + let kind = if children[base] > 1 { + SegmentKind::Branch + } else { + SegmentKind::Turn + }; + Self::open(&mut segments, kind, Some(segment)) + } + None => Self::open(&mut segments, SegmentKind::Root, None), + }; + segments[segment].exchanges.push(index); + placed[index] = Some(segment); + } + Self { + segments, + segment_of: placed.into_iter().map(Option::unwrap_or_default).collect(), + } + } + + fn open(segments: &mut Vec, kind: SegmentKind, parent: Option) -> usize { + segments.push(Segment { + kind, + parent, + exchanges: Vec::new(), + }); + segments.len().saturating_sub(1) + } +} + #[cfg(test)] mod tests { + use chrono::{DateTime, TimeDelta, TimeZone as _, Utc}; use serde_json::{Value, json}; + use uuid::Uuid; - use super::{RequestFacts, ResponseFacts}; + use super::{ + BaseRule, EventFacts, NoBaseReason, RequestFacts, ResponseFacts, SegmentKind, + SessionStreamsResponse, StreamEvent, session_streams, + }; fn conversation(entries: &Value) -> Value { json!({"conversation": {"entries": entries, "completeness": "complete"}}) @@ -371,4 +793,267 @@ mod tests { "trailing roles keep reasoning and leave out tool results" ); } + + /// A session built from exchanges with conversation entries; exchange + /// `name` shares `name` as its response id. + struct Session { + start: DateTime, + events: Vec, + } + + impl Session { + fn new() -> Self { + Self { + start: Utc + .with_ymd_and_hms(2026, 10, 20, 0, 0, 0) + .single() + .expect("valid time"), + events: Vec::new(), + } + } + + fn add(&mut self, name: &str, millis: i64, previous: Option<&str>, facts: EventFacts) { + let at = self + .start + .checked_add_signed(TimeDelta::milliseconds(millis)) + .expect("test times stay in range"); + self.events.push(StreamEvent { + pk: Uuid::now_v7(), + event_id: format!("{name}.{millis}"), + observed_at: at, + response_id: Some(name.to_owned()), + previous_response_id: previous.map(str::to_owned), + facts, + }); + } + + /// Adds the request of exchange `name` at `second`. + fn ask(&mut self, name: &str, second: i64, previous: Option<&str>, entries: &Value) { + let facts = EventFacts::Request(RequestFacts::read(&conversation(entries))); + self.add(name, second.saturating_mul(1_000), previous, facts); + } + + /// Adds the response of exchange `name`, half a second after `second`. + fn answer(&mut self, name: &str, second: i64, entries: &Value) { + let facts = EventFacts::Response(ResponseFacts::read(&conversation(entries))); + let millis = second.saturating_mul(1_000).saturating_add(500); + self.add(name, millis, None, facts); + } + + fn exchange(&mut self, name: &str, second: i64, request: &Value, response: &Value) { + self.ask(name, second, None, request); + self.answer(name, second, response); + } + + fn streams(&self) -> SessionStreamsResponse { + session_streams(Uuid::nil(), self.events.clone()) + } + + /// The base and rule of exchange `name`, or why it has none. + fn base(&self, name: &str) -> Result<(String, BaseRule), NoBaseReason> { + let streams = self.streams(); + let exchange = streams + .exchanges + .iter() + .find(|exchange| exchange.id == name) + .expect("the exchange exists"); + match (&exchange.base, exchange.rule, exchange.no_base_reason) { + (Some(base), Some(rule), None) => Ok((base.clone(), rule)), + (None, None, Some(reason)) => Err(reason), + other => panic!("inconsistent base of {name}: {other:?}"), + } + } + } + + fn linked(base: &str) -> (String, BaseRule) { + (base.to_owned(), BaseRule::LastOutput) + } + + #[test] + fn a_request_continues_the_emitter_of_its_last_identity_unless_more_trails_it() { + // The examples of STREAM-DESIGN "base 怎么判定": the outputs of the + // earlier replies p1, p2, …, then what the next request carries back. + let cases = [ + ( + "1: the call's result and new input do not count", + vec![json!([assistant("look"), call("c1")])], + json!([ + user("go"), + assistant("look"), + call("c1"), + result("c1"), + user("more") + ]), + Ok(linked("p1")), + ), + ( + "2: rewritten text", + vec![json!([assistant("check the log"), call("c1")])], + json!([ + user("go"), + assistant("see the log"), + call("c1"), + result("c1") + ]), + Ok(linked("p1")), + ), + ( + "3: a dropped entry", + vec![json!([assistant("look"), call("c1")])], + json!([user("go"), call("c1"), result("c1")]), + Ok(linked("p1")), + ), + ( + "4: dropped thinking", + vec![json!([reasoning(), call("c1")])], + json!([user("go"), call("c1"), result("c1")]), + Ok(linked("p1")), + ), + ( + "5: text p1 did not say", + vec![json!([call("c1")])], + json!([user("go"), call("c1"), result("c1"), assistant("fixed")]), + Err(NoBaseReason::TextOnly), + ), + ( + "6: earlier calls of the run come from earlier replies", + vec![json!([call("a")]), json!([call("b")])], + json!([user("go"), call("a"), result("a"), call("b"), result("b")]), + Ok(linked("p2")), + ), + ( + "7: a dropped server tool result", + vec![json!([call("s1"), result("s1"), assistant("found")])], + json!([user("go"), call("s1"), assistant("found")]), + Ok(linked("p1")), + ), + ( + "8: text aligned on its item id", + vec![json!([assistant("look"), item(assistant("so"), "msg_2")])], + json!([user("go"), item(assistant("so"), "msg_2"), user("more")]), + Ok(linked("p1")), + ), + ( + "9: dropped text after the call", + vec![json!([call("c1"), assistant("done")])], + json!([user("go"), call("c1"), result("c1")]), + Ok(linked("p1")), + ), + ( + "thinking p1 did not say", + vec![json!([call("c1"), assistant("done")])], + json!([ + user("go"), + call("c1"), + result("c1"), + reasoning(), + assistant("done") + ]), + Err(NoBaseReason::TextOnly), + ), + ( + "more text than p1 said", + vec![json!([call("c1"), assistant("done")])], + json!([ + user("go"), + call("c1"), + assistant("done"), + assistant("again") + ]), + Err(NoBaseReason::TextOnly), + ), + ]; + for (case, outputs, request, expected) in cases { + let mut session = Session::new(); + let mut second = 0_i64; + for output in &outputs { + second = second.saturating_add(1); + session.exchange(&format!("p{second}"), second, &json!([user("go")]), output); + } + session.ask("next", second.saturating_add(1), None, &request); + assert_eq!(session.base("next"), expected, "{case}"); + } + } + + #[test] + fn pointers_and_tool_results_find_a_base_and_requests_without_one_say_why() { + let mut session = Session::new(); + session.exchange("p", 1, &json!([user("go")]), &json!([call("a"), call("b")])); + session.exchange("twin", 2, &json!([user("go")]), &json!([call("d")])); + session.exchange("other", 3, &json!([user("go")]), &json!([call("d")])); + session.ask("pointer", 4, Some("p"), &json!([user("next")])); + session.ask("results", 5, None, &json!([result("a"), result("b")])); + session.ask( + "plain_text", + 6, + None, + &json!([user("hi"), assistant("hey"), user("so")]), + ); + session.ask("nothing", 7, None, &json!([user("hi")])); + session.ask( + "unknown", + 8, + None, + &json!([user("go"), call("zz"), result("zz")]), + ); + session.ask("ambiguous", 9, None, &json!([result("d")])); + session.answer("lonely", 10, &json!([assistant("hi")])); + for (name, expected) in [ + ("pointer", Ok(("p".to_owned(), BaseRule::Pointer))), + ("results", Ok(("p".to_owned(), BaseRule::ResultsOnly))), + ("plain_text", Err(NoBaseReason::TextOnly)), + ("nothing", Err(NoBaseReason::NoOutput)), + ("unknown", Err(NoBaseReason::UnknownIds)), + ("ambiguous", Err(NoBaseReason::MultipleResponses)), + ("lonely", Err(NoBaseReason::MissingRequest)), + ] { + assert_eq!(session.base(name), expected, "{name}"); + } + } + + #[test] + fn segments_open_at_roots_turns_and_branches() { + let mut session = Session::new(); + let asked = json!([user("fix it")]); + let done = json!([user("fix it"), call("a"), result("a")]); + let answered = json!([ + user("fix it"), + call("a"), + result("a"), + item(assistant("done"), "msg_1"), + user("next") + ]); + let mut branch = answered.as_array().cloned().expect("an entry list"); + branch.extend([call("b"), result("b")]); + session.exchange("e1", 1, &asked, &json!([call("a")])); + session.exchange("e2", 2, &done, &json!([item(assistant("done"), "msg_1")])); + session.exchange("e3", 3, &answered, &json!([call("b")])); + session.exchange("e4", 4, &json!(branch), &json!([assistant("one")])); + session.exchange("e5", 5, &json!(branch), &json!([assistant("two")])); + session.exchange( + "e6", + 6, + &json!([user("unrelated")]), + &json!([assistant("ok")]), + ); + + let streams = session.streams(); + let segments = streams + .segments + .iter() + .map(|segment| (segment.kind, segment.parent, segment.exchanges.join(","))) + .collect::>(); + assert_eq!( + segments, + [ + (SegmentKind::Root, None, "e1,e2".to_owned()), + (SegmentKind::Turn, Some(0), "e3".to_owned()), + (SegmentKind::Branch, Some(1), "e4".to_owned()), + (SegmentKind::Branch, Some(1), "e5".to_owned()), + (SegmentKind::Root, None, "e6".to_owned()), + ] + ); + let e3 = &streams.exchanges[2]; + assert_eq!((e3.segment, e3.new_from), (1, Some(4_i32))); + } } From 2af3b5b347b846692fa34109379eb87d6e111b37 Mon Sep 17 00:00:00 2001 From: Auceptin Date: Wed, 21 Oct 2026 01:21:06 -0700 Subject: [PATCH 04/25] feat(usage): serve the session page in layers The session page reads one layer per request, by session_pk: - GET /sessions/{session_pk}/header: agents, time span, event, exchange, turn and segment counts, token totals, providers and models. - GET /sessions/{session_pk}/streams: segments only, each identified by its first exchange (head), with its kind, parent head, the exchange it forks from, time span and exchange count. - GET /sessions/{session_pk}/segments/{head}: one page of a segment's exchanges from head on (limit, after), each with its base, rule or reason, new part, turn start, times, model, tokens, and a preview of its request and response. - GET /events/{event_pk}/conversation: an event's entries through the same window as a preview (entries runs, text_preview). Previews give the asked runs of entries as reported, cut texts carrying their full UTF-16 length, and count what was left out. streams.rs splits into facts, base, segmentation and test fixtures; the code moves without change. Co-Authored-By: Claude Opus 5.5 --- crates/abyss-backend/src/api.rs | 64 +- crates/abyss-backend/src/storage/mod.rs | 34 +- crates/abyss-backend/src/storage/postgres.rs | 45 + .../src/storage/postgres/repository.rs | 149 +- .../abyss-backend/src/storage/sqlite/mod.rs | 45 + .../src/storage/sqlite/repository.rs | 259 +++- crates/abyss-backend/src/usage/mod.rs | 8 +- crates/abyss-backend/src/usage/preview.rs | 273 ++++ crates/abyss-backend/src/usage/segments.rs | 169 +++ .../abyss-backend/src/usage/session_header.rs | 92 ++ crates/abyss-backend/src/usage/streams.rs | 1205 ++++------------- .../abyss-backend/src/usage/streams/base.rs | 390 ++++++ .../abyss-backend/src/usage/streams/facts.rs | 327 +++++ .../src/usage/streams/fixtures.rs | 123 ++ .../src/usage/streams/segmentation.rs | 79 ++ 15 files changed, 2324 insertions(+), 938 deletions(-) create mode 100644 crates/abyss-backend/src/usage/preview.rs create mode 100644 crates/abyss-backend/src/usage/segments.rs create mode 100644 crates/abyss-backend/src/usage/session_header.rs create mode 100644 crates/abyss-backend/src/usage/streams/base.rs create mode 100644 crates/abyss-backend/src/usage/streams/facts.rs create mode 100644 crates/abyss-backend/src/usage/streams/fixtures.rs create mode 100644 crates/abyss-backend/src/usage/streams/segmentation.rs diff --git a/crates/abyss-backend/src/api.rs b/crates/abyss-backend/src/api.rs index a2b21c0..b080c47 100644 --- a/crates/abyss-backend/src/api.rs +++ b/crates/abyss-backend/src/api.rs @@ -28,6 +28,9 @@ use crate::{ IngestEventsRequest, IngestEventsResponse, RawEventListResponse, RawEventsQuery, SessionOutlineResponse, SessionTimelineResponse, SummaryFields, SummaryQuery, TokenUsageSummaryResponse, UsageEventResponse, + preview::{ConversationPreview, PreviewQuery, Window}, + segments::{SegmentQuery, SegmentResponse}, + session_header::SessionHeaderResponse, session_list::{SessionListQuery, SessionListResponse}, streams::SessionStreamsResponse, trajectory::{self, SessionTrajectoryResponse}, @@ -88,10 +91,22 @@ pub fn router(state: AppState) -> Router { "/v1/agent-usage/sessions/{session_pk}/trajectory", get(session_trajectory), ) + .route( + "/v1/agent-usage/sessions/{session_pk}/header", + get(session_header), + ) .route( "/v1/agent-usage/sessions/{session_pk}/streams", get(session_streams), ) + .route( + "/v1/agent-usage/sessions/{session_pk}/segments/{head}", + get(session_segment), + ) + .route( + "/v1/agent-usage/events/{event_pk}/conversation", + get(event_conversation), + ) .layer(TraceLayer::new_for_http()) .with_state(state) } @@ -204,7 +219,7 @@ async fn session_trajectory( Ok(Json(trajectory::build(timeline))) } -/// Exchanges and segments of one session, without content. +/// Segments of one session, without content. async fn session_streams( State(state): State, headers: HeaderMap, @@ -216,6 +231,53 @@ async fn session_streams( )) } +/// Agents, time span, counts, and token totals of one session. +async fn session_header( + State(state): State, + headers: HeaderMap, + Path(session_pk): Path, +) -> Result, AppError> { + let user_id = state.identity.authenticate(&headers)?; + Ok(Json( + state.storage.session_header(user_id, session_pk).await?, + )) +} + +/// One page of a segment's exchanges, from exchange `head` on, with a +/// preview of each request and response. +async fn session_segment( + State(state): State, + headers: HeaderMap, + Path((session_pk, head)): Path<(Uuid, String)>, + Query(query): Query, +) -> Result, AppError> { + let user_id = state.identity.authenticate(&headers)?; + let query = query.validate()?; + Ok(Json( + state + .storage + .session_segment(user_id, session_pk, head, query) + .await?, + )) +} + +/// The conversation of one event, all of it or the asked entries. +async fn event_conversation( + State(state): State, + headers: HeaderMap, + Path(event_pk): Path, + Query(query): Query, +) -> Result, AppError> { + let user_id = state.identity.authenticate(&headers)?; + let window = Window::parse(&query)?; + Ok(Json( + state + .storage + .event_conversation(user_id, event_pk, window) + .await?, + )) +} + async fn usage_event( State(state): State, headers: HeaderMap, diff --git a/crates/abyss-backend/src/storage/mod.rs b/crates/abyss-backend/src/storage/mod.rs index bdb8ead..2735ba3 100644 --- a/crates/abyss-backend/src/storage/mod.rs +++ b/crates/abyss-backend/src/storage/mod.rs @@ -23,6 +23,9 @@ use crate::{ SessionOutlineResponse, SessionTimelineResponse, SummaryQuery, SummaryResponse, UsageEventResponse, attachments::StoredImageAttachment, + preview::{ConversationPreview, Window}, + segments::{SegmentResponse, ValidatedSegmentQuery}, + session_header::SessionHeaderResponse, session_list::{SessionListResponse, ValidatedSessionListQuery}, streams::SessionStreamsResponse, }, @@ -81,14 +84,41 @@ pub trait StorageBackend: Send + Sync { session_pk: Uuid, ) -> BoxedFuture<'_, Result>; - /// Derives the exchanges and segments of an owner-scoped session; not - /// found when the owner has no event in that session. + /// The header of an owner-scoped session; not found when the owner has + /// no event in it. + fn session_header( + &self, + user_id: Uuid, + session_pk: Uuid, + ) -> BoxedFuture<'_, Result>; + + /// Derives the segments of an owner-scoped session; not found when the + /// owner has no event in it. fn session_streams( &self, user_id: Uuid, session_pk: Uuid, ) -> BoxedFuture<'_, Result>; + /// One page of the exchanges of the segment holding exchange `head` in + /// an owner-scoped session, from `head` on; not found when the session + /// has no such exchange. + fn session_segment( + &self, + user_id: Uuid, + session_pk: Uuid, + head: String, + query: ValidatedSegmentQuery, + ) -> BoxedFuture<'_, Result>; + + /// The conversation of one owner-scoped event through `window`. + fn event_conversation( + &self, + user_id: Uuid, + event_pk: Uuid, + window: Window, + ) -> BoxedFuture<'_, Result>; + /// Lists one page of owner-scoped sessions, most recently active first. fn list_sessions( &self, diff --git a/crates/abyss-backend/src/storage/postgres.rs b/crates/abyss-backend/src/storage/postgres.rs index 043d39c..3b38a0b 100644 --- a/crates/abyss-backend/src/storage/postgres.rs +++ b/crates/abyss-backend/src/storage/postgres.rs @@ -22,6 +22,9 @@ use crate::{ SessionOutlineResponse, SessionTimelineResponse, SummaryQuery, SummaryResponse, UsageEventResponse, attachments::StoredImageAttachment, + preview::{ConversationPreview, Window}, + segments::{SegmentResponse, ValidatedSegmentQuery}, + session_header::SessionHeaderResponse, session_list::{SessionListResponse, ValidatedSessionListQuery}, streams::SessionStreamsResponse, }, @@ -170,6 +173,19 @@ impl StorageBackend for PostgresEsBackend { }) } + fn session_header( + &self, + user_id: Uuid, + session_pk: Uuid, + ) -> BoxedFuture<'_, Result> { + Box::pin(async move { + self.run_db(move |connection| { + repository::session_header(connection, user_id, session_pk) + }) + .await + }) + } + fn session_streams( &self, user_id: Uuid, @@ -183,6 +199,35 @@ impl StorageBackend for PostgresEsBackend { }) } + fn session_segment( + &self, + user_id: Uuid, + session_pk: Uuid, + head: String, + query: ValidatedSegmentQuery, + ) -> BoxedFuture<'_, Result> { + Box::pin(async move { + self.run_db(move |connection| { + repository::session_segment(connection, user_id, session_pk, &head, &query) + }) + .await + }) + } + + fn event_conversation( + &self, + user_id: Uuid, + event_pk: Uuid, + window: Window, + ) -> BoxedFuture<'_, Result> { + Box::pin(async move { + self.run_db(move |connection| { + repository::event_conversation(connection, user_id, event_pk, &window) + }) + .await + }) + } + fn list_sessions( &self, user_id: Uuid, diff --git a/crates/abyss-backend/src/storage/postgres/repository.rs b/crates/abyss-backend/src/storage/postgres/repository.rs index da9c8c1..74265d9 100644 --- a/crates/abyss-backend/src/storage/postgres/repository.rs +++ b/crates/abyss-backend/src/storage/postgres/repository.rs @@ -56,9 +56,14 @@ use crate::{ normalize_limit, normalize_text, parse_group_by, sha256_bytes, unix_epoch, validate_batch, validate_event_type, }, + preview::{ConversationPreview, Window, preview}, + segments::{ + SegmentResponse, TokenCounts, ValidatedSegmentQuery, page_event_pks, segment_response, + }, + session_header::{HeaderEvent, SessionHeaderResponse, session_header as header_of}, streams::{ EventFacts, RequestFacts, ResponseFacts, SessionStreamsResponse, StreamEvent, - session_streams as streams_of, + segment_page, session_streams as streams_of, stream_counts, }, }, }; @@ -682,6 +687,148 @@ pub fn session_streams( Ok(streams_of(session_pk, events)) } +/// Columns of one session event the header reads. +type HeaderColumns = ( + String, + String, + Option, + String, + String, + DateTime, + i64, + i64, + i64, + i64, + i64, + i64, +); + +/// The header of an owner-scoped session. +pub fn session_header( + connection: &mut PgConnection, + user_id: Uuid, + session_pk: Uuid, +) -> Result { + let events = llm_usage_events::table + .filter(llm_usage_events::user_id.eq(user_id)) + .filter(llm_usage_events::session_pk.eq(session_pk)) + .select(( + llm_usage_events::session_id, + llm_usage_events::agent_name, + llm_usage_events::agent_version, + llm_usage_events::llm_provider, + llm_usage_events::llm_model, + llm_usage_events::observed_at, + llm_usage_events::input_tokens, + llm_usage_events::output_tokens, + llm_usage_events::cache_read_tokens, + llm_usage_events::cache_write_tokens, + llm_usage_events::reasoning_tokens, + llm_usage_events::total_tokens, + )) + .load::(connection)? + .into_iter() + .map(header_event) + .collect::>(); + if events.is_empty() { + return Err(AppError::not_found(format!( + "session not found: {session_pk}" + ))); + } + let counts = stream_counts(stream_events(connection, user_id, session_pk)?); + Ok(header_of(session_pk, events, counts)) +} + +fn header_event( + ( + session_id, + agent_name, + agent_version, + provider, + model, + observed_at, + input, + output, + cache_read, + cache_write, + reasoning, + total, + ): HeaderColumns, +) -> HeaderEvent { + HeaderEvent { + session_id, + agent_name, + agent_version, + provider, + model, + observed_at, + tokens: TokenCounts { + input, + output, + cache_read, + cache_write, + reasoning, + total, + }, + } +} + +/// One page of the exchanges of the segment holding exchange `head`, with a +/// preview of each event. +pub fn session_segment( + connection: &mut PgConnection, + user_id: Uuid, + session_pk: Uuid, + head: &str, + query: &ValidatedSegmentQuery, +) -> Result { + let events = stream_events(connection, user_id, session_pk)?; + let page = + segment_page(events, head, query.after.as_deref(), query.limit).ok_or_else(|| { + AppError::not_found(format!( + "exchange not found in session {session_pk}: {head}" + )) + })?; + let events = usage_events(connection, user_id, &page_event_pks(&page))?; + Ok(segment_response(session_pk, page, events, &query.window)) +} + +/// The conversation of one owner-scoped event through `window`. +pub fn event_conversation( + connection: &mut PgConnection, + user_id: Uuid, + event_pk: Uuid, + window: &Window, +) -> Result { + usage_event(connection, user_id, event_pk).map(|event| preview(event, window)) +} + +/// Owner-scoped events by primary key, with their device labels and +/// attachments. +fn usage_events( + connection: &mut PgConnection, + user_id: Uuid, + pks: &[Uuid], +) -> Result, AppError> { + let events = llm_usage_events::table + .filter(llm_usage_events::id.eq_any(pks)) + .filter(llm_usage_events::user_id.eq(user_id)) + .load::(connection)?; + let devices = load_device_contexts_for_events(connection, &events)?; + let mut attachments = load_attachments_for_events(connection, &events)?; + Ok(events + .into_iter() + .map(|event| { + let (event_pk, device_id) = (event.id, event.device_context_id); + let attachments = attachments.remove(&event_pk).unwrap_or_default(); + ( + event_pk, + event_response(event, devices.get(&device_id), attachments), + ) + }) + .collect()) +} + /// The events of one session in session order, with their stream facts and /// without any request body. fn stream_events( diff --git a/crates/abyss-backend/src/storage/sqlite/mod.rs b/crates/abyss-backend/src/storage/sqlite/mod.rs index 00e4294..6674fa4 100644 --- a/crates/abyss-backend/src/storage/sqlite/mod.rs +++ b/crates/abyss-backend/src/storage/sqlite/mod.rs @@ -22,6 +22,9 @@ use crate::{ SessionOutlineResponse, SessionTimelineResponse, SummaryQuery, SummaryResponse, UsageEventResponse, attachments::StoredImageAttachment, + preview::{ConversationPreview, Window}, + segments::{SegmentResponse, ValidatedSegmentQuery}, + session_header::SessionHeaderResponse, session_list::{SessionListResponse, ValidatedSessionListQuery}, streams::SessionStreamsResponse, }, @@ -154,6 +157,19 @@ impl StorageBackend for SqliteFtsBackend { }) } + fn session_header( + &self, + user_id: Uuid, + session_pk: Uuid, + ) -> BoxedFuture<'_, Result> { + Box::pin(async move { + self.run_db(move |connection| { + repository::session_header(connection, user_id, session_pk) + }) + .await + }) + } + fn session_streams( &self, user_id: Uuid, @@ -167,6 +183,35 @@ impl StorageBackend for SqliteFtsBackend { }) } + fn session_segment( + &self, + user_id: Uuid, + session_pk: Uuid, + head: String, + query: ValidatedSegmentQuery, + ) -> BoxedFuture<'_, Result> { + Box::pin(async move { + self.run_db(move |connection| { + repository::session_segment(connection, user_id, session_pk, &head, &query) + }) + .await + }) + } + + fn event_conversation( + &self, + user_id: Uuid, + event_pk: Uuid, + window: Window, + ) -> BoxedFuture<'_, Result> { + Box::pin(async move { + self.run_db(move |connection| { + repository::event_conversation(connection, user_id, event_pk, &window) + }) + .await + }) + } + fn list_sessions( &self, user_id: Uuid, diff --git a/crates/abyss-backend/src/storage/sqlite/repository.rs b/crates/abyss-backend/src/storage/sqlite/repository.rs index 8fc8c01..ca9035c 100644 --- a/crates/abyss-backend/src/storage/sqlite/repository.rs +++ b/crates/abyss-backend/src/storage/sqlite/repository.rs @@ -31,9 +31,14 @@ use crate::{ exact_values_filter, non_empty, normalize_limit, normalize_text, parse_group_by, sha256_bytes, validate_batch, validate_event_type, }, + preview::{ConversationPreview, Window, preview}, + segments::{ + SegmentResponse, TokenCounts, ValidatedSegmentQuery, page_event_pks, segment_response, + }, + session_header::{HeaderEvent, SessionHeaderResponse, session_header as header_of}, streams::{ - RequestFacts, ResponseFacts, SessionStreamsResponse, StreamEvent, - session_streams as streams_of, + RequestFacts, ResponseFacts, SessionStreamsResponse, StreamEvent, segment_page, + session_streams as streams_of, stream_counts, }, }, }; @@ -534,6 +539,152 @@ pub(super) fn session_streams( Ok(streams_of(session_pk, events)) } +/// Columns of one session event the header reads. +type HeaderColumns = ( + String, + String, + Option, + String, + String, + i64, + i64, + i64, + i64, + i64, + i64, + i64, +); + +/// The header of an owner-scoped session. +pub(super) fn session_header( + connection: &mut SqliteConnection, + user_id: Uuid, + session_pk: Uuid, +) -> Result { + let events = llm_usage_events::table + .filter(llm_usage_events::user_id.eq(user_id.to_string())) + .filter(llm_usage_events::session_pk.eq(session_pk.to_string())) + .select(( + llm_usage_events::session_id, + llm_usage_events::agent_name, + llm_usage_events::agent_version, + llm_usage_events::llm_provider, + llm_usage_events::llm_model, + llm_usage_events::observed_at, + llm_usage_events::input_tokens, + llm_usage_events::output_tokens, + llm_usage_events::cache_read_tokens, + llm_usage_events::cache_write_tokens, + llm_usage_events::reasoning_tokens, + llm_usage_events::total_tokens, + )) + .load::(connection)? + .into_iter() + .map(header_event) + .collect::, AppError>>()?; + if events.is_empty() { + return Err(AppError::not_found(format!( + "session not found: {session_pk}" + ))); + } + let counts = stream_counts(stream_events(connection, user_id, session_pk)?); + Ok(header_of(session_pk, events, counts)) +} + +fn header_event( + ( + session_id, + agent_name, + agent_version, + provider, + model, + observed_at, + input, + output, + cache_read, + cache_write, + reasoning, + total, + ): HeaderColumns, +) -> Result { + Ok(HeaderEvent { + session_id, + agent_name, + agent_version, + provider, + model, + observed_at: timestamp_from_micros(observed_at)?, + tokens: TokenCounts { + input, + output, + cache_read, + cache_write, + reasoning, + total, + }, + }) +} + +/// One page of the exchanges of the segment holding exchange `head`, with a +/// preview of each event. +pub(super) fn session_segment( + connection: &mut SqliteConnection, + user_id: Uuid, + session_pk: Uuid, + head: &str, + query: &ValidatedSegmentQuery, +) -> Result { + let events = stream_events(connection, user_id, session_pk)?; + let page = + segment_page(events, head, query.after.as_deref(), query.limit).ok_or_else(|| { + AppError::not_found(format!( + "exchange not found in session {session_pk}: {head}" + )) + })?; + let events = usage_events(connection, user_id, &page_event_pks(&page))?; + Ok(segment_response(session_pk, page, events, &query.window)) +} + +/// The conversation of one owner-scoped event through `window`. +pub(super) fn event_conversation( + connection: &mut SqliteConnection, + user_id: Uuid, + event_pk: Uuid, + window: &Window, +) -> Result { + usage_event(connection, user_id, event_pk).map(|event| preview(event, window)) +} + +/// Owner-scoped events by primary key, with their device labels and +/// attachments. +fn usage_events( + connection: &mut SqliteConnection, + user_id: Uuid, + pks: &[Uuid], +) -> Result, AppError> { + let events = llm_usage_events::table + .filter(llm_usage_events::id.eq_any(pks.iter().map(Uuid::to_string))) + .filter(llm_usage_events::user_id.eq(user_id.to_string())) + .select(EventRow::as_select()) + .load::(connection)? + .into_iter() + .map(EventRow::into_record) + .collect::, _>>()?; + let devices = load_device_contexts(connection, &events)?; + let mut attachments = load_attachments(connection, &events)?; + Ok(events + .into_iter() + .map(|event| { + let (event_pk, device_id) = (event.id, event.device_context_id); + let attachments = attachments.remove(&event_pk).unwrap_or_default(); + ( + event_pk, + event_response(event, devices.get(&device_id), attachments), + ) + }) + .collect()) +} + /// The events of one session in session order, with their stream facts and /// without any request body. fn stream_events( @@ -1499,7 +1650,11 @@ mod tests { use crate::{ search::SessionSearchQuery, - usage::{IngestEventsRequest, RawEventsQuery, SummaryQuery}, + usage::{ + IngestEventsRequest, RawEventsQuery, SummaryQuery, + preview::{PreviewQuery, Window}, + segments::SegmentQuery, + }, }; use super::{ @@ -1508,7 +1663,8 @@ mod tests { schema::{agent_sessions, agent_turns, llm_usage_event_stream_facts, llm_usage_events}, search, }, - ingest_events, raw_events, session_outline, session_streams, usage_summary, + event_conversation, ingest_events, raw_events, session_header, session_outline, + session_segment, session_streams, usage_summary, }; const OWNER_ID: Uuid = Uuid::from_u128(1); @@ -1767,8 +1923,9 @@ mod tests { ); } - #[test] - fn session_streams_link_stored_exchanges() { + /// A database holding session `streams-session`: exchange r1 asks and + /// calls c1, exchange r2 sends its result back and gets an answer. + fn streams_database() -> (SqliteConnection, Uuid) { let mut database = database(); let event = |id: &str, second: u8, event_type: &str, entries: serde_json::Value| { let response = if second < 3 { "r1" } else { "r2" }; @@ -1803,22 +1960,102 @@ mod tests { .first::(&mut database) .expect("the session is stored"); let session_pk = Uuid::parse_str(&session_pk).expect("a UUID"); + (database, session_pk) + } + #[test] + fn session_streams_link_stored_exchanges() { + let (mut database, session_pk) = streams_database(); let streams = session_streams(&mut database, OWNER_ID, session_pk) .expect("the session's streams derive"); - let bases = streams - .exchanges + let segments = streams + .segments .iter() - .map(|exchange| (exchange.id.as_str(), exchange.base.as_deref())) + .map(|segment| (segment.head.as_str(), segment.exchange_count)) .collect::>(); - assert_eq!(bases, [("r1", None), ("r2", Some("r1"))]); - assert_eq!(streams.segments.len(), 1); + assert_eq!(segments, [("r1", 2)], "r2 continues r1 by its call"); assert!( session_streams(&mut database, OWNER_ID, Uuid::nil()).is_err(), "a session without events is not found" ); } + #[test] + fn segments_and_conversations_preview_stored_events() { + let (mut database, session_pk) = streams_database(); + let query = SegmentQuery { + entries: Some("0".to_owned()), + text_preview: Some(3), + ..SegmentQuery::default() + } + .validate() + .expect("a valid query"); + let segment = session_segment(&mut database, OWNER_ID, session_pk, "r1", &query) + .expect("the segment pages"); + assert_eq!( + (segment.segment.as_str(), segment.next.as_deref()), + ("r1", None) + ); + let r2 = &segment.exchanges[1]; + assert_eq!( + (r2.exchange.base.as_deref(), r2.model.as_deref()), + (Some("r1"), Some("gpt-5")) + ); + let request = r2.request.as_ref().expect("a request preview"); + assert_eq!( + ( + request.entries_total, + request.omitted_entries, + request.chunks.len() + ), + (3_usize, 2_usize, 1_usize) + ); + assert_eq!( + request.chunks[0].entries[0], + json!({"role": "user", "text": "fix", "text_length": 11_usize}) + ); + assert!(session_segment(&mut database, OWNER_ID, session_pk, "zz", &query).is_err()); + + let window = Window::parse(&PreviewQuery { + entries: Some("-1".to_owned()), + text_preview: None, + }) + .expect("a valid window"); + let conversation = event_conversation(&mut database, OWNER_ID, request.event_pk, &window) + .expect("the event's conversation"); + assert_eq!( + ( + conversation.chunks[0].from, + conversation.chunks[0].entries[0]["role"].clone() + ), + (2, json!("tool_result")) + ); + } + + #[test] + fn the_session_header_sums_its_events() { + let (mut database, session_pk) = streams_database(); + let header = session_header(&mut database, OWNER_ID, session_pk).expect("the header"); + assert_eq!( + ( + header.event_count, + header.exchange_count, + header.turn_count, + header.segment_count + ), + (4, 2, 1, 1) + ); + assert_eq!( + (header.agents[0].name.as_str(), header.models.as_slice()), + ("codex", ["gpt-5".to_owned()].as_slice()) + ); + assert_eq!( + header.end.signed_duration_since(header.start), + chrono::TimeDelta::seconds(3) + ); + assert!(session_header(&mut database, OWNER_ID, Uuid::nil()).is_err()); + } + #[test] fn event_list_leaves_out_content_but_keeps_what_a_list_shows() { let mut database = summary_seeded(); diff --git a/crates/abyss-backend/src/usage/mod.rs b/crates/abyss-backend/src/usage/mod.rs index f25714f..8cd0cf0 100644 --- a/crates/abyss-backend/src/usage/mod.rs +++ b/crates/abyss-backend/src/usage/mod.rs @@ -12,10 +12,16 @@ mod conversation; pub mod diagnostics; pub mod event_order; pub mod persistence; +/// Previews of an event's conversation entries. +pub mod preview; +/// One segment's exchanges for the session page. +pub mod segments; +/// The header of the session page. +pub mod session_header; /// Owner-scoped session index contracts. pub mod session_list; pub mod session_title; -/// Stream facts read from each event at ingest. +/// Streams of a session: stream facts, bases, and segments. pub mod streams; pub mod trajectory; diff --git a/crates/abyss-backend/src/usage/preview.rs b/crates/abyss-backend/src/usage/preview.rs new file mode 100644 index 0000000..1d6fa28 --- /dev/null +++ b/crates/abyss-backend/src/usage/preview.rs @@ -0,0 +1,273 @@ +//! Previews of one event's conversation: the chosen runs of its entries, each +//! text cut to a length, with what was left out counted. +//! +//! Entry indexes are those of the event's full entry list, so a position found +//! elsewhere, such as a search hit, points into a preview unchanged. + +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use uuid::Uuid; + +use super::{UsageEventResponse, attachments::UsageEventAttachmentResponse}; +use crate::error::AppError; + +#[derive(Debug, Default, Deserialize)] +/// Which entries of a conversation to give, and how much of each text. +pub struct PreviewQuery { + /// Comma-separated runs of entries: `a-b` from index `a` to `b`, `-n` the + /// last `n`, `a` the one at `a`. All entries without it. + pub entries: Option, + /// Most UTF-16 code units of each entry text. Whole texts without it. + pub text_preview: Option, +} + +/// One run of entries as a query asks for it. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Run { + /// Entries `from` through `to`. + Span { from: usize, to: usize }, + /// The last `count` entries. + Last { count: usize }, +} + +#[derive(Clone, Debug, Default, PartialEq, Eq)] +/// A validated [`PreviewQuery`]. +pub struct Window { + runs: Option>, + text_preview: Option, +} + +impl Window { + /// Validates a query; a malformed run is a validation error. + pub fn parse(query: &PreviewQuery) -> Result { + let runs = query + .entries + .as_deref() + .map(|entries| entries.split(',').map(str::trim).map(parse_run).collect()) + .transpose()?; + Ok(Self { + runs, + text_preview: query.text_preview, + }) + } + + /// The runs over `total` entries as sorted, disjoint index ranges. + fn ranges(&self, total: usize) -> Vec> { + let Some(runs) = &self.runs else { + return std::iter::once(0..total).collect(); + }; + let mut ranges = runs + .iter() + .map(|run| match *run { + Run::Span { from, to } => from.min(total)..to.saturating_add(1).min(total), + Run::Last { count } => total.saturating_sub(count)..total, + }) + .filter(|range| !range.is_empty()) + .collect::>(); + ranges.sort_by_key(|range| range.start); + let mut merged: Vec> = Vec::new(); + for range in ranges { + match merged.last_mut() { + Some(last) if range.start <= last.end => last.end = last.end.max(range.end), + _ => merged.push(range), + } + } + merged + } +} + +fn parse_run(run: &str) -> Result { + let invalid = || AppError::validation(format!("invalid entries run: {run:?}")); + let index = |text: &str| text.parse::().map_err(|_| invalid()); + if let Some(count) = run.strip_prefix('-') { + return Ok(Run::Last { + count: index(count)?, + }); + } + let (from, to) = if let Some((from, to)) = run.split_once('-') { + (index(from)?, index(to)?) + } else { + let at = index(run)?; + (at, at) + }; + if from > to { + return Err(invalid()); + } + Ok(Run::Span { from, to }) +} + +#[derive(Debug, Serialize)] +/// The chosen part of one event's conversation. +pub struct ConversationPreview { + /// Event primary key. + pub event_pk: Uuid, + /// Completeness the collector reported for the conversation. + pub completeness: Option, + /// Number of entries in the full conversation. + pub entries_total: usize, + /// The chosen runs in order. + pub chunks: Vec, + /// Number of entries no chunk holds. + pub omitted_entries: usize, + /// UTF-8 bytes of the texts of those entries. + pub omitted_bytes: usize, + /// Image attachments of the event. + pub attachments: Vec, +} + +#[derive(Debug, Serialize)] +/// One run of consecutive entries. +pub struct Chunk { + /// Index of the first entry in the full conversation. + pub from: usize, + /// The entries as the collector reported them. A text cut to the + /// preview length carries `text_length`, its full length in UTF-16 code + /// units. + pub entries: Vec, +} + +/// Previews the conversation of `event` through `window`. +pub fn preview(event: UsageEventResponse, window: &Window) -> ConversationPreview { + let conversation = event.metadata.get("conversation"); + let entries = conversation + .and_then(|conversation| conversation.get("entries")) + .and_then(Value::as_array) + .map(Vec::as_slice) + .unwrap_or_default(); + let ranges = window.ranges(entries.len()); + let shown = ranges.iter().map(std::ops::Range::len).sum::(); + let omitted_bytes = entries + .iter() + .enumerate() + .filter(|(index, _)| !ranges.iter().any(|range| range.contains(index))) + .map(|(_, entry)| text_of(entry).len()) + .sum(); + let completeness = conversation + .and_then(|conversation| conversation.get("completeness")) + .and_then(Value::as_str) + .map(str::to_owned); + let chunks = ranges + .into_iter() + .map(|range| Chunk { + from: range.start, + entries: entries[range] + .iter() + .map(|entry| cut(entry, window.text_preview)) + .collect(), + }) + .collect(); + let entries_total = entries.len(); + ConversationPreview { + event_pk: event.id, + completeness, + entries_total, + chunks, + omitted_entries: entries_total.saturating_sub(shown), + omitted_bytes, + attachments: event.attachments, + } +} + +fn text_of(entry: &Value) -> &str { + entry + .get("text") + .and_then(Value::as_str) + .unwrap_or_default() +} + +/// `entry` with its text cut to `limit` UTF-16 code units, never inside a +/// character. +fn cut(entry: &Value, limit: Option) -> Value { + let mut entry = entry.clone(); + let Some(limit) = limit else { + return entry; + }; + let text = text_of(&entry); + let length = text.encode_utf16().count(); + if length <= limit { + return entry; + } + let mut units = 0_usize; + let end = text + .char_indices() + .find(|(_, character)| { + units = units.saturating_add(character.len_utf16()); + units > limit + }) + .map_or(text.len(), |(at, _)| at); + let kept = text[..end].to_owned(); + if let Some(object) = entry.as_object_mut() { + object.insert("text".to_owned(), Value::String(kept)); + object.insert("text_length".to_owned(), Value::from(length)); + } + entry +} + +#[cfg(test)] +mod tests { + use serde_json::{Value, json}; + + use super::{PreviewQuery, Window, cut}; + + fn window(entries: Option<&str>, text_preview: Option) -> Window { + Window::parse(&PreviewQuery { + entries: entries.map(str::to_owned), + text_preview, + }) + .expect("a valid window") + } + + #[test] + fn runs_resolve_to_sorted_disjoint_ranges_within_the_entries() { + /// Entries run, entry count, and the expected `(start, end)` ranges. + type Case<'a> = (Option<&'a str>, usize, &'a [(usize, usize)]); + let cases: [Case<'_>; 6] = [ + (None, 4, &[(0, 4)]), + (Some("0-2,-5"), 10, &[(0, 3), (5, 10)]), + (Some("0-2,-5"), 6, &[(0, 6)]), + (Some("-2,3"), 10, &[(3, 4), (8, 10)]), + (Some("0-99"), 3, &[(0, 3)]), + (Some("7"), 3, &[]), + ]; + for (entries, total, expected) in cases { + let ranges = window(entries, None) + .ranges(total) + .into_iter() + .map(|range| (range.start, range.end)) + .collect::>(); + assert_eq!(ranges, expected, "{entries:?} of {total}"); + } + for invalid in ["", "a", "3-1", "1-", "-"] { + assert!( + Window::parse(&PreviewQuery { + entries: Some(invalid.to_owned()), + text_preview: None, + }) + .is_err(), + "{invalid:?}" + ); + } + } + + #[test] + fn long_texts_keep_their_start_and_full_length() { + let entry = json!({"role": "user", "text": "ab😀cd"}); + assert_eq!(cut(&entry, None), entry); + assert_eq!(cut(&entry, Some(6)), entry, "six UTF-16 code units fit"); + let cut_at = |limit| { + let cut = cut(&entry, Some(limit)); + (cut["text"].clone(), cut["text_length"].clone()) + }; + assert_eq!( + cut_at(3), + (json!("ab"), json!(6_usize)), + "never half a character" + ); + assert_eq!(cut_at(4), (json!("ab😀"), json!(6_usize))); + assert_eq!( + cut(&json!({"role": "user"}), Some(1)), + json!({"role": "user"}) + ); + assert_eq!(cut(&Value::Null, Some(1)), Value::Null); + } +} diff --git a/crates/abyss-backend/src/usage/segments.rs b/crates/abyss-backend/src/usage/segments.rs new file mode 100644 index 0000000..ba6db7a --- /dev/null +++ b/crates/abyss-backend/src/usage/segments.rs @@ -0,0 +1,169 @@ +//! The exchange layer of a session page: one segment's exchanges with their +//! times, models, token counts, and a preview of each request and response. + +use std::collections::HashMap; + +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use super::{ + UsageEventResponse, + preview::{ConversationPreview, PreviewQuery, Window, preview}, + streams::{SegmentExchange, SegmentPage}, +}; +use crate::error::AppError; + +/// Exchanges a segment page holds without `limit`. +const DEFAULT_LIMIT: usize = 50; +/// Most exchanges one segment page holds. +const MAX_LIMIT: usize = 200; + +#[derive(Debug, Default, Deserialize)] +/// Query of a segment page. +pub struct SegmentQuery { + /// Runs of entries each preview gives; see [`PreviewQuery`]. + pub entries: Option, + /// Most UTF-16 code units of each previewed entry text. + pub text_preview: Option, + /// Most exchanges in the page. + pub limit: Option, + /// Exchange the page continues after. + pub after: Option, +} + +#[derive(Debug)] +/// A validated [`SegmentQuery`]. +pub struct ValidatedSegmentQuery { + pub window: Window, + pub limit: usize, + pub after: Option, +} + +impl SegmentQuery { + pub fn validate(self) -> Result { + let limit = self.limit.unwrap_or(DEFAULT_LIMIT); + if limit == 0 || limit > MAX_LIMIT { + return Err(AppError::validation(format!( + "limit must be between 1 and {MAX_LIMIT}" + ))); + } + Ok(ValidatedSegmentQuery { + window: Window::parse(&PreviewQuery { + entries: self.entries, + text_preview: self.text_preview, + })?, + limit, + after: self.after, + }) + } +} + +#[derive(Debug, Serialize)] +/// One segment's exchanges from the asked one on. +pub struct SegmentResponse { + /// Session primary key. + pub session_pk: Uuid, + /// Head of the segment that now holds the asked exchange. + pub segment: String, + /// The exchanges in order. + pub exchanges: Vec, + /// Exchange to pass as `after` for the next page, while there is one. + pub next: Option, +} + +#[derive(Debug, Serialize)] +/// One exchange with what its card shows. +pub struct SegmentExchangeResponse { + #[serde(flatten)] + pub exchange: SegmentExchange, + /// Observation time of the request event. + pub requested_at: Option>, + /// Observation time of the response event. + pub responded_at: Option>, + /// Model of the request, else of the response. + pub model: Option, + pub request_tokens: Option, + pub response_tokens: Option, + pub request: Option, + pub response: Option, +} + +#[derive(Debug, Default, PartialEq, Eq, Serialize)] +/// Token counts of one event as stored, or a sum of them. +pub struct TokenCounts { + pub input: i64, + pub output: i64, + pub cache_read: i64, + pub cache_write: i64, + pub reasoning: i64, + pub total: i64, +} + +impl TokenCounts { + /// Adds `other` to these counts. + pub const fn add(&mut self, other: &Self) { + self.input = self.input.saturating_add(other.input); + self.output = self.output.saturating_add(other.output); + self.cache_read = self.cache_read.saturating_add(other.cache_read); + self.cache_write = self.cache_write.saturating_add(other.cache_write); + self.reasoning = self.reasoning.saturating_add(other.reasoning); + self.total = self.total.saturating_add(other.total); + } + + const fn of(event: &UsageEventResponse) -> Self { + Self { + input: event.input_tokens, + output: event.output_tokens, + cache_read: event.cache_read_tokens, + cache_write: event.cache_write_tokens, + reasoning: event.reasoning_tokens, + total: event.total_tokens, + } + } +} + +/// The primary keys of the events a page's exchanges hold. +pub fn page_event_pks(page: &SegmentPage) -> Vec { + page.exchanges + .iter() + .flat_map(|exchange| [exchange.request_pk, exchange.response_pk]) + .flatten() + .collect() +} + +/// Fills a page with its events, keyed by primary key. +pub fn segment_response( + session_pk: Uuid, + page: SegmentPage, + mut events: HashMap, + window: &Window, +) -> SegmentResponse { + let exchanges = page + .exchanges + .into_iter() + .map(|exchange| { + let request = exchange.request_pk.and_then(|pk| events.remove(&pk)); + let response = exchange.response_pk.and_then(|pk| events.remove(&pk)); + SegmentExchangeResponse { + requested_at: request.as_ref().map(|event| event.observed_at), + responded_at: response.as_ref().map(|event| event.observed_at), + model: request + .as_ref() + .or(response.as_ref()) + .map(|event| event.llm_model.clone()), + request_tokens: request.as_ref().map(TokenCounts::of), + response_tokens: response.as_ref().map(TokenCounts::of), + request: request.map(|event| preview(event, window)), + response: response.map(|event| preview(event, window)), + exchange, + } + }) + .collect(); + SegmentResponse { + session_pk, + segment: page.segment, + exchanges, + next: page.next, + } +} diff --git a/crates/abyss-backend/src/usage/session_header.rs b/crates/abyss-backend/src/usage/session_header.rs new file mode 100644 index 0000000..d26dda2 --- /dev/null +++ b/crates/abyss-backend/src/usage/session_header.rs @@ -0,0 +1,92 @@ +//! The header of a session page: who took part, when, and how much. + +use std::collections::BTreeSet; + +use chrono::{DateTime, Utc}; +use serde::Serialize; +use uuid::Uuid; + +use super::{segments::TokenCounts, streams::StreamCounts}; + +/// The columns of one session event the header reads. +pub struct HeaderEvent { + pub session_id: String, + pub agent_name: String, + pub agent_version: Option, + pub provider: String, + pub model: String, + pub observed_at: DateTime, + pub tokens: TokenCounts, +} + +#[derive(Debug, Serialize)] +/// One session's agents, time span, counts, token totals, and models. +pub struct SessionHeaderResponse { + /// Session primary key. + pub session_pk: Uuid, + /// Session id the agent declared. + pub session_id: String, + /// Agents that reported events, by name and version. + pub agents: Vec, + /// Earliest event time. + pub start: DateTime, + /// Latest event time. + pub end: DateTime, + pub event_count: usize, + pub exchange_count: usize, + /// Requests that open a turn. + pub turn_count: usize, + pub segment_count: usize, + /// Token counts summed over every event. + pub tokens: TokenCounts, + pub providers: Vec, + pub models: Vec, +} + +#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Serialize)] +pub struct HeaderAgent { + pub name: String, + pub version: Option, +} + +/// The header of a session with at least one event. +pub fn session_header( + session_pk: Uuid, + events: Vec, + counts: StreamCounts, +) -> SessionHeaderResponse { + let start = events.iter().map(|event| event.observed_at).min(); + let end = events.iter().map(|event| event.observed_at).max(); + let event_count = events.len(); + let session_id = events + .first() + .map(|event| event.session_id.clone()) + .unwrap_or_default(); + let mut agents = BTreeSet::new(); + let mut providers = BTreeSet::new(); + let mut models = BTreeSet::new(); + let mut tokens = TokenCounts::default(); + for event in events { + tokens.add(&event.tokens); + agents.insert(HeaderAgent { + name: event.agent_name, + version: event.agent_version, + }); + providers.insert(event.provider); + models.insert(event.model); + } + SessionHeaderResponse { + session_pk, + session_id, + agents: agents.into_iter().collect(), + start: start.unwrap_or_default(), + end: end.unwrap_or_default(), + event_count, + exchange_count: counts.exchanges, + turn_count: counts.turns, + segment_count: counts.segments, + tokens, + providers: providers.into_iter().collect(), + models: models.into_iter().collect(), + } +} diff --git a/crates/abyss-backend/src/usage/streams.rs b/crates/abyss-backend/src/usage/streams.rs index 572a94d..81fec98 100644 --- a/crates/abyss-backend/src/usage/streams.rs +++ b/crates/abyss-backend/src/usage/streams.rs @@ -8,174 +8,27 @@ //! groups one session's events into exchanges and finds each base by key //! lookups within the session, without comparing content. +mod base; +mod facts; +#[cfg(test)] +mod fixtures; +mod segmentation; + use std::collections::HashMap; use chrono::{DateTime, Utc}; use serde::Serialize; -use serde_json::Value; use uuid::Uuid; -use super::conversation::{Conversation, Entry}; - -/// Roles of the entries the model produced. -const MODEL_ROLES: [&str; 3] = ["assistant", "reasoning", "tool_call"]; - -/// Prefix that keeps item ids apart from call ids among entry identities. -const ITEM_PREFIX: &str = "item:"; - -impl Entry { - fn is_model_side(&self) -> bool { - MODEL_ROLES.contains(&self.role.as_str()) - } - - /// The item id of a model-side entry, with [`ITEM_PREFIX`]. - fn item(&self) -> Option { - self.item_id - .as_deref() - .filter(|_| self.is_model_side()) - .map(|item_id| format!("{ITEM_PREFIX}{item_id}")) - } - - /// The provider-made ids of the entry: its item, then the call id of a - /// `tool_call` entry. - fn identities(&self) -> impl Iterator { - let call = self.call_id.clone().filter(|_| self.role == "tool_call"); - self.item().into_iter().chain(call) - } - - /// The entry's key in a skeleton: its call id, else its item. - fn key(&self) -> Option { - self.call_id.clone().or_else(|| self.item()) - } -} - -/// The entries of the event's conversation; none when it is not completely -/// decoded. -fn conversation_entries(metadata: &Value) -> Vec { - Conversation::read(metadata) - .map(|conversation| conversation.entries) - .unwrap_or_default() -} - -/// One skeleton entry: an entry's role and key, its call id, else its -/// `item:`-prefixed item id. -pub type SkeletonEntry = (String, Option); - -/// The `(role, key)` of each entry in order. -fn skeleton(entries: &[Entry]) -> Vec { - entries - .iter() - .map(|entry| (entry.role.clone(), entry.key())) - .collect() -} - -/// Call ids of the `tool_result` entries, in order. -fn result_ids(entries: &[Entry]) -> Vec { - entries - .iter() - .filter(|entry| entry.role == "tool_result") - .filter_map(|entry| entry.call_id.clone()) - .collect() -} - -/// Identities of the entries, in order. -fn identities(entries: &[Entry]) -> Vec { - entries.iter().flat_map(Entry::identities).collect() -} - -#[derive(Clone, Debug, Default, PartialEq, Eq)] -/// Facts of one request event, read from its conversation entries alone. -pub struct RequestFacts { - /// Index after the last model-side entry, or 0 without one; the entries - /// from here on are the request's new part. - pub new_from: i32, - /// The last identity in the last run, the run reaching from the last - /// model-side entry back through model-side and `tool_result` entries to - /// any other role or the start: the call id of a `tool_call` entry, else - /// the `item:`-prefixed item id of a model-side entry. - pub last_identity: Option, - /// Roles of the model-side entries after the one holding - /// `last_identity`, in order; none of them has an identity. Empty without - /// `last_identity`. - pub trailing: Vec, - /// Without model-side entries, the call ids of every `tool_result` - /// entry; otherwise empty. - pub result_ids: Vec, - /// Whether the new part holds a `user` entry with text and no - /// `tool_result` entry. - pub starts_turn: bool, -} - -impl RequestFacts { - /// Reads the facts of a request event from its metadata. - /// core logic of connect exchanges. - pub fn read(metadata: &Value) -> Self { - let entries = conversation_entries(metadata); - let Some(last) = entries.iter().rposition(Entry::is_model_side) else { - return Self { - new_from: 0, - last_identity: None, - trailing: Vec::new(), - result_ids: result_ids(&entries), - starts_turn: starts_turn(&entries), - }; - }; - let start = entries[..last] - .iter() - .rposition(|entry| !entry.is_model_side() && entry.role != "tool_result") - .map_or(0, |other| other.saturating_add(1)); - let run = &entries[start..=last]; - let held = run - .iter() - .rposition(|entry| entry.identities().next().is_some()); - let last_identity = held.and_then(|held| run[held].identities().last()); - let trailing = held.map_or_else(Vec::new, |held| { - run[held.saturating_add(1)..] - .iter() - .filter(|entry| entry.is_model_side()) - .map(|entry| entry.role.clone()) - .collect() - }); - let new_from = last.saturating_add(1); - Self { - new_from: i32::try_from(new_from).unwrap_or(i32::MAX), - last_identity, - trailing, - result_ids: Vec::new(), - starts_turn: starts_turn(&entries[new_from..]), - } - } -} - -/// Whether a new part opens a turn: it has user text and no tool result. -fn starts_turn(new_part: &[Entry]) -> bool { - new_part - .iter() - .any(|entry| entry.role == "user" && !entry.text.is_empty()) - && !new_part.iter().any(|entry| entry.role == "tool_result") -} - -#[derive(Clone, Debug, Default, PartialEq, Eq)] -/// Facts of one response event, read from its conversation entries alone. -pub struct ResponseFacts { - /// Identities of every entry, in order: for each entry, the - /// `item:`-prefixed item id of a model-side entry, then the call id of a - /// `tool_call` entry. - pub emitted_ids: Vec, - /// Skeleton of every entry. - pub output_skeleton: Vec, -} - -impl ResponseFacts { - /// Reads the facts of a response event from its metadata. - pub fn read(metadata: &Value) -> Self { - let entries = conversation_entries(metadata); - Self { - emitted_ids: identities(&entries), - output_skeleton: skeleton(&entries), - } - } -} +pub use self::{ + base::{BaseRule, NoBaseReason}, + facts::{RequestFacts, ResponseFacts}, + segmentation::SegmentKind, +}; +use self::{ + base::{Exchange, emitters, find_base, group_exchanges}, + segmentation::{Segment, Segmentation}, +}; #[derive(Clone, Debug, PartialEq, Eq)] /// Stored stream facts of one event, by its side of the exchange. @@ -204,812 +57,245 @@ pub struct StreamEvent { pub facts: EventFacts, } -#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)] -#[serde(rename_all = "snake_case")] -/// Rule that found a request's base. -pub enum BaseRule { - /// The request names a response of the session in `previous_response_id`. - Pointer, - /// The exchange that emitted the request's last identity said every - /// model-side entry that trails it. - LastOutput, - /// The request has no model output, and one exchange emitted every call - /// its tool results answer. - ResultsOnly, -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)] -#[serde(rename_all = "snake_case")] -/// Why a request has no base. -pub enum NoBaseReason { - /// The last model output has no identity, or model output its emitter - /// did not say trails the last identity. - TextOnly, - /// The request has neither model output nor tool results. - NoOutput, - /// No other exchange of the session emitted the ids. - UnknownIds, - /// The ids come from more than one exchange. - MultipleResponses, - /// The exchange has no request event. - MissingRequest, -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)] -#[serde(rename_all = "snake_case")] -/// Why a segment begins. -pub enum SegmentKind { - /// Its first exchange has no base. - Root, - /// Its first exchange continues a turn-opening request. - Turn, - /// Its first exchange continues a fork point; every branch is a new stream. - Branch, -} - #[derive(Debug, Serialize)] -/// Exchanges and segments of one session, without any content. +/// Segments of one session, without their exchanges or any content. pub struct SessionStreamsResponse { /// Session primary key. pub session_pk: Uuid, - /// Exchanges by request time. - pub exchanges: Vec, /// Segments by their first exchange. pub segments: Vec, } -#[derive(Debug, Serialize)] -/// One request and its response. -pub struct StreamExchange { - /// Shared `response_id`, or the `event_id` of an event without one. - pub id: String, - /// Request event primary key. - pub request_pk: Option, - /// Response event primary key. - pub response_pk: Option, - /// Exchange the request's context continues. - pub base: Option, - /// Rule that found the base. - pub rule: Option, - /// Why there is no base. - pub no_base_reason: Option, - /// Index of the request's entries where its new part begins. - pub new_from: Option, - /// Index of the exchange's segment. - pub segment: usize, -} - #[derive(Debug, Serialize)] /// A run of exchanges along one stream. pub struct StreamSegment { + /// Id of the segment's first exchange, which identifies the segment. + pub head: String, /// Why the segment begins. pub kind: SegmentKind, - /// Segment of the first exchange's base. - pub parent: Option, - /// Exchange ids in order. - pub exchanges: Vec, + /// Head of the segment holding the first exchange's base. + pub parent: Option, + /// Id of the first exchange's base. + pub forked_from: Option, + /// Earliest event time of the segment. + pub start: DateTime, + /// Latest event time of the segment. + pub end: DateTime, + /// Number of exchanges in the segment. + pub exchange_count: usize, } -/// One side of an exchange as streams use it. -struct Side { - pk: Uuid, - previous_response_id: Option, - facts: F, +/// Exchanges of one session, the base of each, and the segments they form. +struct Streams { + exchanges: Vec, + bases: Vec>, + segmentation: Segmentation, } -struct Exchange { - id: String, - response_id: Option, - /// Request time, else response time. - at: DateTime, - request: Option>, - response: Option>, -} - -impl Exchange { - /// Adds `event` unless the exchange already has an event of its side. - fn add(&mut self, event: StreamEvent) { - let (pk, previous_response_id) = (event.pk, event.previous_response_id); - match event.facts { - EventFacts::Request(facts) if self.request.is_none() => { - self.at = event.observed_at; - self.request = Some(Side { - pk, - previous_response_id, - facts, - }); - } - EventFacts::Response(facts) if self.response.is_none() => { - self.response = Some(Side { - pk, - previous_response_id, - facts, - }); - } - EventFacts::Request(_) | EventFacts::Response(_) => {} +impl Streams { + /// Derives the streams of one session from its events. + /// + /// `events` are the session's events in session order: observation time, + /// request before response, then primary key. + fn derive(events: Vec) -> Self { + let exchanges = group_exchanges(events); + let by_response_id = exchanges + .iter() + .enumerate() + .filter_map(|(index, exchange)| Some((exchange.response_id.as_deref()?, index))) + .collect::>(); + let by_id = emitters(&exchanges); + let bases = (0..exchanges.len()) + .map(|index| find_base(index, &exchanges, &by_response_id, &by_id)) + .collect::>(); + let segmentation = Segmentation::cut(&exchanges, &bases); + Self { + exchanges, + bases, + segmentation, } } - fn starts_turn(&self) -> bool { - self.request - .as_ref() - .is_some_and(|request| request.facts.starts_turn) + fn base(&self, exchange: usize) -> Option { + self.bases[exchange].ok().map(|(base, _)| base) } -} -/// Pairs events into exchanges by `response_id`, ordered by request time; -/// an event without a `response_id` is its own exchange. -fn group_exchanges(events: Vec) -> Vec { - let mut exchanges: Vec = Vec::new(); - let mut by_response_id: HashMap = HashMap::new(); - for event in events { - let known = event - .response_id - .as_ref() - .and_then(|response_id| by_response_id.get(response_id).copied()); - let index = known.unwrap_or_else(|| { - let id = event - .response_id + fn segment(&self, segment: &Segment) -> StreamSegment { + let id = |exchange: usize| self.exchanges[exchange].id.clone(); + let first = segment.exchanges.first().copied().unwrap_or_default(); + let members = segment + .exchanges + .iter() + .map(|&exchange| &self.exchanges[exchange]); + StreamSegment { + head: id(first), + kind: segment.kind, + parent: segment + .parent + .and_then(|parent| self.segmentation.segments[parent].exchanges.first()) + .map(|&head| id(head)), + forked_from: segment.parent.and_then(|_| self.base(first)).map(id), + start: members .clone() - .unwrap_or_else(|| event.event_id.clone()); - if let Some(response_id) = &event.response_id { - by_response_id.insert(response_id.clone(), exchanges.len()); - } - exchanges.push(Exchange { - id, - response_id: event.response_id.clone(), - at: event.observed_at, - request: None, - response: None, - }); - exchanges.len().saturating_sub(1) - }); - exchanges[index].add(event); - } - exchanges.sort_by_key(|exchange| exchange.at); - exchanges -} - -/// Exchanges whose response emitted each identity, in exchange order. -fn emitters(exchanges: &[Exchange]) -> HashMap<&str, Vec> { - let mut by_id: HashMap<&str, Vec> = HashMap::new(); - for (index, exchange) in exchanges.iter().enumerate() { - let Some(response) = &exchange.response else { - continue; - }; - for id in &response.facts.emitted_ids { - let list = by_id.entry(id.as_str()).or_default(); - if list.last() != Some(&index) { - list.push(index); - } - } - } - by_id -} - -/// The one exchange other than `own` that emitted any of `ids`. -fn only_emitter<'a>( - ids: impl IntoIterator, - own: usize, - by_id: &HashMap<&str, Vec>, -) -> Result { - let mut found = None; - for id in ids { - for &emitter in by_id.get(id).into_iter().flatten() { - if emitter == own || found == Some(emitter) { - continue; - } - if found.is_some() { - return Err(NoBaseReason::MultipleResponses); - } - found = Some(emitter); + .map(|exchange| exchange.at) + .min() + .unwrap_or_default(), + end: members + .map(|exchange| exchange.end) + .max() + .unwrap_or_default(), + exchange_count: segment.exchanges.len(), } } - found.ok_or(NoBaseReason::UnknownIds) -} - -/// Whether `output` said every trailing role after its entry keyed by -/// `last`, in order: a client may drop entries of an output, but entries it -/// did not say come from a later reply. -fn said_after(output: &[SkeletonEntry], last: &str, trailing: &[String]) -> bool { - let is_model_side = |role: &str| MODEL_ROLES.contains(&role); - let Some(held) = output - .iter() - .rposition(|(role, key)| is_model_side(role) && key.as_deref() == Some(last)) - else { - return false; - }; - let mut said = output[held.saturating_add(1)..] - .iter() - .map(|(role, _)| role) - .filter(|role| is_model_side(role)); - trailing - .iter() - .all(|role| said.any(|spoken| spoken == role)) -} - -/// The base of exchange `index` and the rule that found it: the response -/// its pointer names; else the one other exchange that emitted its last -/// identity, when that exchange said everything trailing it; else, without -/// model output, the one exchange that emitted the calls its tool results -/// answer. -fn find_base( - index: usize, - exchanges: &[Exchange], - by_response_id: &HashMap<&str, usize>, - by_id: &HashMap<&str, Vec>, -) -> Result<(usize, BaseRule), NoBaseReason> { - let request = exchanges[index] - .request - .as_ref() - .ok_or(NoBaseReason::MissingRequest)?; - if let Some(&base) = request - .previous_response_id - .as_deref() - .and_then(|response_id| by_response_id.get(response_id)) - { - return Ok((base, BaseRule::Pointer)); - } - let facts = &request.facts; - if let Some(last) = facts.last_identity.as_deref() { - let emitter = only_emitter([last], index, by_id)?; - let output = exchanges[emitter] - .response - .as_ref() - .map(|response| response.facts.output_skeleton.as_slice()) - .unwrap_or_default(); - return if said_after(output, last, &facts.trailing) { - Ok((emitter, BaseRule::LastOutput)) - } else { - Err(NoBaseReason::TextOnly) - }; - } - // The last run has model output without any identity. - if facts.new_from > 0_i32 { - return Err(NoBaseReason::TextOnly); - } - if facts.result_ids.is_empty() { - return Err(NoBaseReason::NoOutput); - } - only_emitter(facts.result_ids.iter().map(String::as_str), index, by_id) - .map(|base| (base, BaseRule::ResultsOnly)) } -/// Derives the exchanges and segments of one session from its events. +/// Derives the segments of one session from its events. /// /// `events` are the session's events in session order: observation time, /// request before response, then primary key. pub fn session_streams(session_pk: Uuid, events: Vec) -> SessionStreamsResponse { - let exchanges = group_exchanges(events); - let by_response_id = exchanges - .iter() - .enumerate() - .filter_map(|(index, exchange)| Some((exchange.response_id.as_deref()?, index))) - .collect::>(); - let by_id = emitters(&exchanges); - let bases = (0..exchanges.len()) - .map(|index| find_base(index, &exchanges, &by_response_id, &by_id)) - .collect::>(); - let segmentation = Segmentation::cut(&exchanges, &bases); - let segments = segmentation + let streams = Streams::derive(events); + let segments = streams + .segmentation .segments - .into_iter() - .map(|segment| StreamSegment { - kind: segment.kind, - parent: segment.parent, - exchanges: segment - .exchanges - .iter() - .map(|&exchange| exchanges[exchange].id.clone()) - .collect(), - }) - .collect(); - let exchanges = exchanges .iter() - .zip(&bases) - .zip(&segmentation.segment_of) - .map(|((exchange, base), &segment)| StreamExchange { - id: exchange.id.clone(), - request_pk: exchange.request.as_ref().map(|request| request.pk), - response_pk: exchange.response.as_ref().map(|response| response.pk), - base: base.ok().map(|(base, _)| exchanges[base].id.clone()), - rule: base.ok().map(|(_, rule)| rule), - no_base_reason: base.err(), - new_from: exchange - .request - .as_ref() - .map(|request| request.facts.new_from), - segment, - }) + .map(|segment| streams.segment(segment)) .collect(); SessionStreamsResponse { session_pk, - exchanges, segments, } } -struct Segment { - kind: SegmentKind, - parent: Option, - exchanges: Vec, +/// How many exchanges, turns, and segments one session's events form. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct StreamCounts { + pub exchanges: usize, + /// Requests that open a turn. + pub turns: usize, + pub segments: usize, } -struct Segmentation { - segments: Vec, - /// Segment of each exchange. - segment_of: Vec, -} - -impl Segmentation { - /// Walks the exchanges by request time. A segment begins at an exchange - /// without a base, at one whose base is a fork point, the base of more - /// than one exchange, and at one whose request opens a turn; any other - /// exchange joins its base's segment. - fn cut(exchanges: &[Exchange], bases: &[Result<(usize, BaseRule), NoBaseReason>]) -> Self { - let mut children = vec![0_usize; exchanges.len()]; - for &(base, _) in bases.iter().flatten() { - children[base] = children[base].saturating_add(1); - } - let mut segments: Vec = Vec::new(); - let mut placed: Vec> = vec![None; exchanges.len()]; - for (index, exchange) in exchanges.iter().enumerate() { - let base = bases[index].ok().map(|(base, _)| base); - let base_segment = base.and_then(|base| placed[base]).zip(base); - let segment = match base_segment { - Some((segment, base)) if children[base] <= 1 && !exchange.starts_turn() => segment, - Some((segment, base)) => { - let kind = if children[base] > 1 { - SegmentKind::Branch - } else { - SegmentKind::Turn - }; - Self::open(&mut segments, kind, Some(segment)) - } - None => Self::open(&mut segments, SegmentKind::Root, None), - }; - segments[segment].exchanges.push(index); - placed[index] = Some(segment); - } - Self { - segments, - segment_of: placed.into_iter().map(Option::unwrap_or_default).collect(), - } - } - - fn open(segments: &mut Vec, kind: SegmentKind, parent: Option) -> usize { - segments.push(Segment { - kind, - parent, - exchanges: Vec::new(), - }); - segments.len().saturating_sub(1) +/// Counts the exchanges, turns, and segments of one session's events. +pub fn stream_counts(events: Vec) -> StreamCounts { + let streams = Streams::derive(events); + StreamCounts { + exchanges: streams.exchanges.len(), + turns: streams + .exchanges + .iter() + .filter(|exchange| exchange.starts_turn()) + .count(), + segments: streams.segmentation.segments.len(), } } -#[cfg(test)] -mod tests { - use chrono::{DateTime, TimeDelta, TimeZone as _, Utc}; - use serde_json::{Value, json}; - use uuid::Uuid; - - use super::{ - BaseRule, EventFacts, NoBaseReason, RequestFacts, ResponseFacts, SegmentKind, - SessionStreamsResponse, StreamEvent, session_streams, - }; - - fn conversation(entries: &Value) -> Value { - json!({"conversation": {"entries": entries, "completeness": "complete"}}) - } - - fn user(text: &str) -> Value { - json!({"role": "user", "text": text}) - } - - fn system(text: &str) -> Value { - json!({"role": "system", "text": text}) - } - - fn assistant(text: &str) -> Value { - json!({"role": "assistant", "text": text}) - } - - fn reasoning() -> Value { - json!({"role": "reasoning", "text": ""}) - } - - fn call(id: &str) -> Value { - json!({"role": "tool_call", "text": "{}", "call_id": id, "name": "Bash"}) - } - - fn result(id: &str) -> Value { - json!({"role": "tool_result", "text": "ok", "call_id": id}) - } +#[derive(Debug, Serialize)] +/// One exchange of a segment, as the segment layer gives it. +pub struct SegmentExchange { + /// Shared `response_id`, or the `event_id` of an event without one. + pub id: String, + /// Request event primary key. + pub request_pk: Option, + /// Response event primary key. + pub response_pk: Option, + /// Exchange the request's context continues. + pub base: Option, + /// Head of the segment holding the base. + pub base_segment: Option, + /// Rule that found the base. + pub rule: Option, + /// Why there is no base. + pub no_base_reason: Option, + /// Index of the request's entries where its new part begins. + pub new_from: Option, + /// Whether the request opens a turn. + pub starts_turn: bool, +} - /// `entry` as part of the model output item `id`. - fn item(mut entry: Value, id: &str) -> Value { - entry["item_id"] = json!(id); - entry - } +/// Exchanges of one segment from a given exchange on. +pub struct SegmentPage { + /// Head of the segment that now holds the asked exchange. + pub segment: String, + /// The exchanges in order. + pub exchanges: Vec, + /// Exchange to continue after when the segment holds more. + pub next: Option, +} - fn skeleton(entries: &[(&str, Option<&str>)]) -> Vec<(String, Option)> { - entries +/// The exchanges of the segment holding exchange `from`, from it on, or +/// after `after` when given, at most `limit`. `None` when the session has no +/// such exchange, or `after` is not in that segment from `from` on. +pub fn segment_page( + events: Vec, + from: &str, + after: Option<&str>, + limit: usize, +) -> Option { + let streams = Streams::derive(events); + let id = |exchange: usize| streams.exchanges[exchange].id.clone(); + let from = streams + .exchanges + .iter() + .position(|exchange| exchange.id == from)?; + let segment = &streams.segmentation.segments[streams.segmentation.segment_of[from]]; + let mut rest = &segment.exchanges[segment + .exchanges + .iter() + .position(|&exchange| exchange == from)?..]; + if let Some(after) = after { + let at = rest .iter() - .map(|(role, id)| ((*role).to_owned(), id.map(str::to_owned))) - .collect() - } - - fn ids(values: &[&str]) -> Vec { - values.iter().map(|value| (*value).to_owned()).collect() + .position(|&exchange| streams.exchanges[exchange].id == after)?; + rest = &rest[at.saturating_add(1)..]; } + let shown = &rest[..limit.min(rest.len())]; + Some(SegmentPage { + segment: id(segment.exchanges.first().copied().unwrap_or_default()), + exchanges: shown + .iter() + .map(|&exchange| streams.exchange(exchange)) + .collect(), + next: (shown.len() < rest.len()) + .then(|| shown.last().copied().map(id)) + .flatten(), + }) +} - #[test] - fn request_facts_follow_the_last_run_and_the_new_part() { - // A tool loop resent with its history: the last run walks back - // through the client's tool result to the user entry. - let facts = RequestFacts::read(&conversation(&json!([ - system("rules"), - user("fix it"), - reasoning(), - call("a"), - result("a"), - reasoning(), - call("b"), - result("b") - ]))); - assert_eq!( - facts, - RequestFacts { - new_from: 7, - last_identity: Some("b".to_owned()), - trailing: Vec::new(), - result_ids: Vec::new(), - starts_turn: false, - } - ); - // New user text after the last answer opens a turn; the answer has no - // identity, so it trails the last call. - let facts = RequestFacts::read(&conversation(&json!([ - user("fix it"), - call("a"), - result("a"), - assistant("fixed"), - system(""), - user("next") - ]))); - assert_eq!(facts.new_from, 4_i32); - assert_eq!(facts.last_identity.as_deref(), Some("a")); - assert_eq!(facts.trailing, ["assistant"]); - assert!(facts.starts_turn); - assert!(facts.result_ids.is_empty()); - // Without model output, every entry is new and tool results answer - // calls that live elsewhere. - assert_eq!( - RequestFacts::read(&conversation(&json!([ - result("a"), - result("b"), - user("go") - ]))), - RequestFacts { - new_from: 0, - last_identity: None, - trailing: Vec::new(), - result_ids: ids(&["a", "b"]), - starts_turn: false, - } - ); - // A run without identities has nothing to align on and nothing - // trailing; the run stops at the user entry before it. - let facts = RequestFacts::read(&conversation(&json!([ - call("a"), - result("a"), - user("thanks"), - assistant("sure") - ]))); - assert_eq!((facts.last_identity, facts.trailing), (None, Vec::new())); - // Empty user text opens no turn; a request without a complete - // conversation has no facts. - assert!(!RequestFacts::read(&conversation(&json!([assistant("a"), user("")]))).starts_turn); - assert_eq!(RequestFacts::read(&json!({})), RequestFacts::default()); - assert_eq!( - RequestFacts::read(&json!({"conversation": { - "entries": [assistant("a"), user("next")], - "completeness": "partial" - }})), - RequestFacts::default() - ); - } - - #[test] - fn response_facts_list_emitted_calls_and_the_output_skeleton() { - assert_eq!( - ResponseFacts::read(&conversation(&json!([ - reasoning(), - assistant("let me look"), - call("a"), - call("b"), - {"role": "tool_result", "text": "found", "call_id": "s"} - ]))), - ResponseFacts { - emitted_ids: ids(&["a", "b"]), - output_skeleton: skeleton(&[ - ("reasoning", None), - ("assistant", None), - ("tool_call", Some("a")), - ("tool_call", Some("b")), - ("tool_result", Some("s")) - ]), - } - ); - assert_eq!( - ResponseFacts::read(&json!({"conversation": {}})), - ResponseFacts::default() - ); - } - - #[test] - fn identities_are_call_ids_and_prefixed_item_ids_of_model_output() { - // OpenAI Responses: every output item carries its id, and a function - // call its call id as well. - let output = [ - item(reasoning(), "rs_1"), - item(assistant("let me look"), "msg_1"), - item(call("a"), "fc_1"), - ]; - let facts = ResponseFacts::read(&conversation(&json!(output))); - assert_eq!( - facts.emitted_ids, - ids(&["item:rs_1", "item:msg_1", "item:fc_1", "a"]) - ); - assert_eq!( - facts.output_skeleton, - skeleton(&[ - ("reasoning", Some("item:rs_1")), - ("assistant", Some("item:msg_1")), - ("tool_call", Some("a")) - ]), - "a skeleton keys entries by call id, else by item id" - ); - // Entries of one item share its id; item ids of other roles are no - // identities; a call's last identity is its call id. - let mut request = vec![item(user("go"), "u_1")]; - request.extend(output.clone()); - request.extend([ - item(result("a"), "fco_1"), - item(assistant("done,"), "msg_2"), - item(assistant("really"), "msg_2"), - ]); - let facts = RequestFacts::read(&conversation(&json!(request))); - assert_eq!(facts.last_identity.as_deref(), Some("item:msg_2")); - assert!(facts.trailing.is_empty()); - let mut request = vec![user("go")]; - request.extend(output); - request.extend([result("a"), reasoning(), assistant("done")]); - let facts = RequestFacts::read(&conversation(&json!(request))); - assert_eq!(facts.last_identity.as_deref(), Some("a")); - assert_eq!( - facts.trailing, - ["reasoning", "assistant"], - "trailing roles keep reasoning and leave out tool results" - ); - } - - /// A session built from exchanges with conversation entries; exchange - /// `name` shares `name` as its response id. - struct Session { - start: DateTime, - events: Vec, - } - - impl Session { - fn new() -> Self { - Self { - start: Utc - .with_ymd_and_hms(2026, 10, 20, 0, 0, 0) - .single() - .expect("valid time"), - events: Vec::new(), - } - } - - fn add(&mut self, name: &str, millis: i64, previous: Option<&str>, facts: EventFacts) { - let at = self - .start - .checked_add_signed(TimeDelta::milliseconds(millis)) - .expect("test times stay in range"); - self.events.push(StreamEvent { - pk: Uuid::now_v7(), - event_id: format!("{name}.{millis}"), - observed_at: at, - response_id: Some(name.to_owned()), - previous_response_id: previous.map(str::to_owned), - facts, - }); - } - - /// Adds the request of exchange `name` at `second`. - fn ask(&mut self, name: &str, second: i64, previous: Option<&str>, entries: &Value) { - let facts = EventFacts::Request(RequestFacts::read(&conversation(entries))); - self.add(name, second.saturating_mul(1_000), previous, facts); - } - - /// Adds the response of exchange `name`, half a second after `second`. - fn answer(&mut self, name: &str, second: i64, entries: &Value) { - let facts = EventFacts::Response(ResponseFacts::read(&conversation(entries))); - let millis = second.saturating_mul(1_000).saturating_add(500); - self.add(name, millis, None, facts); - } - - fn exchange(&mut self, name: &str, second: i64, request: &Value, response: &Value) { - self.ask(name, second, None, request); - self.answer(name, second, response); - } - - fn streams(&self) -> SessionStreamsResponse { - session_streams(Uuid::nil(), self.events.clone()) - } - - /// The base and rule of exchange `name`, or why it has none. - fn base(&self, name: &str) -> Result<(String, BaseRule), NoBaseReason> { - let streams = self.streams(); - let exchange = streams +impl Streams { + fn exchange(&self, index: usize) -> SegmentExchange { + let exchange = &self.exchanges[index]; + let base = self.base(index); + let segment_head = |segment: usize| { + let head = self.segmentation.segments[segment] .exchanges - .iter() - .find(|exchange| exchange.id == name) - .expect("the exchange exists"); - match (&exchange.base, exchange.rule, exchange.no_base_reason) { - (Some(base), Some(rule), None) => Ok((base.clone(), rule)), - (None, None, Some(reason)) => Err(reason), - other => panic!("inconsistent base of {name}: {other:?}"), - } + .first() + .copied(); + head.map(|head| self.exchanges[head].id.clone()) + }; + let request = exchange.request.as_ref(); + SegmentExchange { + id: exchange.id.clone(), + request_pk: request.map(|request| request.pk), + response_pk: exchange.response.as_ref().map(|response| response.pk), + base: base.map(|base| self.exchanges[base].id.clone()), + base_segment: base.and_then(|base| segment_head(self.segmentation.segment_of[base])), + rule: self.bases[index].ok().map(|(_, rule)| rule), + no_base_reason: self.bases[index].err(), + new_from: request.map(|request| request.facts.new_from), + starts_turn: exchange.starts_turn(), } } +} - fn linked(base: &str) -> (String, BaseRule) { - (base.to_owned(), BaseRule::LastOutput) - } - - #[test] - fn a_request_continues_the_emitter_of_its_last_identity_unless_more_trails_it() { - // The examples of STREAM-DESIGN "base 怎么判定": the outputs of the - // earlier replies p1, p2, …, then what the next request carries back. - let cases = [ - ( - "1: the call's result and new input do not count", - vec![json!([assistant("look"), call("c1")])], - json!([ - user("go"), - assistant("look"), - call("c1"), - result("c1"), - user("more") - ]), - Ok(linked("p1")), - ), - ( - "2: rewritten text", - vec![json!([assistant("check the log"), call("c1")])], - json!([ - user("go"), - assistant("see the log"), - call("c1"), - result("c1") - ]), - Ok(linked("p1")), - ), - ( - "3: a dropped entry", - vec![json!([assistant("look"), call("c1")])], - json!([user("go"), call("c1"), result("c1")]), - Ok(linked("p1")), - ), - ( - "4: dropped thinking", - vec![json!([reasoning(), call("c1")])], - json!([user("go"), call("c1"), result("c1")]), - Ok(linked("p1")), - ), - ( - "5: text p1 did not say", - vec![json!([call("c1")])], - json!([user("go"), call("c1"), result("c1"), assistant("fixed")]), - Err(NoBaseReason::TextOnly), - ), - ( - "6: earlier calls of the run come from earlier replies", - vec![json!([call("a")]), json!([call("b")])], - json!([user("go"), call("a"), result("a"), call("b"), result("b")]), - Ok(linked("p2")), - ), - ( - "7: a dropped server tool result", - vec![json!([call("s1"), result("s1"), assistant("found")])], - json!([user("go"), call("s1"), assistant("found")]), - Ok(linked("p1")), - ), - ( - "8: text aligned on its item id", - vec![json!([assistant("look"), item(assistant("so"), "msg_2")])], - json!([user("go"), item(assistant("so"), "msg_2"), user("more")]), - Ok(linked("p1")), - ), - ( - "9: dropped text after the call", - vec![json!([call("c1"), assistant("done")])], - json!([user("go"), call("c1"), result("c1")]), - Ok(linked("p1")), - ), - ( - "thinking p1 did not say", - vec![json!([call("c1"), assistant("done")])], - json!([ - user("go"), - call("c1"), - result("c1"), - reasoning(), - assistant("done") - ]), - Err(NoBaseReason::TextOnly), - ), - ( - "more text than p1 said", - vec![json!([call("c1"), assistant("done")])], - json!([ - user("go"), - call("c1"), - assistant("done"), - assistant("again") - ]), - Err(NoBaseReason::TextOnly), - ), - ]; - for (case, outputs, request, expected) in cases { - let mut session = Session::new(); - let mut second = 0_i64; - for output in &outputs { - second = second.saturating_add(1); - session.exchange(&format!("p{second}"), second, &json!([user("go")]), output); - } - session.ask("next", second.saturating_add(1), None, &request); - assert_eq!(session.base("next"), expected, "{case}"); - } - } +#[cfg(test)] +mod tests { + use chrono::TimeDelta; + use serde_json::json; + use uuid::Uuid; - #[test] - fn pointers_and_tool_results_find_a_base_and_requests_without_one_say_why() { - let mut session = Session::new(); - session.exchange("p", 1, &json!([user("go")]), &json!([call("a"), call("b")])); - session.exchange("twin", 2, &json!([user("go")]), &json!([call("d")])); - session.exchange("other", 3, &json!([user("go")]), &json!([call("d")])); - session.ask("pointer", 4, Some("p"), &json!([user("next")])); - session.ask("results", 5, None, &json!([result("a"), result("b")])); - session.ask( - "plain_text", - 6, - None, - &json!([user("hi"), assistant("hey"), user("so")]), - ); - session.ask("nothing", 7, None, &json!([user("hi")])); - session.ask( - "unknown", - 8, - None, - &json!([user("go"), call("zz"), result("zz")]), - ); - session.ask("ambiguous", 9, None, &json!([result("d")])); - session.answer("lonely", 10, &json!([assistant("hi")])); - for (name, expected) in [ - ("pointer", Ok(("p".to_owned(), BaseRule::Pointer))), - ("results", Ok(("p".to_owned(), BaseRule::ResultsOnly))), - ("plain_text", Err(NoBaseReason::TextOnly)), - ("nothing", Err(NoBaseReason::NoOutput)), - ("unknown", Err(NoBaseReason::UnknownIds)), - ("ambiguous", Err(NoBaseReason::MultipleResponses)), - ("lonely", Err(NoBaseReason::MissingRequest)), - ] { - assert_eq!(session.base(name), expected, "{name}"); - } - } + use super::{BaseRule, SegmentKind, segment_page, session_streams}; + use crate::usage::streams::fixtures::{Session, assistant, call, item, result, user}; #[test] fn segments_open_at_roots_turns_and_branches() { @@ -1037,23 +323,98 @@ mod tests { &json!([assistant("ok")]), ); - let streams = session.streams(); + let streams = session_streams(Uuid::nil(), session.events); let segments = streams .segments .iter() - .map(|segment| (segment.kind, segment.parent, segment.exchanges.join(","))) + .map(|segment| { + ( + segment.head.as_str(), + segment.kind, + segment.parent.as_deref(), + segment.forked_from.as_deref(), + segment.exchange_count, + ) + }) .collect::>(); assert_eq!( segments, [ - (SegmentKind::Root, None, "e1,e2".to_owned()), - (SegmentKind::Turn, Some(0), "e3".to_owned()), - (SegmentKind::Branch, Some(1), "e4".to_owned()), - (SegmentKind::Branch, Some(1), "e5".to_owned()), - (SegmentKind::Root, None, "e6".to_owned()), + ("e1", SegmentKind::Root, None, None, 2), + ("e3", SegmentKind::Turn, Some("e1"), Some("e2"), 1), + ("e4", SegmentKind::Branch, Some("e3"), Some("e3"), 1), + ("e5", SegmentKind::Branch, Some("e3"), Some("e3"), 1), + ("e6", SegmentKind::Root, None, None, 1), ] ); - let e3 = &streams.exchanges[2]; - assert_eq!((e3.segment, e3.new_from), (1, Some(4_i32))); + // A segment spans from its first request to its last response. + let first = &streams.segments[0]; + assert_eq!( + first.end.signed_duration_since(first.start), + TimeDelta::milliseconds(1_500) + ); + } + #[test] + fn a_segment_page_runs_from_its_exchange_to_the_segment_end() { + let mut session = Session::new(); + session.exchange("e1", 1, &json!([user("go")]), &json!([call("a")])); + session.exchange( + "e2", + 2, + &json!([user("go"), call("a"), result("a")]), + &json!([call("b")]), + ); + session.exchange( + "e3", + 3, + &json!([user("go"), call("a"), result("a"), call("b"), result("b")]), + &json!([assistant("done")]), + ); + let page = |from: &str, after: Option<&str>, limit: usize| { + segment_page(session.events.clone(), from, after, limit).map(|page| { + let ids = page + .exchanges + .iter() + .map(|exchange| exchange.id.as_str()) + .collect::>() + .join(","); + (page.segment, ids, page.next) + }) + }; + let some = |segment: &str, ids: &str, next: Option<&str>| { + Some((segment.to_owned(), ids.to_owned(), next.map(str::to_owned))) + }; + assert_eq!(page("e1", None, 2), some("e1", "e1,e2", Some("e2"))); + assert_eq!(page("e1", Some("e2"), 2), some("e1", "e3", None)); + assert_eq!( + page("e2", None, 50), + some("e1", "e2,e3", None), + "an exchange inside a segment pages from itself and names the head" + ); + assert_eq!(page("zz", None, 50), None); + assert_eq!( + page("e2", Some("e1"), 50), + None, + "the cursor lies before the page" + ); + + let segment = segment_page(session.events.clone(), "e1", None, 50).expect("a page"); + let e2 = &segment.exchanges[1]; + assert_eq!( + ( + e2.base.as_deref(), + e2.base_segment.as_deref(), + e2.rule, + e2.new_from + ), + ( + Some("e1"), + Some("e1"), + Some(BaseRule::LastOutput), + Some(2_i32) + ) + ); + assert!(e2.request_pk.is_some() && e2.response_pk.is_some()); + assert!(segment.exchanges[0].starts_turn && !e2.starts_turn); } } diff --git a/crates/abyss-backend/src/usage/streams/base.rs b/crates/abyss-backend/src/usage/streams/base.rs new file mode 100644 index 0000000..ac61d6a --- /dev/null +++ b/crates/abyss-backend/src/usage/streams/base.rs @@ -0,0 +1,390 @@ +//! Exchanges of one session and the base of each: the exchange whose output a +//! request's context continues, found by key lookups without comparing +//! content. + +use std::collections::HashMap; + +use chrono::{DateTime, Utc}; +use serde::Serialize; +use uuid::Uuid; + +use super::{ + EventFacts, StreamEvent, + facts::{MODEL_ROLES, RequestFacts, ResponseFacts, SkeletonEntry}, +}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +/// Rule that found a request's base. +pub enum BaseRule { + /// The request names a response of the session in `previous_response_id`. + Pointer, + /// The exchange that emitted the request's last identity said every + /// model-side entry that trails it. + LastOutput, + /// The request has no model output, and one exchange emitted every call + /// its tool results answer. + ResultsOnly, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +/// Why a request has no base. +pub enum NoBaseReason { + /// The last model output has no identity, or model output its emitter + /// did not say trails the last identity. + TextOnly, + /// The request has neither model output nor tool results. + NoOutput, + /// No other exchange of the session emitted the ids. + UnknownIds, + /// The ids come from more than one exchange. + MultipleResponses, + /// The exchange has no request event. + MissingRequest, +} + +/// One side of an exchange as streams use it. +pub(super) struct Side { + pub(super) pk: Uuid, + pub(super) previous_response_id: Option, + pub(super) facts: F, +} + +pub(super) struct Exchange { + pub(super) id: String, + pub(super) response_id: Option, + /// Request time, else response time. + pub(super) at: DateTime, + /// Latest event time. + pub(super) end: DateTime, + pub(super) request: Option>, + pub(super) response: Option>, +} + +impl Exchange { + /// Adds `event` unless the exchange already has an event of its side. + fn add(&mut self, event: StreamEvent) { + let (pk, previous_response_id) = (event.pk, event.previous_response_id); + self.end = self.end.max(event.observed_at); + match event.facts { + EventFacts::Request(facts) if self.request.is_none() => { + self.at = event.observed_at; + self.request = Some(Side { + pk, + previous_response_id, + facts, + }); + } + EventFacts::Response(facts) if self.response.is_none() => { + self.response = Some(Side { + pk, + previous_response_id, + facts, + }); + } + EventFacts::Request(_) | EventFacts::Response(_) => {} + } + } + + pub(super) fn starts_turn(&self) -> bool { + self.request + .as_ref() + .is_some_and(|request| request.facts.starts_turn) + } +} + +/// Pairs events into exchanges by `response_id`, ordered by request time; +/// an event without a `response_id` is its own exchange. +pub(super) fn group_exchanges(events: Vec) -> Vec { + let mut exchanges: Vec = Vec::new(); + let mut by_response_id: HashMap = HashMap::new(); + for event in events { + let known = event + .response_id + .as_ref() + .and_then(|response_id| by_response_id.get(response_id).copied()); + let index = known.unwrap_or_else(|| { + let id = event + .response_id + .clone() + .unwrap_or_else(|| event.event_id.clone()); + if let Some(response_id) = &event.response_id { + by_response_id.insert(response_id.clone(), exchanges.len()); + } + exchanges.push(Exchange { + id, + response_id: event.response_id.clone(), + at: event.observed_at, + end: event.observed_at, + request: None, + response: None, + }); + exchanges.len().saturating_sub(1) + }); + exchanges[index].add(event); + } + exchanges.sort_by_key(|exchange| exchange.at); + exchanges +} + +/// Exchanges whose response emitted each identity, in exchange order. +pub(super) fn emitters(exchanges: &[Exchange]) -> HashMap<&str, Vec> { + let mut by_id: HashMap<&str, Vec> = HashMap::new(); + for (index, exchange) in exchanges.iter().enumerate() { + let Some(response) = &exchange.response else { + continue; + }; + for id in &response.facts.emitted_ids { + let list = by_id.entry(id.as_str()).or_default(); + if list.last() != Some(&index) { + list.push(index); + } + } + } + by_id +} + +/// The one exchange other than `own` that emitted any of `ids`. +fn only_emitter<'a>( + ids: impl IntoIterator, + own: usize, + by_id: &HashMap<&str, Vec>, +) -> Result { + let mut found = None; + for id in ids { + for &emitter in by_id.get(id).into_iter().flatten() { + if emitter == own || found == Some(emitter) { + continue; + } + if found.is_some() { + return Err(NoBaseReason::MultipleResponses); + } + found = Some(emitter); + } + } + found.ok_or(NoBaseReason::UnknownIds) +} + +/// Whether `output` said every trailing role after its entry keyed by +/// `last`, in order: a client may drop entries of an output, but entries it +/// did not say come from a later reply. +fn said_after(output: &[SkeletonEntry], last: &str, trailing: &[String]) -> bool { + let is_model_side = |role: &str| MODEL_ROLES.contains(&role); + let Some(held) = output + .iter() + .rposition(|(role, key)| is_model_side(role) && key.as_deref() == Some(last)) + else { + return false; + }; + let mut said = output[held.saturating_add(1)..] + .iter() + .map(|(role, _)| role) + .filter(|role| is_model_side(role)); + trailing + .iter() + .all(|role| said.any(|spoken| spoken == role)) +} + +/// The base of exchange `index` and the rule that found it: the response +/// its pointer names; else the one other exchange that emitted its last +/// identity, when that exchange said everything trailing it; else, without +/// model output, the one exchange that emitted the calls its tool results +/// answer. +pub(super) fn find_base( + index: usize, + exchanges: &[Exchange], + by_response_id: &HashMap<&str, usize>, + by_id: &HashMap<&str, Vec>, +) -> Result<(usize, BaseRule), NoBaseReason> { + let request = exchanges[index] + .request + .as_ref() + .ok_or(NoBaseReason::MissingRequest)?; + if let Some(&base) = request + .previous_response_id + .as_deref() + .and_then(|response_id| by_response_id.get(response_id)) + { + return Ok((base, BaseRule::Pointer)); + } + let facts = &request.facts; + if let Some(last) = facts.last_identity.as_deref() { + let emitter = only_emitter([last], index, by_id)?; + let output = exchanges[emitter] + .response + .as_ref() + .map(|response| response.facts.output_skeleton.as_slice()) + .unwrap_or_default(); + return if said_after(output, last, &facts.trailing) { + Ok((emitter, BaseRule::LastOutput)) + } else { + Err(NoBaseReason::TextOnly) + }; + } + // The last run has model output without any identity. + if facts.new_from > 0_i32 { + return Err(NoBaseReason::TextOnly); + } + if facts.result_ids.is_empty() { + return Err(NoBaseReason::NoOutput); + } + only_emitter(facts.result_ids.iter().map(String::as_str), index, by_id) + .map(|base| (base, BaseRule::ResultsOnly)) +} + +#[cfg(test)] +mod tests { + use serde_json::json; + + use super::{BaseRule, NoBaseReason}; + use crate::usage::streams::fixtures::{ + Session, assistant, call, item, reasoning, result, user, + }; + + fn linked(base: &str) -> (String, BaseRule) { + (base.to_owned(), BaseRule::LastOutput) + } + + #[test] + fn a_request_continues_the_emitter_of_its_last_identity_unless_more_trails_it() { + // The examples of STREAM-DESIGN "base 怎么判定": the outputs of the + // earlier replies p1, p2, …, then what the next request carries back. + let cases = [ + ( + "1: the call's result and new input do not count", + vec![json!([assistant("look"), call("c1")])], + json!([ + user("go"), + assistant("look"), + call("c1"), + result("c1"), + user("more") + ]), + Ok(linked("p1")), + ), + ( + "2: rewritten text", + vec![json!([assistant("check the log"), call("c1")])], + json!([ + user("go"), + assistant("see the log"), + call("c1"), + result("c1") + ]), + Ok(linked("p1")), + ), + ( + "3: a dropped entry", + vec![json!([assistant("look"), call("c1")])], + json!([user("go"), call("c1"), result("c1")]), + Ok(linked("p1")), + ), + ( + "4: dropped thinking", + vec![json!([reasoning(), call("c1")])], + json!([user("go"), call("c1"), result("c1")]), + Ok(linked("p1")), + ), + ( + "5: text p1 did not say", + vec![json!([call("c1")])], + json!([user("go"), call("c1"), result("c1"), assistant("fixed")]), + Err(NoBaseReason::TextOnly), + ), + ( + "6: earlier calls of the run come from earlier replies", + vec![json!([call("a")]), json!([call("b")])], + json!([user("go"), call("a"), result("a"), call("b"), result("b")]), + Ok(linked("p2")), + ), + ( + "7: a dropped server tool result", + vec![json!([call("s1"), result("s1"), assistant("found")])], + json!([user("go"), call("s1"), assistant("found")]), + Ok(linked("p1")), + ), + ( + "8: text aligned on its item id", + vec![json!([assistant("look"), item(assistant("so"), "msg_2")])], + json!([user("go"), item(assistant("so"), "msg_2"), user("more")]), + Ok(linked("p1")), + ), + ( + "9: dropped text after the call", + vec![json!([call("c1"), assistant("done")])], + json!([user("go"), call("c1"), result("c1")]), + Ok(linked("p1")), + ), + ( + "thinking p1 did not say", + vec![json!([call("c1"), assistant("done")])], + json!([ + user("go"), + call("c1"), + result("c1"), + reasoning(), + assistant("done") + ]), + Err(NoBaseReason::TextOnly), + ), + ( + "more text than p1 said", + vec![json!([call("c1"), assistant("done")])], + json!([ + user("go"), + call("c1"), + assistant("done"), + assistant("again") + ]), + Err(NoBaseReason::TextOnly), + ), + ]; + for (case, outputs, request, expected) in cases { + let mut session = Session::new(); + let mut second = 0_i64; + for output in &outputs { + second = second.saturating_add(1); + session.exchange(&format!("p{second}"), second, &json!([user("go")]), output); + } + session.ask("next", second.saturating_add(1), None, &request); + assert_eq!(session.base("next"), expected, "{case}"); + } + } + + #[test] + fn pointers_and_tool_results_find_a_base_and_requests_without_one_say_why() { + let mut session = Session::new(); + session.exchange("p", 1, &json!([user("go")]), &json!([call("a"), call("b")])); + session.exchange("twin", 2, &json!([user("go")]), &json!([call("d")])); + session.exchange("other", 3, &json!([user("go")]), &json!([call("d")])); + session.ask("pointer", 4, Some("p"), &json!([user("next")])); + session.ask("results", 5, None, &json!([result("a"), result("b")])); + session.ask( + "plain_text", + 6, + None, + &json!([user("hi"), assistant("hey"), user("so")]), + ); + session.ask("nothing", 7, None, &json!([user("hi")])); + session.ask( + "unknown", + 8, + None, + &json!([user("go"), call("zz"), result("zz")]), + ); + session.ask("ambiguous", 9, None, &json!([result("d")])); + session.answer("lonely", 10, &json!([assistant("hi")])); + for (name, expected) in [ + ("pointer", Ok(("p".to_owned(), BaseRule::Pointer))), + ("results", Ok(("p".to_owned(), BaseRule::ResultsOnly))), + ("plain_text", Err(NoBaseReason::TextOnly)), + ("nothing", Err(NoBaseReason::NoOutput)), + ("unknown", Err(NoBaseReason::UnknownIds)), + ("ambiguous", Err(NoBaseReason::MultipleResponses)), + ("lonely", Err(NoBaseReason::MissingRequest)), + ] { + assert_eq!(session.base(name), expected, "{name}"); + } + } +} diff --git a/crates/abyss-backend/src/usage/streams/facts.rs b/crates/abyss-backend/src/usage/streams/facts.rs new file mode 100644 index 0000000..da84c58 --- /dev/null +++ b/crates/abyss-backend/src/usage/streams/facts.rs @@ -0,0 +1,327 @@ +//! The stream facts ingest reads from one event's conversation entries alone: +//! roles, call ids, item ids, and whether a user entry has text. + +use serde_json::Value; + +use crate::usage::conversation::{Conversation, Entry}; + +/// Roles of the entries the model produced. +pub(super) const MODEL_ROLES: [&str; 3] = ["assistant", "reasoning", "tool_call"]; + +/// Prefix that keeps item ids apart from call ids among entry identities. +const ITEM_PREFIX: &str = "item:"; + +impl Entry { + fn is_model_side(&self) -> bool { + MODEL_ROLES.contains(&self.role.as_str()) + } + + /// The item id of a model-side entry, with [`ITEM_PREFIX`]. + fn item(&self) -> Option { + self.item_id + .as_deref() + .filter(|_| self.is_model_side()) + .map(|item_id| format!("{ITEM_PREFIX}{item_id}")) + } + + /// The provider-made ids of the entry: its item, then the call id of a + /// `tool_call` entry. + fn identities(&self) -> impl Iterator { + let call = self.call_id.clone().filter(|_| self.role == "tool_call"); + self.item().into_iter().chain(call) + } + + /// The entry's key in a skeleton: its call id, else its item. + fn key(&self) -> Option { + self.call_id.clone().or_else(|| self.item()) + } +} + +/// The entries of the event's conversation; none when it is not completely +/// decoded. +fn conversation_entries(metadata: &Value) -> Vec { + Conversation::read(metadata) + .map(|conversation| conversation.entries) + .unwrap_or_default() +} + +/// One skeleton entry: an entry's role and key, its call id, else its +/// `item:`-prefixed item id. +pub type SkeletonEntry = (String, Option); + +/// The `(role, key)` of each entry in order. +fn skeleton(entries: &[Entry]) -> Vec { + entries + .iter() + .map(|entry| (entry.role.clone(), entry.key())) + .collect() +} + +/// Call ids of the `tool_result` entries, in order. +fn result_ids(entries: &[Entry]) -> Vec { + entries + .iter() + .filter(|entry| entry.role == "tool_result") + .filter_map(|entry| entry.call_id.clone()) + .collect() +} + +/// Identities of the entries, in order. +fn identities(entries: &[Entry]) -> Vec { + entries.iter().flat_map(Entry::identities).collect() +} + +#[derive(Clone, Debug, Default, PartialEq, Eq)] +/// Facts of one request event, read from its conversation entries alone. +pub struct RequestFacts { + /// Index after the last model-side entry, or 0 without one; the entries + /// from here on are the request's new part. + pub new_from: i32, + /// The last identity in the last run, the run reaching from the last + /// model-side entry back through model-side and `tool_result` entries to + /// any other role or the start: the call id of a `tool_call` entry, else + /// the `item:`-prefixed item id of a model-side entry. + pub last_identity: Option, + /// Roles of the model-side entries after the one holding + /// `last_identity`, in order; none of them has an identity. Empty without + /// `last_identity`. + pub trailing: Vec, + /// Without model-side entries, the call ids of every `tool_result` + /// entry; otherwise empty. + pub result_ids: Vec, + /// Whether the new part holds a `user` entry with text and no + /// `tool_result` entry. + pub starts_turn: bool, +} + +impl RequestFacts { + /// Reads the facts of a request event from its metadata. + /// core logic of connect exchanges. + pub fn read(metadata: &Value) -> Self { + let entries = conversation_entries(metadata); + let Some(last) = entries.iter().rposition(Entry::is_model_side) else { + return Self { + new_from: 0, + last_identity: None, + trailing: Vec::new(), + result_ids: result_ids(&entries), + starts_turn: starts_turn(&entries), + }; + }; + let start = entries[..last] + .iter() + .rposition(|entry| !entry.is_model_side() && entry.role != "tool_result") + .map_or(0, |other| other.saturating_add(1)); + let run = &entries[start..=last]; + let held = run + .iter() + .rposition(|entry| entry.identities().next().is_some()); + let last_identity = held.and_then(|held| run[held].identities().last()); + let trailing = held.map_or_else(Vec::new, |held| { + run[held.saturating_add(1)..] + .iter() + .filter(|entry| entry.is_model_side()) + .map(|entry| entry.role.clone()) + .collect() + }); + let new_from = last.saturating_add(1); + Self { + new_from: i32::try_from(new_from).unwrap_or(i32::MAX), + last_identity, + trailing, + result_ids: Vec::new(), + starts_turn: starts_turn(&entries[new_from..]), + } + } +} + +/// Whether a new part opens a turn: it has user text and no tool result. +fn starts_turn(new_part: &[Entry]) -> bool { + new_part + .iter() + .any(|entry| entry.role == "user" && !entry.text.is_empty()) + && !new_part.iter().any(|entry| entry.role == "tool_result") +} + +#[derive(Clone, Debug, Default, PartialEq, Eq)] +/// Facts of one response event, read from its conversation entries alone. +pub struct ResponseFacts { + /// Identities of every entry, in order: for each entry, the + /// `item:`-prefixed item id of a model-side entry, then the call id of a + /// `tool_call` entry. + pub emitted_ids: Vec, + /// Skeleton of every entry. + pub output_skeleton: Vec, +} + +impl ResponseFacts { + /// Reads the facts of a response event from its metadata. + pub fn read(metadata: &Value) -> Self { + let entries = conversation_entries(metadata); + Self { + emitted_ids: identities(&entries), + output_skeleton: skeleton(&entries), + } + } +} + +#[cfg(test)] +mod tests { + use serde_json::json; + + use super::{RequestFacts, ResponseFacts}; + use crate::usage::streams::fixtures::{ + assistant, call, conversation, ids, item, reasoning, result, skeleton, system, user, + }; + + #[test] + fn request_facts_follow_the_last_run_and_the_new_part() { + // A tool loop resent with its history: the last run walks back + // through the client's tool result to the user entry. + let facts = RequestFacts::read(&conversation(&json!([ + system("rules"), + user("fix it"), + reasoning(), + call("a"), + result("a"), + reasoning(), + call("b"), + result("b") + ]))); + assert_eq!( + facts, + RequestFacts { + new_from: 7, + last_identity: Some("b".to_owned()), + trailing: Vec::new(), + result_ids: Vec::new(), + starts_turn: false, + } + ); + // New user text after the last answer opens a turn; the answer has no + // identity, so it trails the last call. + let facts = RequestFacts::read(&conversation(&json!([ + user("fix it"), + call("a"), + result("a"), + assistant("fixed"), + system(""), + user("next") + ]))); + assert_eq!(facts.new_from, 4_i32); + assert_eq!(facts.last_identity.as_deref(), Some("a")); + assert_eq!(facts.trailing, ["assistant"]); + assert!(facts.starts_turn); + assert!(facts.result_ids.is_empty()); + // Without model output, every entry is new and tool results answer + // calls that live elsewhere. + assert_eq!( + RequestFacts::read(&conversation(&json!([ + result("a"), + result("b"), + user("go") + ]))), + RequestFacts { + new_from: 0, + last_identity: None, + trailing: Vec::new(), + result_ids: ids(&["a", "b"]), + starts_turn: false, + } + ); + // A run without identities has nothing to align on and nothing + // trailing; the run stops at the user entry before it. + let facts = RequestFacts::read(&conversation(&json!([ + call("a"), + result("a"), + user("thanks"), + assistant("sure") + ]))); + assert_eq!((facts.last_identity, facts.trailing), (None, Vec::new())); + // Empty user text opens no turn; a request without a complete + // conversation has no facts. + assert!(!RequestFacts::read(&conversation(&json!([assistant("a"), user("")]))).starts_turn); + assert_eq!(RequestFacts::read(&json!({})), RequestFacts::default()); + assert_eq!( + RequestFacts::read(&json!({"conversation": { + "entries": [assistant("a"), user("next")], + "completeness": "partial" + }})), + RequestFacts::default() + ); + } + + #[test] + fn response_facts_list_emitted_calls_and_the_output_skeleton() { + assert_eq!( + ResponseFacts::read(&conversation(&json!([ + reasoning(), + assistant("let me look"), + call("a"), + call("b"), + {"role": "tool_result", "text": "found", "call_id": "s"} + ]))), + ResponseFacts { + emitted_ids: ids(&["a", "b"]), + output_skeleton: skeleton(&[ + ("reasoning", None), + ("assistant", None), + ("tool_call", Some("a")), + ("tool_call", Some("b")), + ("tool_result", Some("s")) + ]), + } + ); + assert_eq!( + ResponseFacts::read(&json!({"conversation": {}})), + ResponseFacts::default() + ); + } + + #[test] + fn identities_are_call_ids_and_prefixed_item_ids_of_model_output() { + // OpenAI Responses: every output item carries its id, and a function + // call its call id as well. + let output = [ + item(reasoning(), "rs_1"), + item(assistant("let me look"), "msg_1"), + item(call("a"), "fc_1"), + ]; + let facts = ResponseFacts::read(&conversation(&json!(output))); + assert_eq!( + facts.emitted_ids, + ids(&["item:rs_1", "item:msg_1", "item:fc_1", "a"]) + ); + assert_eq!( + facts.output_skeleton, + skeleton(&[ + ("reasoning", Some("item:rs_1")), + ("assistant", Some("item:msg_1")), + ("tool_call", Some("a")) + ]), + "a skeleton keys entries by call id, else by item id" + ); + // Entries of one item share its id; item ids of other roles are no + // identities; a call's last identity is its call id. + let mut request = vec![item(user("go"), "u_1")]; + request.extend(output.clone()); + request.extend([ + item(result("a"), "fco_1"), + item(assistant("done,"), "msg_2"), + item(assistant("really"), "msg_2"), + ]); + let facts = RequestFacts::read(&conversation(&json!(request))); + assert_eq!(facts.last_identity.as_deref(), Some("item:msg_2")); + assert!(facts.trailing.is_empty()); + let mut request = vec![user("go")]; + request.extend(output); + request.extend([result("a"), reasoning(), assistant("done")]); + let facts = RequestFacts::read(&conversation(&json!(request))); + assert_eq!(facts.last_identity.as_deref(), Some("a")); + assert_eq!( + facts.trailing, + ["reasoning", "assistant"], + "trailing roles keep reasoning and leave out tool results" + ); + } +} diff --git a/crates/abyss-backend/src/usage/streams/fixtures.rs b/crates/abyss-backend/src/usage/streams/fixtures.rs new file mode 100644 index 0000000..894fff8 --- /dev/null +++ b/crates/abyss-backend/src/usage/streams/fixtures.rs @@ -0,0 +1,123 @@ +//! Conversation entries and sessions the stream tests build on. + +use chrono::{DateTime, TimeDelta, TimeZone as _, Utc}; +use serde_json::{Value, json}; +use uuid::Uuid; + +use super::{ + BaseRule, EventFacts, NoBaseReason, RequestFacts, ResponseFacts, StreamEvent, Streams, +}; + +pub(super) fn conversation(entries: &Value) -> Value { + json!({"conversation": {"entries": entries, "completeness": "complete"}}) +} + +pub(super) fn user(text: &str) -> Value { + json!({"role": "user", "text": text}) +} + +pub(super) fn system(text: &str) -> Value { + json!({"role": "system", "text": text}) +} + +pub(super) fn assistant(text: &str) -> Value { + json!({"role": "assistant", "text": text}) +} + +pub(super) fn reasoning() -> Value { + json!({"role": "reasoning", "text": ""}) +} + +pub(super) fn call(id: &str) -> Value { + json!({"role": "tool_call", "text": "{}", "call_id": id, "name": "Bash"}) +} + +pub(super) fn result(id: &str) -> Value { + json!({"role": "tool_result", "text": "ok", "call_id": id}) +} + +/// `entry` as part of the model output item `id`. +pub(super) fn item(mut entry: Value, id: &str) -> Value { + entry["item_id"] = json!(id); + entry +} + +pub(super) fn skeleton(entries: &[(&str, Option<&str>)]) -> Vec<(String, Option)> { + entries + .iter() + .map(|(role, id)| ((*role).to_owned(), id.map(str::to_owned))) + .collect() +} + +pub(super) fn ids(values: &[&str]) -> Vec { + values.iter().map(|value| (*value).to_owned()).collect() +} + +/// A session built from exchanges with conversation entries; exchange +/// `name` shares `name` as its response id. +pub(super) struct Session { + start: DateTime, + pub(super) events: Vec, +} + +impl Session { + pub(super) fn new() -> Self { + Self { + start: Utc + .with_ymd_and_hms(2026, 10, 20, 0, 0, 0) + .single() + .expect("valid time"), + events: Vec::new(), + } + } + + pub(super) fn add( + &mut self, + name: &str, + millis: i64, + previous: Option<&str>, + facts: EventFacts, + ) { + let at = self + .start + .checked_add_signed(TimeDelta::milliseconds(millis)) + .expect("test times stay in range"); + self.events.push(StreamEvent { + pk: Uuid::now_v7(), + event_id: format!("{name}.{millis}"), + observed_at: at, + response_id: Some(name.to_owned()), + previous_response_id: previous.map(str::to_owned), + facts, + }); + } + + /// Adds the request of exchange `name` at `second`. + pub(super) fn ask(&mut self, name: &str, second: i64, previous: Option<&str>, entries: &Value) { + let facts = EventFacts::Request(RequestFacts::read(&conversation(entries))); + self.add(name, second.saturating_mul(1_000), previous, facts); + } + + /// Adds the response of exchange `name`, half a second after `second`. + pub(super) fn answer(&mut self, name: &str, second: i64, entries: &Value) { + let facts = EventFacts::Response(ResponseFacts::read(&conversation(entries))); + let millis = second.saturating_mul(1_000).saturating_add(500); + self.add(name, millis, None, facts); + } + + pub(super) fn exchange(&mut self, name: &str, second: i64, request: &Value, response: &Value) { + self.ask(name, second, None, request); + self.answer(name, second, response); + } + + /// The base and rule of exchange `name`, or why it has none. + pub(super) fn base(&self, name: &str) -> Result<(String, BaseRule), NoBaseReason> { + let streams = Streams::derive(self.events.clone()); + let index = streams + .exchanges + .iter() + .position(|exchange| exchange.id == name) + .expect("the exchange exists"); + streams.bases[index].map(|(base, rule)| (streams.exchanges[base].id.clone(), rule)) + } +} diff --git a/crates/abyss-backend/src/usage/streams/segmentation.rs b/crates/abyss-backend/src/usage/streams/segmentation.rs new file mode 100644 index 0000000..1d08afc --- /dev/null +++ b/crates/abyss-backend/src/usage/streams/segmentation.rs @@ -0,0 +1,79 @@ +//! Segments: runs of exchanges along one stream, cut at roots, turns, and +//! fork points. + +use serde::Serialize; + +use super::base::{BaseRule, Exchange, NoBaseReason}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +/// Why a segment begins. +pub enum SegmentKind { + /// Its first exchange has no base. + Root, + /// Its first exchange continues a turn-opening request. + Turn, + /// Its first exchange continues a fork point; every branch is a new stream. + Branch, +} + +pub(super) struct Segment { + pub(super) kind: SegmentKind, + pub(super) parent: Option, + pub(super) exchanges: Vec, +} + +pub(super) struct Segmentation { + pub(super) segments: Vec, + /// Segment of each exchange. + pub(super) segment_of: Vec, +} + +impl Segmentation { + /// Walks the exchanges by request time. A segment begins at an exchange + /// without a base, at one whose base is a fork point, the base of more + /// than one exchange, and at one whose request opens a turn; any other + /// exchange joins its base's segment. + pub(super) fn cut( + exchanges: &[Exchange], + bases: &[Result<(usize, BaseRule), NoBaseReason>], + ) -> Self { + let mut children = vec![0_usize; exchanges.len()]; + for &(base, _) in bases.iter().flatten() { + children[base] = children[base].saturating_add(1); + } + let mut segments: Vec = Vec::new(); + let mut placed: Vec> = vec![None; exchanges.len()]; + for (index, exchange) in exchanges.iter().enumerate() { + let base = bases[index].ok().map(|(base, _)| base); + let base_segment = base.and_then(|base| placed[base]).zip(base); + let segment = match base_segment { + Some((segment, base)) if children[base] <= 1 && !exchange.starts_turn() => segment, + Some((segment, base)) => { + let kind = if children[base] > 1 { + SegmentKind::Branch + } else { + SegmentKind::Turn + }; + Self::open(&mut segments, kind, Some(segment)) + } + None => Self::open(&mut segments, SegmentKind::Root, None), + }; + segments[segment].exchanges.push(index); + placed[index] = Some(segment); + } + Self { + segments, + segment_of: placed.into_iter().map(Option::unwrap_or_default).collect(), + } + } + + fn open(segments: &mut Vec, kind: SegmentKind, parent: Option) -> usize { + segments.push(Segment { + kind, + parent, + exchanges: Vec::new(), + }); + segments.len().saturating_sub(1) + } +} From 97a0145f24a9f5e23bce143c81ed2c28caebf2ff Mon Sep 17 00:00:00 2001 From: Auceptin Date: Wed, 21 Oct 2026 04:37:19 -0700 Subject: [PATCH 05/25] feat(usage)!: read content only from conversation entries, label segments Events carry their content only in conversation entries: - Ingest takes no text; responses drop text and text_sha256; /outline drops text_chars and preview. The text columns stay in the schema and are no longer written or read. - Search indexes every entry's text in order and the names of called tools; tool_content stays empty. - Trajectory drops its text fallback; a compaction summary is the response's assistant entries. Each request's stream facts keep a label: the last user entry with text in its new part, as its index, its first 50 UTF-16 code units, and its whole length, in three columns of the stream facts migration. /streams gives each segment the label of its first request, and the session header's title is the label of the session's first request. Co-Authored-By: Claude Opus 5.5 --- .../20261019120000_event_stream_facts/up.sql | 12 +- .../20261019120000_event_stream_facts/up.sql | 11 +- crates/abyss-backend/src/db/models.rs | 22 ++- crates/abyss-backend/src/db/schema.rs | 3 + crates/abyss-backend/src/search/projection.rs | 75 ++++----- .../src/storage/postgres/repository.rs | 44 +++-- .../src/storage/postgres/search/document.rs | 76 ++++----- .../src/storage/sqlite/models.rs | 20 +-- .../src/storage/sqlite/repository.rs | 150 +++++------------- .../src/storage/sqlite/schema.rs | 3 + .../src/storage/sqlite/search.rs | 6 +- .../src/storage/sqlite/session_list.rs | 1 - crates/abyss-backend/src/usage/mod.rs | 19 +-- crates/abyss-backend/src/usage/persistence.rs | 5 - .../abyss-backend/src/usage/session_header.rs | 16 +- crates/abyss-backend/src/usage/streams.rs | 46 +++++- .../abyss-backend/src/usage/streams/facts.rs | 91 ++++++++++- crates/abyss-backend/src/usage/trajectory.rs | 85 +++++----- scripts/tests/blackbox_api.py | 17 +- 19 files changed, 356 insertions(+), 346 deletions(-) diff --git a/crates/abyss-backend/migrations-sqlite/20261019120000_event_stream_facts/up.sql b/crates/abyss-backend/migrations-sqlite/20261019120000_event_stream_facts/up.sql index 9e906f7..c3ec215 100644 --- a/crates/abyss-backend/migrations-sqlite/20261019120000_event_stream_facts/up.sql +++ b/crates/abyss-backend/migrations-sqlite/20261019120000_event_stream_facts/up.sql @@ -1,8 +1,11 @@ -- Stream facts read at ingest from one event's own conversation entries, -- kept beside the event so a session's streams read them without walking -- the overflow pages of event metadata. A request has new_from, --- starts_turn, last_identity, trailing, and result_ids; a response has --- emitted_ids and output_skeleton; the other side's columns stay NULL. +-- starts_turn, last_identity, trailing, result_ids, and its label; a +-- response has emitted_ids and output_skeleton; the other side's columns +-- stay NULL. The label is the last user entry with text in the request's new +-- part: its index, its first 50 UTF-16 code units, and its whole length in +-- them. -- trailing is a JSON array of roles, id lists JSON arrays of strings, and -- output_skeleton a JSON array of [role, key] pairs, the key being the call -- id, else the item id. Events stored before this migration have no facts @@ -15,5 +18,8 @@ CREATE TABLE llm_usage_event_stream_facts ( trailing TEXT CHECK (json_valid(trailing)), result_ids TEXT CHECK (json_valid(result_ids)), emitted_ids TEXT CHECK (json_valid(emitted_ids)), - output_skeleton TEXT CHECK (json_valid(output_skeleton)) + output_skeleton TEXT CHECK (json_valid(output_skeleton)), + label_index INTEGER, + label_text TEXT, + label_length INTEGER ) STRICT; diff --git a/crates/abyss-backend/migrations/20261019120000_event_stream_facts/up.sql b/crates/abyss-backend/migrations/20261019120000_event_stream_facts/up.sql index 2cad9c0..ffca2e8 100644 --- a/crates/abyss-backend/migrations/20261019120000_event_stream_facts/up.sql +++ b/crates/abyss-backend/migrations/20261019120000_event_stream_facts/up.sql @@ -1,8 +1,10 @@ -- Stream facts read at ingest from one event's own conversation entries, -- kept beside the event so a session's streams read them without touching -- event metadata. A request has new_from, starts_turn, last_identity, --- trailing, and result_ids; a response has emitted_ids and output_skeleton; --- the other side's columns stay NULL. trailing is a JSON array of roles, id +-- trailing, result_ids, and its label; a response has emitted_ids and +-- output_skeleton; the other side's columns stay NULL. The label is the last +-- user entry with text in the request's new part: its index, its first 50 +-- UTF-16 code units, and its whole length in them. trailing is a JSON array of roles, id -- lists JSON arrays of strings, and output_skeleton a JSON array of -- [role, key] pairs, the key being the call id, else the item id. Events -- stored before this migration have no facts row. @@ -14,5 +16,8 @@ CREATE TABLE llm_usage_event_stream_facts ( trailing jsonb, result_ids jsonb, emitted_ids jsonb, - output_skeleton jsonb + output_skeleton jsonb, + label_index integer, + label_text text, + label_length integer ); diff --git a/crates/abyss-backend/src/db/models.rs b/crates/abyss-backend/src/db/models.rs index c211564..6afafe1 100644 --- a/crates/abyss-backend/src/db/models.rs +++ b/crates/abyss-backend/src/db/models.rs @@ -203,9 +203,11 @@ pub struct UsageEvent { pub llm_model: String, /// Canonical request or response value. pub event_type: String, - /// Optional prompt or response text. + /// Retired event text; no longer written, always empty. + #[expect(dead_code, reason = "the column stays in the schema; nothing reads it")] pub text: Option, - /// Binary SHA-256 digest of text when present. + /// Retired digest of the event text; always empty. + #[expect(dead_code, reason = "the column stays in the schema; nothing reads it")] pub text_sha256: Option>, /// Normalized input-token count. pub input_tokens: i64, @@ -324,10 +326,6 @@ pub struct NewUsageEvent { pub llm_model: String, /// Canonical request or response value. pub event_type: String, - /// Optional prompt or response text. - pub text: Option, - /// Optional binary SHA-256 of text. - pub text_sha256: Option>, /// Normalized input-token count. pub input_tokens: i64, /// Normalized output-token count. @@ -384,6 +382,12 @@ pub struct StreamEventRow { pub emitted_ids: Option, /// Response: skeleton of its entries. pub output_skeleton: Option, + /// Request: index of its label entry. + pub label_index: Option, + /// Request: start of its label entry's text. + pub label_text: Option, + /// Request: length of its label entry's text in UTF-16 code units. + pub label_length: Option, } #[derive(Debug, Default, Insertable)] @@ -406,6 +410,12 @@ pub struct NewStreamFacts { pub emitted_ids: Option, /// Response: skeleton of its entries. pub output_skeleton: Option, + /// Request: index of its label entry. + pub label_index: Option, + /// Request: start of its label entry's text. + pub label_text: Option, + /// Request: length of its label entry's text in UTF-16 code units. + pub label_length: Option, } #[derive(Insertable)] diff --git a/crates/abyss-backend/src/db/schema.rs b/crates/abyss-backend/src/db/schema.rs index bb1f458..98e7eda 100644 --- a/crates/abyss-backend/src/db/schema.rs +++ b/crates/abyss-backend/src/db/schema.rs @@ -107,6 +107,9 @@ diesel::table! { result_ids -> Nullable, emitted_ids -> Nullable, output_skeleton -> Nullable, + label_index -> Nullable, + label_text -> Nullable, + label_length -> Nullable, } } diff --git a/crates/abyss-backend/src/search/projection.rs b/crates/abyss-backend/src/search/projection.rs index c4e4733..d2d71d2 100644 --- a/crates/abyss-backend/src/search/projection.rs +++ b/crates/abyss-backend/src/search/projection.rs @@ -16,57 +16,38 @@ pub struct SearchProjection { } impl SearchProjection { - pub fn from_source(text: Option, metadata: &Value) -> Self { - let extracted = SearchableMetadata::extract(metadata); + /// Reads the event's conversation entries: the text of every entry, of + /// every role, in order, and the names of the tools it calls. Every other + /// metadata key is ignored, so headers, credentials, and image data never + /// enter a derived full-text index. + pub fn from_source(metadata: &Value) -> Self { + let entries = metadata + .get("conversation") + .and_then(|conversation| conversation.get("entries")) + .and_then(Value::as_array) + .map(Vec::as_slice) + .unwrap_or_default(); + let mut tool_names = Vec::new(); + let mut texts = Vec::new(); + for entry in entries { + if entry.get("role").and_then(Value::as_str) == Some("tool_call") + && let Some(name) = entry.get("name").and_then(Value::as_str) + { + push_bounded(&mut tool_names, name); + } + if let Some(text) = entry.get("text").and_then(Value::as_str) + && !text.is_empty() + { + texts.push(text); + } + } Self { - content: text - .map(|value| sanitize_search_text(&value)) + content: (!texts.is_empty()) + .then(|| sanitize_search_text(&texts.join("\n"))) .map(|value| truncate_characters(value, MAX_CONTENT_CHARACTERS)), - tool_names: extracted.tool_names, - tool_content: extracted.tool_content, - } - } -} - -struct SearchableMetadata { - tool_names: Vec, - tool_content: Vec, -} - -impl SearchableMetadata { - fn extract(metadata: &Value) -> Self { - let mut extracted = Self { - tool_names: Vec::new(), + tool_names, tool_content: Vec::new(), - }; - - // Ignore every other top-level key so headers, credentials, and image - // data never enter a derived full-text index. - let Some(segments) = metadata.get("content_segments").and_then(Value::as_array) else { - return extracted; - }; - for segment in segments.iter().take(MAX_EXTRACTED_VALUES) { - let Some(object) = segment.as_object() else { - continue; - }; - match object.get("type").and_then(Value::as_str) { - Some("tool_call") => { - if let Some(name) = object.get("name").and_then(Value::as_str) { - push_bounded(&mut extracted.tool_names, name); - } - if let Some(input) = object.get("input").and_then(Value::as_str) { - push_bounded(&mut extracted.tool_content, input); - } - } - Some("tool_result") => { - if let Some(output) = object.get("output").and_then(Value::as_str) { - push_bounded(&mut extracted.tool_content, output); - } - } - _ => {} - } } - extracted } } diff --git a/crates/abyss-backend/src/storage/postgres/repository.rs b/crates/abyss-backend/src/storage/postgres/repository.rs index 74265d9..9534a8b 100644 --- a/crates/abyss-backend/src/storage/postgres/repository.rs +++ b/crates/abyss-backend/src/storage/postgres/repository.rs @@ -12,7 +12,7 @@ use chrono::{DateTime, Utc}; use diesel::{ Connection, ExpressionMethods, IntoSql, NullableExpressionMethods, PgConnection, QueryDsl, QueryableByName, RunQueryDsl, SelectableHelper, define_sql_function, - dsl::{count_star, max, sql}, + dsl::{count_star, max}, result::OptionalExtension, sql_query, sql_types::{ @@ -41,10 +41,10 @@ use crate::{ }, error::AppError, usage::{ - IngestEventsRequest, IngestEventsResponse, IngestUsageEvent, OUTLINE_PREVIEW_CHARS, - RawEventsQuery, RawEventsResponse, SessionInfo, SessionOutlineResponse, - SessionTimelineResponse, SummaryQuery, SummaryResponse, SummaryRow, TurnTimeline, - UsageEventOutline, UsageEventResponse, UsageEventType, + IngestEventsRequest, IngestEventsResponse, IngestUsageEvent, RawEventsQuery, + RawEventsResponse, SessionInfo, SessionOutlineResponse, SessionTimelineResponse, + SummaryQuery, SummaryResponse, SummaryRow, TurnTimeline, UsageEventOutline, + UsageEventResponse, UsageEventType, attachments::{ ImageMediaType, StoredImageAttachment, UsageEventAttachmentResponse, ValidatedImageAttachment, @@ -53,8 +53,8 @@ use crate::{ event_order::UsageEventTimelineOrder, persistence::{ escape_like_pattern, exact_values_filter, non_empty, non_negative_count, - normalize_limit, normalize_text, parse_group_by, sha256_bytes, unix_epoch, - validate_batch, validate_event_type, + normalize_limit, normalize_text, parse_group_by, unix_epoch, validate_batch, + validate_event_type, }, preview::{ConversationPreview, Window, preview}, segments::{ @@ -62,8 +62,8 @@ use crate::{ }, session_header::{HeaderEvent, SessionHeaderResponse, session_header as header_of}, streams::{ - EventFacts, RequestFacts, ResponseFacts, SessionStreamsResponse, StreamEvent, - segment_page, session_streams as streams_of, stream_counts, + EventFacts, Label, RequestFacts, ResponseFacts, SessionStreamsResponse, StreamEvent, + segment_page, session_streams as streams_of, stream_summary, }, }, }; @@ -405,8 +405,6 @@ type OutlineRow = ( i64, i64, DateTime, - i64, - Option, ); /// Loads one owner-scoped session timeline without event content bodies. @@ -435,11 +433,6 @@ pub fn session_outline( llm_usage_events::reasoning_tokens, llm_usage_events::total_tokens, llm_usage_events::observed_at, - sql::("coalesce(char_length(text), 0)::bigint"), - sql::>(&format!( - "CASE WHEN event_type = 'request' THEN right(text, {OUTLINE_PREVIEW_CHARS}) \ - ELSE left(text, {OUTLINE_PREVIEW_CHARS}) END" - )), )) .load::(connection)?; let event_ids = rows.iter().map(|row| row.0).collect::>(); @@ -467,8 +460,6 @@ pub fn session_outline( reasoning_tokens: row.11, total_tokens: row.12, observed_at: row.13, - text_chars: row.14, - preview: row.15, attachment_count: attachment_counts.get(&row.0).copied().unwrap_or(0), }) .collect::>(); @@ -735,8 +726,8 @@ pub fn session_header( "session not found: {session_pk}" ))); } - let counts = stream_counts(stream_events(connection, user_id, session_pk)?); - Ok(header_of(session_pk, events, counts)) + let summary = stream_summary(stream_events(connection, user_id, session_pk)?); + Ok(header_of(session_pk, events, summary)) } fn header_event( @@ -859,6 +850,9 @@ fn stream_events( llm_usage_event_stream_facts::result_ids.nullable(), llm_usage_event_stream_facts::emitted_ids.nullable(), llm_usage_event_stream_facts::output_skeleton.nullable(), + llm_usage_event_stream_facts::label_index.nullable(), + llm_usage_event_stream_facts::label_text.nullable(), + llm_usage_event_stream_facts::label_length.nullable(), )) .load::(connection)? .into_iter() @@ -874,6 +868,7 @@ fn stream_event(row: StreamEventRow) -> Result { last_identity: row.last_identity, trailing: json_list(row.trailing, "trailing roles")?, result_ids: json_list(row.result_ids, "result ids")?, + label: Label::from_columns(row.label_index, row.label_text, row.label_length), }) } else { EventFacts::Response(ResponseFacts { @@ -922,6 +917,9 @@ fn stream_facts(event_pk: Uuid, event: &IngestUsageEvent) -> Result Self { - let projection = SearchProjection::from_source(event.text, &event.metadata); + let projection = SearchProjection::from_source(&event.metadata); Self { event_pk: event.id, user_id: event.user_id, @@ -86,46 +86,39 @@ mod tests { use super::{HIGHLIGHT_END, HIGHLIGHT_START, MAX_CONTENT_CHARACTERS, SearchDocument}; + /// `entries` as an event's complete conversation. + fn conversation(entries: &serde_json::Value) -> serde_json::Value { + json!({"conversation": {"entries": entries, "completeness": "complete"}}) + } + #[test] - fn extracts_only_searchable_tool_metadata() { + fn indexes_every_entry_text_and_the_called_tool_names_only() { let mut event = usage_event(); - event.metadata = json!({ - "authorization": "Bearer secret", - "content_segments": [ - { - "type": "tool_call", - "name": "Bash", - "input": "{\"command\":\"cargo test\",\"nested\":{\"file_path\":\"src/lib.rs\"}}" - }, - { - "type": "tool_result", - "output": "all tests passed" - }, - { - "type": "image", - "content_base64": "secret-image" - } - ] - }); + event.metadata = conversation(&json!([ + {"role": "user", "text": "run the tests"}, + {"role": "tool_call", "text": "{\"command\":\"cargo test\"}", "call_id": "c1", "name": "Bash"}, + {"role": "tool_result", "text": "all tests passed", "call_id": "c1"} + ])); + event.metadata["headers"] = json!([{"name": "authorization", "value": "Bearer secret"}]); let document = SearchDocument::from_event(event); assert_eq!(document.tool_names, ["Bash"]); - // Tool inputs are searchable as written; no field is pulled out by name. assert_eq!( - document.tool_content[0], - "{\"command\":\"cargo test\",\"nested\":{\"file_path\":\"src/lib.rs\"}}" + document.content.as_deref(), + Some("run the tests\n{\"command\":\"cargo test\"}\nall tests passed") ); - assert_eq!(document.tool_content.len(), 2); + assert!(document.tool_content.is_empty()); let serialized = serde_json::to_string(&document).expect("document should serialize"); assert!(!serialized.contains("Bearer secret")); - assert!(!serialized.contains("secret-image")); } #[test] fn truncates_oversized_content_on_a_utf8_boundary() { let mut event = usage_event(); - event.text = Some("会".repeat(MAX_CONTENT_CHARACTERS + 1)); + event.metadata = conversation(&json!([ + {"role": "user", "text": "会".repeat(MAX_CONTENT_CHARACTERS + 1)} + ])); let document = SearchDocument::from_event(event); @@ -143,26 +136,15 @@ mod tests { fn strips_highlight_markers_from_every_searchable_field() { let mut event = usage_event(); event.session_id = format!("session-{HIGHLIGHT_START}one{HIGHLIGHT_END}"); - event.text = Some(format!( - "before {HIGHLIGHT_START}match{HIGHLIGHT_END} after" - )); - event.metadata = json!({ - "content_segments": [ - { - "type": "tool_call", - "name": format!("{HIGHLIGHT_START}Bash{HIGHLIGHT_END}"), - "input": serde_json::to_string(&json!({ - "command": format!("cargo {HIGHLIGHT_START}test{HIGHLIGHT_END}"), - "file_path": format!("src/{HIGHLIGHT_END}lib.rs") - })) - .expect("tool input should serialize") - }, - { - "type": "tool_result", - "output": format!("{HIGHLIGHT_START}passed{HIGHLIGHT_END}") - } - ] - }); + event.metadata = conversation(&json!([ + {"role": "user", "text": format!("before {HIGHLIGHT_START}match{HIGHLIGHT_END} after")}, + { + "role": "tool_call", + "text": format!("cargo {HIGHLIGHT_START}test{HIGHLIGHT_END}"), + "call_id": "c1", + "name": format!("{HIGHLIGHT_START}Bash{HIGHLIGHT_END}") + } + ])); let serialized = serde_json::to_string(&SearchDocument::from_event(event)) .expect("document should serialize"); @@ -186,7 +168,7 @@ mod tests { llm_provider: "openai".to_owned(), llm_model: "gpt-5".to_owned(), event_type: "request".to_owned(), - text: Some("hello".to_owned()), + text: None, text_sha256: None, input_tokens: 1, output_tokens: 0, diff --git a/crates/abyss-backend/src/storage/sqlite/models.rs b/crates/abyss-backend/src/storage/sqlite/models.rs index 2297180..e1e64dc 100644 --- a/crates/abyss-backend/src/storage/sqlite/models.rs +++ b/crates/abyss-backend/src/storage/sqlite/models.rs @@ -10,7 +10,7 @@ use crate::{ error::AppError, usage::{ event_order::TimelineEvent, - streams::{EventFacts, RequestFacts, ResponseFacts, StreamEvent}, + streams::{EventFacts, Label, RequestFacts, ResponseFacts, StreamEvent}, }, }; @@ -127,8 +127,6 @@ pub(super) struct EventRow { pub(super) llm_provider: String, pub(super) llm_model: String, pub(super) event_type: String, - pub(super) text: Option, - pub(super) text_sha256: Option>, pub(super) input_tokens: i64, pub(super) output_tokens: i64, pub(super) cache_read_tokens: i64, @@ -159,8 +157,6 @@ pub(super) struct NewEvent { pub(super) llm_provider: String, pub(super) llm_model: String, pub(super) event_type: String, - pub(super) text: Option, - pub(super) text_sha256: Option>, pub(super) input_tokens: i64, pub(super) output_tokens: i64, pub(super) cache_read_tokens: i64, @@ -187,6 +183,9 @@ pub(super) struct NewStreamFacts { pub(super) result_ids: Option, pub(super) emitted_ids: Option, pub(super) output_skeleton: Option, + pub(super) label_index: Option, + pub(super) label_text: Option, + pub(super) label_length: Option, } impl NewStreamFacts { @@ -199,6 +198,9 @@ impl NewStreamFacts { last_identity: facts.last_identity.clone(), trailing: Some(json_text(&facts.trailing, "trailing roles")?), result_ids: Some(json_text(&facts.result_ids, "result ids")?), + label_index: facts.label.as_ref().map(|label| label.index), + label_text: facts.label.as_ref().map(|label| label.text.clone()), + label_length: facts.label.as_ref().map(|label| label.length), ..Self::default() }) } @@ -231,6 +233,9 @@ pub(super) struct StreamEventRow { pub(super) result_ids: Option, pub(super) emitted_ids: Option, pub(super) output_skeleton: Option, + pub(super) label_index: Option, + pub(super) label_text: Option, + pub(super) label_length: Option, } impl StreamEventRow { @@ -242,6 +247,7 @@ impl StreamEventRow { last_identity: self.last_identity, trailing: json_list(self.trailing.as_deref(), "trailing roles")?, result_ids: json_list(self.result_ids.as_deref(), "result ids")?, + label: Label::from_columns(self.label_index, self.label_text, self.label_length), }) } else { EventFacts::Response(ResponseFacts { @@ -370,8 +376,6 @@ pub(super) struct EventRecord { pub(super) llm_provider: String, pub(super) llm_model: String, pub(super) event_type: String, - pub(super) text: Option, - pub(super) text_sha256: Option>, pub(super) input_tokens: i64, pub(super) output_tokens: i64, pub(super) cache_read_tokens: i64, @@ -480,8 +484,6 @@ impl EventRow { llm_provider: self.llm_provider, llm_model: self.llm_model, event_type: self.event_type, - text: self.text, - text_sha256: self.text_sha256, input_tokens: self.input_tokens, output_tokens: self.output_tokens, cache_read_tokens: self.cache_read_tokens, diff --git a/crates/abyss-backend/src/storage/sqlite/repository.rs b/crates/abyss-backend/src/storage/sqlite/repository.rs index ca9035c..0af3054 100644 --- a/crates/abyss-backend/src/storage/sqlite/repository.rs +++ b/crates/abyss-backend/src/storage/sqlite/repository.rs @@ -6,10 +6,10 @@ use chrono::{DateTime, Utc}; use diesel::{ ExpressionMethods, IntoSql, NullableExpressionMethods, QueryDsl, QueryableByName, RunQueryDsl, SelectableHelper, SqliteConnection, - dsl::{count_star, max, sql}, + dsl::{count_star, max}, result::OptionalExtension, sql_query, - sql_types::{BigInt, Binary, Bool, Nullable, Text}, + sql_types::{BigInt, Bool, Nullable, Text}, }; use uuid::Uuid; @@ -17,10 +17,10 @@ use crate::{ error::AppError, search::projection::SearchProjection, usage::{ - IngestEventsRequest, IngestEventsResponse, IngestUsageEvent, OUTLINE_PREVIEW_CHARS, - RawEventsQuery, RawEventsResponse, SessionInfo, SessionOutlineResponse, - SessionTimelineResponse, SummaryQuery, SummaryResponse, SummaryRow, TurnTimeline, - UsageEventOutline, UsageEventResponse, UsageEventType, + IngestEventsRequest, IngestEventsResponse, IngestUsageEvent, RawEventsQuery, + RawEventsResponse, SessionInfo, SessionOutlineResponse, SessionTimelineResponse, + SummaryQuery, SummaryResponse, SummaryRow, TurnTimeline, UsageEventOutline, + UsageEventResponse, UsageEventType, attachments::{ ImageMediaType, StoredImageAttachment, UsageEventAttachmentResponse, ValidatedImageAttachment, @@ -29,7 +29,7 @@ use crate::{ event_order::UsageEventTimelineOrder, persistence::{ exact_values_filter, non_empty, normalize_limit, normalize_text, parse_group_by, - sha256_bytes, validate_batch, validate_event_type, + validate_batch, validate_event_type, }, preview::{ConversationPreview, Window, preview}, segments::{ @@ -38,7 +38,7 @@ use crate::{ session_header::{HeaderEvent, SessionHeaderResponse, session_header as header_of}, streams::{ RequestFacts, ResponseFacts, SessionStreamsResponse, StreamEvent, segment_page, - session_streams as streams_of, stream_counts, + session_streams as streams_of, stream_summary, }, }, }; @@ -143,7 +143,6 @@ fn ingest_one_event( now, )?; let tokens = &event.token_usage; - let text = event.text.clone(); let event_pk = Uuid::now_v7(); let metadata = serialize_json(&event.metadata, "event metadata")?; @@ -162,8 +161,6 @@ fn ingest_one_event( llm_provider: provider, llm_model: model, event_type: event.event_type.as_str().to_owned(), - text: text.clone(), - text_sha256: text.as_deref().map(sha256_bytes), input_tokens: tokens.input_tokens.unwrap_or_default(), output_tokens: tokens.output_tokens.unwrap_or_default(), cache_read_tokens: tokens.cache_read_tokens.unwrap_or_default(), @@ -505,7 +502,7 @@ fn insert_search_projection( session_pk: Uuid, event: &IngestUsageEvent, ) -> Result<(), AppError> { - let projection = SearchProjection::from_source(event.text.clone(), &event.metadata); + let projection = SearchProjection::from_source(&event.metadata); sql_query( "INSERT INTO usage_events_fts ( event_pk, user_id, session_pk, session_id, content, tool_names, @@ -587,8 +584,8 @@ pub(super) fn session_header( "session not found: {session_pk}" ))); } - let counts = stream_counts(stream_events(connection, user_id, session_pk)?); - Ok(header_of(session_pk, events, counts)) + let summary = stream_summary(stream_events(connection, user_id, session_pk)?); + Ok(header_of(session_pk, events, summary)) } fn header_event( @@ -715,6 +712,9 @@ fn stream_events( llm_usage_event_stream_facts::result_ids.nullable(), llm_usage_event_stream_facts::emitted_ids.nullable(), llm_usage_event_stream_facts::output_skeleton.nullable(), + llm_usage_event_stream_facts::label_index.nullable(), + llm_usage_event_stream_facts::label_text.nullable(), + llm_usage_event_stream_facts::label_length.nullable(), )) .load::(connection)? .into_iter() @@ -951,8 +951,6 @@ fn load_filtered_events( llm_usage_events::llm_provider, llm_usage_events::llm_model, llm_usage_events::event_type, - None::.into_sql::>(), - None::>.into_sql::>(), llm_usage_events::input_tokens, llm_usage_events::output_tokens, llm_usage_events::cache_read_tokens, @@ -1025,9 +1023,6 @@ type OutlineRow = ( i64, i64, i64, - i64, - Option, - bool, ); pub(super) fn session_outline( @@ -1036,8 +1031,8 @@ pub(super) fn session_outline( session_pk: Uuid, ) -> Result { let (session, turns) = load_session_turns(connection, user_id, session_pk)?; - // Only fixed-size columns and SQL-computed summaries are selected, so the - // outline stays small no matter how large event text or metadata grows. + // Only fixed-size columns are selected, so the outline stays small no + // matter how large event metadata grows. let rows = llm_usage_events::table .filter(llm_usage_events::session_pk.eq(session.id.to_string())) .select(( @@ -1055,32 +1050,8 @@ pub(super) fn session_outline( llm_usage_events::reasoning_tokens, llm_usage_events::total_tokens, llm_usage_events::observed_at, - sql::("coalesce(length(text), 0)"), - sql::>(&format!( - "CASE WHEN event_type = 'request' THEN substr(text, -{OUTLINE_PREVIEW_CHARS}) \ - ELSE substr(text, 1, {OUTLINE_PREVIEW_CHARS}) END" - )), - // SQLite text functions stop at the first NUL character, so the - // length and preview above are wrong for text containing NUL. - sql::("coalesce(instr(CAST(text AS BLOB), x'00') > 0, 0)"), )) .load::(connection)?; - let nul_text_ids = rows - .iter() - .filter(|row| row.16) - .map(|row| row.0.clone()) - .collect::>(); - let nul_texts = if nul_text_ids.is_empty() { - HashMap::new() - } else { - llm_usage_events::table - .filter(llm_usage_events::id.eq_any(nul_text_ids)) - .select((llm_usage_events::id, llm_usage_events::text)) - .load::<(String, Option)>(connection)? - .into_iter() - .filter_map(|(id, text)| text.map(|text| (id, text))) - .collect::>() - }; let event_ids = rows.iter().map(|row| row.0.clone()).collect::>(); let attachment_counts = llm_usage_event_attachments::table .filter(llm_usage_event_attachments::event_pk.eq_any(event_ids)) @@ -1091,11 +1062,8 @@ pub(super) fn session_outline( .collect::>(); let mut events = rows .into_iter() - .map(|mut row| { + .map(|row| { let attachment_count = attachment_counts.get(&row.0).copied().unwrap_or(0); - if let Some(text) = nul_texts.get(&row.0) { - (row.14, row.15) = text_outline(text, &row.6)?; - } Ok(UsageEventOutline { id: parse_uuid(&row.0, "event id")?, event_id: row.1, @@ -1111,8 +1079,6 @@ pub(super) fn session_outline( reasoning_tokens: row.11, total_tokens: row.12, observed_at: timestamp_from_micros(row.13)?, - text_chars: row.14, - preview: row.15, attachment_count, }) }) @@ -1127,23 +1093,6 @@ pub(super) fn session_outline( Ok(timeline) } -/// Computes the outline length and preview in Rust for text SQLite cannot measure. -fn text_outline(text: &str, event_type: &str) -> Result<(i64, Option), AppError> { - let chars = text.chars().count(); - let preview_chars = usize::try_from(OUTLINE_PREVIEW_CHARS) - .map_err(|error| AppError::internal(format!("invalid outline preview size: {error}")))?; - let preview = if event_type == "request" { - text.chars() - .skip(chars.saturating_sub(preview_chars)) - .collect() - } else { - text.chars().take(preview_chars).collect() - }; - let chars = i64::try_from(chars) - .map_err(|error| AppError::internal(format!("invalid event text length: {error}")))?; - Ok((chars, Some(preview))) -} - fn load_session_turns( connection: &mut SqliteConnection, user_id: Uuid, @@ -1613,8 +1562,6 @@ fn event_response( llm_provider: event.llm_provider, llm_model: event.llm_model, event_type: event.event_type, - text: event.text, - text_sha256: event.text_sha256.map(hex::encode), input_tokens: event.input_tokens, output_tokens: event.output_tokens, cache_read_tokens: event.cache_read_tokens, @@ -1663,8 +1610,8 @@ mod tests { schema::{agent_sessions, agent_turns, llm_usage_event_stream_facts, llm_usage_events}, search, }, - event_conversation, ingest_events, raw_events, session_header, session_outline, - session_segment, session_streams, usage_summary, + event_conversation, ingest_events, raw_events, session_header, session_segment, + session_streams, usage_summary, }; const OWNER_ID: Uuid = Uuid::from_u128(1); @@ -1749,29 +1696,6 @@ mod tests { assert_eq!(fts_count, 0); } - #[test] - fn outline_measures_text_containing_nul_characters() { - let mut database = database(); - let text = format!("ab\0{}", "c".repeat(300)); - ingest_events(&mut database, &request_with_text(&text), OWNER_ID, 100) - .expect("ingest should succeed"); - let session_pk = llm_usage_events::table - .select(llm_usage_events::session_pk) - .first::(&mut database) - .expect("event should exist"); - let session_pk = Uuid::parse_str(&session_pk).expect("session pk should be a UUID"); - - let outline = - session_outline(&mut database, OWNER_ID, session_pk).expect("outline should load"); - - let event = &outline.turns[0].events[0]; - assert_eq!(event.text_chars, 303); - assert_eq!( - event.preview.as_deref(), - Some(format!("ab\0{}", "c".repeat(197)).as_str()) - ); - } - #[test] fn raw_body_archive_survives_ingest_retries_and_trajectory_projection() { use base64::{Engine as _, engine::general_purpose::STANDARD}; @@ -1823,10 +1747,6 @@ mod tests { event.llm.model = " Model ".to_owned(); event.device.platform = "macOS".to_owned(); ingest_events(&mut database, &request, OWNER_ID, 100).expect("ingest should succeed"); - let mut empty = request_with_text(""); - empty.events[0].event_id = "sqlite-empty-text".to_owned(); - ingest_events(&mut database, &empty, OWNER_ID, 100).expect("ingest should succeed"); - let query: RawEventsQuery = serde_json::from_value(json!({})).expect("query should deserialize"); let events = raw_events(&mut database, &query, OWNER_ID, 50) @@ -1840,12 +1760,10 @@ mod tests { assert_eq!(stored.llm_provider, "Gateway_Host"); assert_eq!(stored.llm_model, " Model "); assert_eq!(stored.platform.as_deref(), Some("macOS")); - assert_eq!(stored.text.as_deref(), Some(" hello \n")); - let empty = events - .iter() - .find(|event| event.event_id == "sqlite-empty-text") - .expect("the empty-text event should be stored"); - assert_eq!(empty.text.as_deref(), Some("")); + assert_eq!( + stored.metadata["conversation"]["entries"][0]["text"], + json!(" hello \n") + ); } #[test] @@ -1974,6 +1892,14 @@ mod tests { .map(|segment| (segment.head.as_str(), segment.exchange_count)) .collect::>(); assert_eq!(segments, [("r1", 2)], "r2 continues r1 by its call"); + let label = streams.segments[0] + .label + .as_ref() + .expect("r1 asks in words"); + assert_eq!( + (label.entry_index, label.text.as_str(), label.length), + (0_i32, "fix the bug", 11_i32) + ); assert!( session_streams(&mut database, OWNER_ID, Uuid::nil()).is_err(), "a session without events is not found" @@ -2053,6 +1979,11 @@ mod tests { header.end.signed_duration_since(header.start), chrono::TimeDelta::seconds(3) ); + assert_eq!( + header.title.as_ref().map(|title| title.text.as_str()), + Some("fix the bug"), + "the title is the first request's label" + ); assert!(session_header(&mut database, OWNER_ID, Uuid::nil()).is_err()); } @@ -2074,10 +2005,8 @@ mod tests { assert_eq!(listed.id, complete.id); assert_eq!(listed.total_tokens, complete.total_tokens); assert_eq!(listed.host_name.as_deref(), Some("alice-mbp")); - assert_eq!(listed.text, None); assert_eq!(listed.metadata, json!({})); assert!(listed.attachments.is_empty()); - assert_eq!(complete.text.as_deref(), Some("body")); assert_eq!(complete.metadata["raw_body"], json!("large")); } } @@ -2099,7 +2028,6 @@ mod tests { "turn_id": format!("{session}-{turn}"), "llm": {"provider": provider, "model": model}, "event_type": if id.ends_with("req") { "request" } else { "response" }, - "text": "body", "token_usage": if id.ends_with("req") { json!({"input_tokens": 10_i64, "output_tokens": 0_i64, "cache_read_tokens": 4_i64, "cache_write_tokens": 0_i64, "reasoning_tokens": 0_i64, "total_tokens": 14_i64}) } else { @@ -2448,9 +2376,11 @@ mod tests { "session_id": "sqlite-session", "llm": {"provider": "openai", "model": "gpt-5"}, "event_type": "response", - "text": text, "token_usage": {"input_tokens": 0_i64, "output_tokens": 3_i64, "cache_read_tokens": 0_i64, "cache_write_tokens": 0_i64, "reasoning_tokens": 0_i64, "total_tokens": 3_i64}, - "metadata": {} + "metadata": {"conversation": { + "entries": [{"role": "assistant", "text": text}], + "completeness": "complete" + }} }] })) .expect("test ingest request should deserialize") diff --git a/crates/abyss-backend/src/storage/sqlite/schema.rs b/crates/abyss-backend/src/storage/sqlite/schema.rs index 68ad125..b59f70e 100644 --- a/crates/abyss-backend/src/storage/sqlite/schema.rs +++ b/crates/abyss-backend/src/storage/sqlite/schema.rs @@ -107,6 +107,9 @@ diesel::table! { result_ids -> Nullable, emitted_ids -> Nullable, output_skeleton -> Nullable, + label_index -> Nullable, + label_text -> Nullable, + label_length -> Nullable, } } diff --git a/crates/abyss-backend/src/storage/sqlite/search.rs b/crates/abyss-backend/src/storage/sqlite/search.rs index 2bc5c85..fdfa9d1 100644 --- a/crates/abyss-backend/src/storage/sqlite/search.rs +++ b/crates/abyss-backend/src/storage/sqlite/search.rs @@ -603,9 +603,11 @@ mod tests { "turn_index": 1, "llm": {"provider": "openai", "model": "gpt-5"}, "event_type": "response", - "text": text, "token_usage": {"input_tokens": 0_i64, "output_tokens": 1_i64, "cache_read_tokens": 0_i64, "cache_write_tokens": 0_i64, "reasoning_tokens": 0_i64, "total_tokens": 1_i64}, - "metadata": {} + "metadata": {"conversation": { + "entries": [{"role": "assistant", "text": text}], + "completeness": "complete" + }} }) } diff --git a/crates/abyss-backend/src/storage/sqlite/session_list.rs b/crates/abyss-backend/src/storage/sqlite/session_list.rs index 74390a9..80f6f06 100644 --- a/crates/abyss-backend/src/storage/sqlite/session_list.rs +++ b/crates/abyss-backend/src/storage/sqlite/session_list.rs @@ -216,7 +216,6 @@ mod tests { "turn_id": format!("{session}-{turn}"), "llm": {"provider": "OpenAI", "model": model}, "event_type": "response", - "text": "reply", "token_usage": {"input_tokens": 0_i64, "output_tokens": 10_i64, "cache_read_tokens": 0_i64, "cache_write_tokens": 0_i64, "reasoning_tokens": 0_i64, "total_tokens": 10_i64}, "metadata": {} }) diff --git a/crates/abyss-backend/src/usage/mod.rs b/crates/abyss-backend/src/usage/mod.rs index 8cd0cf0..c8622ed 100644 --- a/crates/abyss-backend/src/usage/mod.rs +++ b/crates/abyss-backend/src/usage/mod.rs @@ -73,8 +73,6 @@ pub struct IngestUsageEvent { pub llm: LlmPayload, /// Whether the event represents the request or response side of a call. pub event_type: UsageEventType, - /// Optional prompt or response text. - pub text: Option, /// Token counters of the `AgentEvent`; absent counters are stored as zero. #[serde(default)] pub token_usage: TokenUsagePayload, @@ -553,10 +551,6 @@ pub struct UsageEventResponse { pub llm_model: String, /// Canonical request or response value. pub event_type: String, - /// Optional prompt or response text. - pub text: Option, - /// Lowercase SHA-256 of text when text is present. - pub text_sha256: Option, /// Normalized input-token count. pub input_tokens: i64, /// Normalized output-token count. @@ -598,14 +592,11 @@ pub struct SessionTimelineResponse { pub turns: Vec>, } -/// Session timeline whose events omit text and metadata bodies. +/// Session timeline whose events omit metadata bodies. pub type SessionOutlineResponse = SessionTimelineResponse; -/// Maximum number of characters returned in an outline event preview. -pub const OUTLINE_PREVIEW_CHARS: i64 = 200; - #[derive(Debug, Serialize)] -/// Content-free representation of one event, sized independently of its text. +/// Content-free representation of one event, sized independently of its content. /// /// Full content is loaded separately by event primary key. pub struct UsageEventOutline { @@ -638,11 +629,6 @@ pub struct UsageEventOutline { pub total_tokens: i64, /// Collector observation timestamp. pub observed_at: DateTime, - /// Text length in characters; zero when text is absent. - pub text_chars: i64, - /// Up to [`OUTLINE_PREVIEW_CHARS`] characters of text: the tail of a - /// request, where the newest prompt is, or the head of a response. - pub preview: Option, /// Number of image attachments on the event. pub attachment_count: i64, } @@ -728,7 +714,6 @@ mod tests { "model": "gpt-5.5" }, "event_type": "request", - "text": "Explain this repository.", "token_usage": {"input_tokens": 10_i64, "output_tokens": 0_i64, "cache_read_tokens": 2_i64, "cache_write_tokens": 0_i64, "reasoning_tokens": 0_i64, "total_tokens": 12_i64}, "metadata": { "source": "unit-test" diff --git a/crates/abyss-backend/src/usage/persistence.rs b/crates/abyss-backend/src/usage/persistence.rs index e8938f8..b2bbff5 100644 --- a/crates/abyss-backend/src/usage/persistence.rs +++ b/crates/abyss-backend/src/usage/persistence.rs @@ -4,7 +4,6 @@ use std::collections::HashSet; #[cfg(feature = "postgres-es")] use chrono::{DateTime, Utc}; -use sha2::{Digest, Sha256}; use crate::error::AppError; @@ -145,10 +144,6 @@ pub fn normalize_limit(limit: Option, fallback: i64, maximum: i64) -> i64 { limit.unwrap_or(fallback).clamp(1_i64, maximum) } -pub fn sha256_bytes(value: &str) -> Vec { - Sha256::digest(value.as_bytes()).to_vec() -} - #[cfg(test)] mod tests { use super::{escape_like_pattern, exact_values_filter, parse_group_by}; diff --git a/crates/abyss-backend/src/usage/session_header.rs b/crates/abyss-backend/src/usage/session_header.rs index d26dda2..22618f4 100644 --- a/crates/abyss-backend/src/usage/session_header.rs +++ b/crates/abyss-backend/src/usage/session_header.rs @@ -6,7 +6,10 @@ use chrono::{DateTime, Utc}; use serde::Serialize; use uuid::Uuid; -use super::{segments::TokenCounts, streams::StreamCounts}; +use super::{ + segments::TokenCounts, + streams::{EntryLabel, StreamSummary}, +}; /// The columns of one session event the header reads. pub struct HeaderEvent { @@ -26,6 +29,8 @@ pub struct SessionHeaderResponse { pub session_pk: Uuid, /// Session id the agent declared. pub session_id: String, + /// The new user words of the session's first request. + pub title: Option, /// Agents that reported events, by name and version. pub agents: Vec, /// Earliest event time. @@ -53,7 +58,7 @@ pub struct HeaderAgent { pub fn session_header( session_pk: Uuid, events: Vec, - counts: StreamCounts, + summary: StreamSummary, ) -> SessionHeaderResponse { let start = events.iter().map(|event| event.observed_at).min(); let end = events.iter().map(|event| event.observed_at).max(); @@ -78,13 +83,14 @@ pub fn session_header( SessionHeaderResponse { session_pk, session_id, + title: summary.title, agents: agents.into_iter().collect(), start: start.unwrap_or_default(), end: end.unwrap_or_default(), event_count, - exchange_count: counts.exchanges, - turn_count: counts.turns, - segment_count: counts.segments, + exchange_count: summary.exchanges, + turn_count: summary.turns, + segment_count: summary.segments, tokens, providers: providers.into_iter().collect(), models: models.into_iter().collect(), diff --git a/crates/abyss-backend/src/usage/streams.rs b/crates/abyss-backend/src/usage/streams.rs index 81fec98..96675a9 100644 --- a/crates/abyss-backend/src/usage/streams.rs +++ b/crates/abyss-backend/src/usage/streams.rs @@ -22,7 +22,7 @@ use uuid::Uuid; pub use self::{ base::{BaseRule, NoBaseReason}, - facts::{RequestFacts, ResponseFacts}, + facts::{Label, RequestFacts, ResponseFacts}, segmentation::SegmentKind, }; use self::{ @@ -83,6 +83,22 @@ pub struct StreamSegment { pub end: DateTime, /// Number of exchanges in the segment. pub exchange_count: usize, + /// The new user words of the segment's first request. + pub label: Option, +} + +#[derive(Debug, Serialize)] +/// Where a request's new user words are, and how they begin: the last +/// `user` entry with text in its new part. +pub struct EntryLabel { + /// The request event. + pub event_pk: Uuid, + /// Index of the entry among the event's entries. + pub entry_index: i32, + /// At most the first 50 UTF-16 code units of the entry's text. + pub text: String, + /// Length of the entry's whole text in UTF-16 code units. + pub length: i32, } /// Exchanges of one session, the base of each, and the segments they form. @@ -116,6 +132,18 @@ impl Streams { } } + /// The label of an exchange's request. + fn label(&self, exchange: usize) -> Option { + let request = self.exchanges.get(exchange)?.request.as_ref()?; + let label = request.facts.label.as_ref()?; + Some(EntryLabel { + event_pk: request.pk, + entry_index: label.index, + text: label.text.clone(), + length: label.length, + }) + } + fn base(&self, exchange: usize) -> Option { self.bases[exchange].ok().map(|(base, _)| base) } @@ -145,6 +173,7 @@ impl Streams { .max() .unwrap_or_default(), exchange_count: segment.exchanges.len(), + label: self.label(first), } } } @@ -167,19 +196,22 @@ pub fn session_streams(session_pk: Uuid, events: Vec) -> SessionStr } } -/// How many exchanges, turns, and segments one session's events form. -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] -pub struct StreamCounts { +/// How many exchanges, turns, and segments one session's events form, and +/// the new user words of its first request. +#[derive(Debug)] +pub struct StreamSummary { pub exchanges: usize, /// Requests that open a turn. pub turns: usize, pub segments: usize, + pub title: Option, } -/// Counts the exchanges, turns, and segments of one session's events. -pub fn stream_counts(events: Vec) -> StreamCounts { +/// Sums up the streams of one session's events. +pub fn stream_summary(events: Vec) -> StreamSummary { let streams = Streams::derive(events); - StreamCounts { + StreamSummary { + title: streams.label(0), exchanges: streams.exchanges.len(), turns: streams .exchanges diff --git a/crates/abyss-backend/src/usage/streams/facts.rs b/crates/abyss-backend/src/usage/streams/facts.rs index da84c58..1cd7ef4 100644 --- a/crates/abyss-backend/src/usage/streams/facts.rs +++ b/crates/abyss-backend/src/usage/streams/facts.rs @@ -92,6 +92,60 @@ pub struct RequestFacts { /// Whether the new part holds a `user` entry with text and no /// `tool_result` entry. pub starts_turn: bool, + /// The last `user` entry with text in the new part. + pub label: Option