mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-30 15:03:57 +00:00
feat(transcript): derived transcript view — append-only session log as source of truth (#5086)
This commit is contained in:
@@ -511,6 +511,7 @@ impl AgentBuilder {
|
||||
// `subagents` declaration against the global registry.
|
||||
agent_definition_id: agent_definition_name.clone(),
|
||||
session_transcript_path: None,
|
||||
persisted_transcript_messages: Vec::new(),
|
||||
session_key: {
|
||||
let unix_ts = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
|
||||
@@ -1481,6 +1481,81 @@ fn seed_resume_from_thread_transcript_preserves_tool_calls_and_reasoning() {
|
||||
);
|
||||
}
|
||||
|
||||
/// Cold-boot resume over an **append-only** transcript that carries a
|
||||
/// compaction record: the resumed model context must equal the REDUCED set the
|
||||
/// compaction installed (byte-identical to what the old full-rewrite produced),
|
||||
/// not the full pre-compaction history.
|
||||
#[test]
|
||||
fn seed_resume_replays_compaction_to_reduced_context() {
|
||||
use super::transcript::{self, TranscriptMeta};
|
||||
use crate::openhuman::inference::provider::ChatMessage;
|
||||
|
||||
let ws = tempfile::TempDir::new().expect("temp workspace");
|
||||
let wsp = ws.path().to_path_buf();
|
||||
let thread_id = "thr_compaction_resume";
|
||||
|
||||
let meta = TranscriptMeta {
|
||||
agent_name: "orchestrator_thread-compact".to_string(),
|
||||
agent_id: Some("orchestrator".to_string()),
|
||||
agent_type: Some("root".to_string()),
|
||||
dispatcher: "native".to_string(),
|
||||
provider: None,
|
||||
model: None,
|
||||
created: "2026-01-01T00:00:00Z".to_string(),
|
||||
updated: "2026-01-01T00:00:00Z".to_string(),
|
||||
turn_count: 2,
|
||||
input_tokens: 0,
|
||||
output_tokens: 0,
|
||||
cached_input_tokens: 0,
|
||||
charged_amount_usd: 0.0,
|
||||
thread_id: Some(thread_id.to_string()),
|
||||
task_id: None,
|
||||
};
|
||||
let path = transcript::resolve_keyed_transcript_path(&wsp, "1700000000_orchestrator")
|
||||
.expect("resolve transcript path");
|
||||
|
||||
// Turn 1: a full exchange. Turn 2: a context reduction (not a prefix) that
|
||||
// must land as a compaction record.
|
||||
let full = vec![
|
||||
ChatMessage::system("system prompt"),
|
||||
ChatMessage::user("q1"),
|
||||
ChatMessage::assistant("a1"),
|
||||
ChatMessage::user("q2"),
|
||||
ChatMessage::assistant("a2"),
|
||||
];
|
||||
transcript::append_transcript_turn(&path, &[], &full, &meta, None, None)
|
||||
.expect("append turn 1");
|
||||
let reduced = vec![
|
||||
ChatMessage::system("system prompt"),
|
||||
ChatMessage::assistant("[summary] q1/q2"),
|
||||
ChatMessage::user("q3"),
|
||||
ChatMessage::assistant("a3"),
|
||||
];
|
||||
transcript::append_transcript_turn(&path, &full, &reduced, &meta, None, None)
|
||||
.expect("append turn 2 (compaction)");
|
||||
|
||||
let mut agent = build_minimal_agent_with_definition_name(Some("some_other_agent_name"));
|
||||
agent.workspace_dir = wsp.clone();
|
||||
|
||||
let loaded = agent.seed_resume_from_thread_transcript(thread_id);
|
||||
assert!(
|
||||
loaded,
|
||||
"cold-boot resume must load the compacted transcript"
|
||||
);
|
||||
let cached = agent
|
||||
.cached_transcript_messages
|
||||
.as_ref()
|
||||
.expect("cached transcript populated");
|
||||
assert_eq!(
|
||||
cached
|
||||
.iter()
|
||||
.map(|m| m.content.as_str())
|
||||
.collect::<Vec<_>>(),
|
||||
vec!["system prompt", "[summary] q1/q2", "q3", "a3"],
|
||||
"resumed context must be the reduced set the compaction installed"
|
||||
);
|
||||
}
|
||||
|
||||
/// When no root transcript exists for the thread, the transcript resume is a
|
||||
/// no-op returning `false` so the caller falls back to prose-pair seeding.
|
||||
#[test]
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -976,3 +976,367 @@ fn read_thread_usage_summary_groups_subagents_by_archetype() {
|
||||
assert_eq!(researcher.input_tokens, 500);
|
||||
assert_eq!(researcher.runs, 1);
|
||||
}
|
||||
|
||||
// ── Phase A: append-only + compaction + display + interrupted ─────────
|
||||
|
||||
/// A helper mirroring the in-process persist loop: track the previously
|
||||
/// persisted logical set and feed each turn through `append_transcript_turn`.
|
||||
struct AppendHarness {
|
||||
path: std::path::PathBuf,
|
||||
prev: Vec<ChatMessage>,
|
||||
}
|
||||
|
||||
impl AppendHarness {
|
||||
fn new(path: std::path::PathBuf) -> Self {
|
||||
Self {
|
||||
path,
|
||||
prev: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn turn(
|
||||
&mut self,
|
||||
messages: &[ChatMessage],
|
||||
meta: &TranscriptMeta,
|
||||
usage: Option<&TurnUsage>,
|
||||
request_id: Option<&str>,
|
||||
) {
|
||||
append_transcript_turn(&self.path, &self.prev, messages, meta, usage, request_id)
|
||||
.expect("append turn");
|
||||
self.prev = messages.to_vec();
|
||||
}
|
||||
}
|
||||
|
||||
fn roles(messages: &[ChatMessage]) -> Vec<&str> {
|
||||
messages.iter().map(|m| m.role.as_str()).collect()
|
||||
}
|
||||
|
||||
/// Pure extension across turns: the model-context read reflects the final
|
||||
/// (growing) message set and never rewrites earlier lines.
|
||||
#[test]
|
||||
fn append_pure_extension_grows_context() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let path = dir.path().join("append.jsonl");
|
||||
let meta = sample_meta();
|
||||
let mut h = AppendHarness::new(path.clone());
|
||||
|
||||
let turn1 = vec![
|
||||
ChatMessage::system("sys"),
|
||||
ChatMessage::user("hi"),
|
||||
ChatMessage::assistant("hello"),
|
||||
];
|
||||
h.turn(&turn1, &meta, None, None);
|
||||
|
||||
let mut turn2 = turn1.clone();
|
||||
turn2.push(ChatMessage::user("again"));
|
||||
turn2.push(ChatMessage::assistant("hello again"));
|
||||
h.turn(&turn2, &meta, None, None);
|
||||
|
||||
let loaded = read_transcript(&path).unwrap();
|
||||
assert_eq!(
|
||||
roles(&loaded.messages),
|
||||
vec!["system", "user", "assistant", "user", "assistant"]
|
||||
);
|
||||
assert_eq!(loaded.messages[4].content, "hello again");
|
||||
}
|
||||
|
||||
/// Compaction round-trip: after a reduction, the model-context read returns the
|
||||
/// REDUCED context, while the display read returns the FULL pre-compaction
|
||||
/// history plus the compaction marker.
|
||||
#[test]
|
||||
fn compaction_round_trip_model_vs_display() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let path = dir.path().join("compact.jsonl");
|
||||
let meta = sample_meta();
|
||||
let mut h = AppendHarness::new(path.clone());
|
||||
|
||||
// Three growing turns.
|
||||
let base = vec![
|
||||
ChatMessage::system("sys"),
|
||||
ChatMessage::user("q1"),
|
||||
ChatMessage::assistant("a1"),
|
||||
ChatMessage::user("q2"),
|
||||
ChatMessage::assistant("a2"),
|
||||
];
|
||||
h.turn(&base, &meta, None, None);
|
||||
|
||||
// A reduction: the harness drops the earliest exchange and keeps a summary
|
||||
// + the recent tail. This is NOT a prefix of `base`, so it must land as a
|
||||
// compaction record.
|
||||
let reduced = vec![
|
||||
ChatMessage::system("sys"),
|
||||
ChatMessage::assistant("[summary] earlier discussion about q1/q2"),
|
||||
ChatMessage::user("q3"),
|
||||
ChatMessage::assistant("a3"),
|
||||
];
|
||||
h.turn(&reduced, &meta, None, None);
|
||||
|
||||
// Model-context read == the reduced set only.
|
||||
let model = read_transcript(&path).unwrap();
|
||||
assert_eq!(
|
||||
model
|
||||
.messages
|
||||
.iter()
|
||||
.map(|m| m.content.as_str())
|
||||
.collect::<Vec<_>>(),
|
||||
vec![
|
||||
"sys",
|
||||
"[summary] earlier discussion about q1/q2",
|
||||
"q3",
|
||||
"a3"
|
||||
],
|
||||
"model context must reflect the reduced set after compaction"
|
||||
);
|
||||
|
||||
// Display read == full history: the 5 pre-compaction messages, then a
|
||||
// compaction marker carrying the 4-message replacement.
|
||||
let display = read_transcript_display(&path).unwrap();
|
||||
let pre: Vec<&str> = display
|
||||
.records
|
||||
.iter()
|
||||
.take_while(|r| matches!(r, DisplayRecord::Message(_)))
|
||||
.filter_map(|r| match r {
|
||||
DisplayRecord::Message(m) => Some(m.message.content.as_str()),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(pre, vec!["sys", "q1", "a1", "q2", "a2"]);
|
||||
let marker = display
|
||||
.records
|
||||
.iter()
|
||||
.find_map(|r| match r {
|
||||
DisplayRecord::Compaction(c) => Some(c),
|
||||
_ => None,
|
||||
})
|
||||
.expect("display must retain the compaction marker");
|
||||
assert_eq!(marker.replacement.len(), 4);
|
||||
assert_eq!(
|
||||
marker.replacement[1].message.content,
|
||||
"[summary] earlier discussion about q1/q2"
|
||||
);
|
||||
}
|
||||
|
||||
/// After a compaction, a subsequent pure extension appends normally and the
|
||||
/// model-context read replays reset-then-extend to the correct final set.
|
||||
#[test]
|
||||
fn append_after_compaction_extends_reduced_set() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let path = dir.path().join("compact_then_extend.jsonl");
|
||||
let meta = sample_meta();
|
||||
let mut h = AppendHarness::new(path.clone());
|
||||
|
||||
h.turn(
|
||||
&[
|
||||
ChatMessage::system("sys"),
|
||||
ChatMessage::user("q1"),
|
||||
ChatMessage::assistant("a1"),
|
||||
],
|
||||
&meta,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
let reduced = vec![
|
||||
ChatMessage::system("sys"),
|
||||
ChatMessage::assistant("[summary]"),
|
||||
];
|
||||
h.turn(&reduced, &meta, None, None);
|
||||
let mut extended = reduced.clone();
|
||||
extended.push(ChatMessage::user("q2"));
|
||||
extended.push(ChatMessage::assistant("a2"));
|
||||
h.turn(&extended, &meta, None, None);
|
||||
|
||||
let model = read_transcript(&path).unwrap();
|
||||
assert_eq!(
|
||||
model
|
||||
.messages
|
||||
.iter()
|
||||
.map(|m| m.content.as_str())
|
||||
.collect::<Vec<_>>(),
|
||||
vec!["sys", "[summary]", "q2", "a2"]
|
||||
);
|
||||
}
|
||||
|
||||
/// request_id turn-boundary stamping round-trips into the display projection on
|
||||
/// every appended line of a turn.
|
||||
#[test]
|
||||
fn request_id_stamped_on_every_line() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let path = dir.path().join("reqid.jsonl");
|
||||
let meta = sample_meta();
|
||||
let mut h = AppendHarness::new(path.clone());
|
||||
|
||||
let turn1 = vec![ChatMessage::system("sys"), ChatMessage::user("q1")];
|
||||
h.turn(&turn1, &meta, None, Some("req-1"));
|
||||
|
||||
let mut turn2 = turn1.clone();
|
||||
turn2.push(ChatMessage::assistant("a2"));
|
||||
h.turn(&turn2, &meta, None, Some("req-2"));
|
||||
|
||||
let display = read_transcript_display(&path).unwrap();
|
||||
let msgs: Vec<&DisplayMessage> = display
|
||||
.records
|
||||
.iter()
|
||||
.filter_map(|r| match r {
|
||||
DisplayRecord::Message(m) => Some(m),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
// turn1 wrote sys + user with req-1; turn2 appended only the assistant tail
|
||||
// with req-2.
|
||||
assert_eq!(msgs[0].request_id.as_deref(), Some("req-1"));
|
||||
assert_eq!(msgs[1].request_id.as_deref(), Some("req-1"));
|
||||
assert_eq!(msgs[2].request_id.as_deref(), Some("req-2"));
|
||||
assert_eq!(msgs[2].message.content, "a2");
|
||||
}
|
||||
|
||||
/// An interrupted partial is appended to the file, is visible in the display
|
||||
/// read flagged `interrupted`, and is SKIPPED by the model-context read (a
|
||||
/// resumed context never carries a truncated answer).
|
||||
#[test]
|
||||
fn interrupted_partial_display_only() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let path = dir.path().join("interrupted.jsonl");
|
||||
let meta = sample_meta();
|
||||
let mut h = AppendHarness::new(path.clone());
|
||||
h.turn(
|
||||
&[ChatMessage::system("sys"), ChatMessage::user("q1")],
|
||||
&meta,
|
||||
None,
|
||||
Some("req-1"),
|
||||
);
|
||||
|
||||
append_interrupted_partial(
|
||||
&path,
|
||||
"partial answer that was cut off",
|
||||
Some("req-1"),
|
||||
Some(3),
|
||||
Some("thinking that was cut off"),
|
||||
)
|
||||
.expect("append interrupted");
|
||||
|
||||
// Model context: the partial is skipped.
|
||||
let model = read_transcript(&path).unwrap();
|
||||
assert_eq!(roles(&model.messages), vec!["system", "user"]);
|
||||
assert!(
|
||||
!model.messages.iter().any(|m| m.content.contains("cut off")),
|
||||
"interrupted partial must NOT enter the model context"
|
||||
);
|
||||
|
||||
// Display: the partial is present and flagged.
|
||||
let display = read_transcript_display(&path).unwrap();
|
||||
let partial = display
|
||||
.records
|
||||
.iter()
|
||||
.find_map(|r| match r {
|
||||
DisplayRecord::Message(m) if m.interrupted => Some(m),
|
||||
_ => None,
|
||||
})
|
||||
.expect("display must include the interrupted partial");
|
||||
assert_eq!(partial.message.content, "partial answer that was cut off");
|
||||
assert_eq!(partial.request_id.as_deref(), Some("req-1"));
|
||||
assert_eq!(partial.iteration, Some(3));
|
||||
assert_eq!(
|
||||
partial.reasoning_content.as_deref(),
|
||||
Some("thinking that was cut off"),
|
||||
"interrupted partial must carry its reasoning_content"
|
||||
);
|
||||
}
|
||||
|
||||
/// Empty partial content is a no-op — no line is written.
|
||||
#[test]
|
||||
fn interrupted_partial_empty_is_noop() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let path = dir.path().join("empty_interrupt.jsonl");
|
||||
let meta = sample_meta();
|
||||
let mut h = AppendHarness::new(path.clone());
|
||||
h.turn(&[ChatMessage::user("q")], &meta, None, None);
|
||||
append_interrupted_partial(&path, "", None, None, None).expect("noop");
|
||||
let display = read_transcript_display(&path).unwrap();
|
||||
assert!(display
|
||||
.records
|
||||
.iter()
|
||||
.all(|r| matches!(r, DisplayRecord::Message(m) if !m.interrupted)));
|
||||
}
|
||||
|
||||
/// A legacy file — one produced by the full-rewrite `write_transcript` with no
|
||||
/// compaction records and no `version` — reads identically under both the
|
||||
/// model-context and display readers.
|
||||
#[test]
|
||||
fn legacy_file_reads_identically() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let path = dir.path().join("legacy.jsonl");
|
||||
let messages = sample_messages();
|
||||
let meta = sample_meta();
|
||||
// Full-rewrite writer == the legacy shape (append-only readers must tolerate
|
||||
// it: zero compaction records, last `_meta` == the only `_meta`).
|
||||
write_transcript(&path, &messages, &meta, None).unwrap();
|
||||
|
||||
let model = read_transcript(&path).unwrap();
|
||||
assert_eq!(model.messages.len(), messages.len());
|
||||
assert_eq!(roles(&model.messages), roles(&messages));
|
||||
|
||||
let display = read_transcript_display(&path).unwrap();
|
||||
let display_roles: Vec<&str> = display
|
||||
.records
|
||||
.iter()
|
||||
.filter_map(|r| match r {
|
||||
DisplayRecord::Message(m) => Some(m.message.role.as_str()),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(display_roles, roles(&messages));
|
||||
}
|
||||
|
||||
/// A file carrying an unknown record kind (as a future core might write) is
|
||||
/// skipped by the reader rather than crashing it.
|
||||
#[test]
|
||||
fn unknown_record_kind_is_skipped_not_fatal() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let path = dir.path().join("unknown_kind.jsonl");
|
||||
let meta = sample_meta();
|
||||
let mut h = AppendHarness::new(path.clone());
|
||||
h.turn(
|
||||
&[ChatMessage::system("sys"), ChatMessage::user("q1")],
|
||||
&meta,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
// Simulate a future kind by appending a foreign record line.
|
||||
{
|
||||
use std::io::Write;
|
||||
let mut f = std::fs::OpenOptions::new()
|
||||
.append(true)
|
||||
.open(&path)
|
||||
.unwrap();
|
||||
writeln!(f, "{{\"kind\":\"future_thing\",\"payload\":42}}").unwrap();
|
||||
}
|
||||
// Append a normal turn after the unknown line to prove reading continues.
|
||||
let mut msgs = vec![ChatMessage::system("sys"), ChatMessage::user("q1")];
|
||||
msgs.push(ChatMessage::assistant("a1"));
|
||||
h.prev = vec![ChatMessage::system("sys"), ChatMessage::user("q1")];
|
||||
h.turn(&msgs, &meta, None, None);
|
||||
|
||||
let model = read_transcript(&path).unwrap();
|
||||
// The unknown record is skipped; the real messages survive.
|
||||
assert!(model.messages.iter().any(|m| m.content == "a1"));
|
||||
assert!(!model
|
||||
.messages
|
||||
.iter()
|
||||
.any(|m| m.content.contains("future_thing")));
|
||||
}
|
||||
|
||||
/// The `_meta` version field is stamped by the append writer and absent (0) on
|
||||
/// legacy files — but both remain readable.
|
||||
#[test]
|
||||
fn meta_version_stamped_and_optional() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let path = dir.path().join("version.jsonl");
|
||||
let meta = sample_meta();
|
||||
let mut h = AppendHarness::new(path.clone());
|
||||
h.turn(&[ChatMessage::user("q")], &meta, None, None);
|
||||
let raw = std::fs::read_to_string(&path).unwrap();
|
||||
assert!(
|
||||
raw.lines().next().unwrap().contains("\"version\":1"),
|
||||
"append writer must stamp the schema version on the meta header"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -332,6 +332,67 @@ fn tool_records_from_conversation(
|
||||
records
|
||||
}
|
||||
|
||||
/// Stamp each **failed** tool-result [`ChatMessage`] with its failure outcome
|
||||
/// before persistence, so the derived transcript view can render an error tool
|
||||
/// row instead of a false success.
|
||||
///
|
||||
/// The harness folds a tool result into a `role:"tool"` message whose native
|
||||
/// content envelope (`{"tool_call_id":…,"content":…}`) has already dropped
|
||||
/// `ToolResult::is_error`. The only structured per-call success signal is the
|
||||
/// captured [`ToolCallOutcome`] side-channel; correlate by provider call id and
|
||||
/// re-attach an additive failure marker (see
|
||||
/// `transcript::attach_tool_failure_metadata`). Non-tool messages, tool messages
|
||||
/// with no matching outcome, and successful calls are left untouched.
|
||||
fn stamp_tool_failures(
|
||||
messages: &mut [ChatMessage],
|
||||
tool_outcomes: &[crate::openhuman::tinyagents::ToolCallOutcome],
|
||||
) {
|
||||
use crate::openhuman::agent::harness::session::transcript;
|
||||
if tool_outcomes.is_empty() {
|
||||
return;
|
||||
}
|
||||
for msg in messages.iter_mut() {
|
||||
if msg.role != "tool" {
|
||||
continue;
|
||||
}
|
||||
let Some(call_id) = parse_tool_call_id(&msg.content) else {
|
||||
continue;
|
||||
};
|
||||
let Some(outcome) = tool_outcomes.iter().find(|o| o.call_id == call_id) else {
|
||||
continue;
|
||||
};
|
||||
if outcome.success {
|
||||
continue;
|
||||
}
|
||||
let detail = short_failure_detail(&outcome.content);
|
||||
log::debug!(
|
||||
"[transcript] stamping tool failure call_id={call_id} name={}",
|
||||
outcome.name
|
||||
);
|
||||
transcript::attach_tool_failure_metadata(msg, detail.as_deref());
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract the `tool_call_id` from a native tool-result content envelope
|
||||
/// (`{"tool_call_id":…,"content":…}`). `None` for non-envelope content (XML /
|
||||
/// P-Format dispatchers, which don't emit `role:"tool"` messages anyway).
|
||||
fn parse_tool_call_id(content: &str) -> Option<String> {
|
||||
let value: serde_json::Value = serde_json::from_str(content).ok()?;
|
||||
value.get("tool_call_id")?.as_str().map(str::to_string)
|
||||
}
|
||||
|
||||
/// Reduce a tool's error output to a short, single-line reason for display.
|
||||
fn short_failure_detail(content: &str) -> Option<String> {
|
||||
const MAX: usize = 160;
|
||||
let line = content.lines().map(str::trim).find(|l| !l.is_empty())?;
|
||||
let short: String = line.chars().take(MAX).collect();
|
||||
if short.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(short)
|
||||
}
|
||||
}
|
||||
|
||||
/// Rewrite the **trailing** assistant `Chat` message in `history` to `text`,
|
||||
/// keeping the persisted transcript and the next turn's KV-cache prefix
|
||||
/// consistent with a repaired required-output reply (issue #4117). Only the last
|
||||
@@ -1465,7 +1526,11 @@ impl Agent {
|
||||
},
|
||||
);
|
||||
|
||||
let persisted = self.tool_dispatcher.to_provider_messages(&self.history);
|
||||
let mut persisted = self.tool_dispatcher.to_provider_messages(&self.history);
|
||||
// Re-attach per-call failure outcomes (dropped when the engine folded
|
||||
// each tool result into a `role:"tool"` message) so the derived
|
||||
// transcript view renders failed tools as errors, not successes.
|
||||
stamp_tool_failures(&mut persisted, &outcome.tool_outcomes);
|
||||
// Carry the turn's provider (event channel) + effective model and usage
|
||||
// into the persisted transcript meta. Passing `None` here dropped
|
||||
// `provider`/`model` from every transcript (they are `TranscriptMeta`
|
||||
|
||||
@@ -546,8 +546,25 @@ impl Agent {
|
||||
task_id: None,
|
||||
};
|
||||
|
||||
match transcript::write_transcript(path, messages, &meta, turn_usage) {
|
||||
// Append-only write (Phase A, transcript-derived view): diff this turn's
|
||||
// logical messages against the previously-persisted set tracked in
|
||||
// memory. A pure extension appends only the new tail; a context
|
||||
// reduction appends a `compaction` record. The file is never rewritten,
|
||||
// so pre-compaction history survives on disk for the display projection.
|
||||
// `request_id` (web-chat only) stamps a turn boundary on each line.
|
||||
let prev = std::mem::take(&mut self.persisted_transcript_messages);
|
||||
let request_id = crate::openhuman::agent::turn_origin::current_request_id();
|
||||
match transcript::append_transcript_turn(
|
||||
path,
|
||||
&prev,
|
||||
messages,
|
||||
&meta,
|
||||
turn_usage,
|
||||
request_id.as_deref(),
|
||||
) {
|
||||
Ok(()) => {
|
||||
// Track the new persisted logical set for the next turn's diff.
|
||||
self.persisted_transcript_messages = messages.to_vec();
|
||||
// Best-effort, non-fatal dual-write into the TinyAgents store.
|
||||
// Gated by the default-ON session dual-write flag
|
||||
// (`OPENHUMAN_SESSION_DUAL_WRITE` is a kill switch). Only runs
|
||||
@@ -556,8 +573,11 @@ impl Agent {
|
||||
self.maybe_dual_write_session_store(path, messages, &meta, turn_usage);
|
||||
}
|
||||
Err(err) => {
|
||||
// Restore the tracked state so a transient failure doesn't make
|
||||
// the next turn mis-diff (and spuriously emit a compaction).
|
||||
self.persisted_transcript_messages = prev;
|
||||
log::warn!(
|
||||
"[transcript] failed to write transcript {}: {err}",
|
||||
"[transcript] failed to append transcript {}: {err}",
|
||||
path.display()
|
||||
);
|
||||
}
|
||||
|
||||
@@ -122,9 +122,16 @@ pub struct Agent {
|
||||
/// [`AgentDefinitionRegistry`]: crate::openhuman::agent::harness::definition::AgentDefinitionRegistry
|
||||
pub(super) agent_definition_id: String,
|
||||
/// Resolved filesystem path for this session's transcript file.
|
||||
/// Set on first write, reused for subsequent overwrites within the
|
||||
/// Set on first write, reused for subsequent **appends** within the
|
||||
/// same session.
|
||||
pub(super) session_transcript_path: Option<PathBuf>,
|
||||
/// The logical message set most recently persisted to
|
||||
/// `session_transcript_path`, tracked in memory so the append-only writer
|
||||
/// can diff each turn's messages against it (pure extension → append tail;
|
||||
/// reduction → compaction record) without re-reading the growing file.
|
||||
/// Empty until the first persist. Each process writes its own transcript
|
||||
/// file, so this in-memory state is always aligned with the file it owns.
|
||||
pub(super) persisted_transcript_messages: Vec<ChatMessage>,
|
||||
/// Unique transcript key for this session, formatted as
|
||||
/// `"{unix_ts}_{agent_id}"`. Generated once at agent-build time so
|
||||
/// every transcript write in this session uses the same filename
|
||||
|
||||
@@ -134,6 +134,18 @@ pub fn current() -> Option<AgentTurnOrigin> {
|
||||
AGENT_TURN_ORIGIN.try_with(|o| o.clone()).ok()
|
||||
}
|
||||
|
||||
/// Read the ambient web-chat `request_id` for the current turn, when one was
|
||||
/// scoped by an [`AgentTurnOrigin::WebChat`] entry point. `None` for every
|
||||
/// other origin (channel / cron / CLI / sub-agent) and outside any scope —
|
||||
/// those turns are not request-scoped, so their transcript lines carry no
|
||||
/// turn-boundary marker.
|
||||
pub fn current_request_id() -> Option<String> {
|
||||
match current() {
|
||||
Some(AgentTurnOrigin::WebChat { request_id, .. }) => request_id,
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -9,6 +9,7 @@ pub mod ops;
|
||||
pub mod schemas;
|
||||
pub mod title;
|
||||
pub mod tools;
|
||||
pub mod transcript_view;
|
||||
pub mod turn_state;
|
||||
pub mod welcome_migration;
|
||||
|
||||
|
||||
@@ -693,6 +693,57 @@ pub struct ThreadTokenUsageRequest {
|
||||
pub thread_id: String,
|
||||
}
|
||||
|
||||
/// Request for [`transcript_get`]: the thread to project, plus newest-first
|
||||
/// pagination controls. `cursor` is the opaque token from a prior page's
|
||||
/// `nextCursor`; `limit` defaults to one screen.
|
||||
#[derive(Debug, Clone, serde::Deserialize)]
|
||||
pub struct TranscriptGetRequest {
|
||||
pub thread_id: String,
|
||||
#[serde(default)]
|
||||
pub cursor: Option<String>,
|
||||
#[serde(default)]
|
||||
pub limit: Option<usize>,
|
||||
}
|
||||
|
||||
/// Project a thread's settled transcript (derived from `session_raw/*.jsonl`)
|
||||
/// into typed display items, newest-first paginated. Returns an empty page with
|
||||
/// `hasTranscript: false` when the thread has no persisted transcript yet.
|
||||
pub async fn transcript_get(
|
||||
request: TranscriptGetRequest,
|
||||
) -> Result<
|
||||
RpcOutcome<ApiEnvelope<crate::openhuman::threads::transcript_view::TranscriptPage>>,
|
||||
String,
|
||||
> {
|
||||
let dir = workspace_dir().await?;
|
||||
let thread_id = request.thread_id.trim();
|
||||
if thread_id.is_empty() {
|
||||
return Err("thread_id is required".to_string());
|
||||
}
|
||||
let page = crate::openhuman::threads::transcript_view::get_page(
|
||||
&dir,
|
||||
thread_id,
|
||||
request.cursor.as_deref(),
|
||||
request.limit,
|
||||
);
|
||||
let counts = counts([
|
||||
("items", page.items.len()),
|
||||
("total", page.total),
|
||||
("has_transcript", usize::from(page.has_transcript)),
|
||||
]);
|
||||
let pagination = Some(PaginationMeta {
|
||||
limit: request
|
||||
.limit
|
||||
.unwrap_or(crate::openhuman::threads::transcript_view::DEFAULT_LIMIT),
|
||||
offset: request
|
||||
.cursor
|
||||
.as_deref()
|
||||
.and_then(|c| c.trim().parse::<usize>().ok())
|
||||
.unwrap_or(0),
|
||||
count: page.total,
|
||||
});
|
||||
Ok(envelope(page, Some(counts), pagination))
|
||||
}
|
||||
|
||||
/// Aggregated token/cost usage for one thread, read back from its persisted
|
||||
/// session transcripts. Seeds the UI footer when the user selects a thread so
|
||||
/// the totals reflect prior turns instead of starting at zero.
|
||||
|
||||
@@ -39,6 +39,7 @@ pub fn all_controller_schemas() -> Vec<ControllerSchema> {
|
||||
schemas("task_board_get"),
|
||||
schemas("task_board_put"),
|
||||
schemas("token_usage"),
|
||||
schemas("transcript_get"),
|
||||
]
|
||||
}
|
||||
|
||||
@@ -120,6 +121,10 @@ pub fn all_registered_controllers() -> Vec<RegisteredController> {
|
||||
schema: schemas("token_usage"),
|
||||
handler: handle_token_usage,
|
||||
},
|
||||
RegisteredController {
|
||||
schema: schemas("transcript_get"),
|
||||
handler: handle_transcript_get,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
@@ -527,6 +532,38 @@ pub fn schemas(function: &str) -> ControllerSchema {
|
||||
required: true,
|
||||
}],
|
||||
},
|
||||
"transcript_get" => ControllerSchema {
|
||||
namespace: "threads",
|
||||
function: "transcript_get",
|
||||
description:
|
||||
"Project a thread's settled transcript (derived from session_raw/*.jsonl) into typed display items, newest-first paginated.",
|
||||
inputs: vec![
|
||||
FieldSchema {
|
||||
name: "thread_id",
|
||||
ty: TypeSchema::String,
|
||||
comment: "Thread identifier.",
|
||||
required: true,
|
||||
},
|
||||
FieldSchema {
|
||||
name: "cursor",
|
||||
ty: TypeSchema::Option(Box::new(TypeSchema::String)),
|
||||
comment: "Opaque pagination cursor from a prior page's nextCursor; absent starts at the newest item.",
|
||||
required: false,
|
||||
},
|
||||
FieldSchema {
|
||||
name: "limit",
|
||||
ty: TypeSchema::Option(Box::new(TypeSchema::U64)),
|
||||
comment: "Max items to return (default 50, capped at 500).",
|
||||
required: false,
|
||||
},
|
||||
],
|
||||
outputs: vec![FieldSchema {
|
||||
name: "result",
|
||||
ty: TypeSchema::Json,
|
||||
comment: "Envelope with the newest-first page of display items, total, and nextCursor.",
|
||||
required: true,
|
||||
}],
|
||||
},
|
||||
_other => ControllerSchema {
|
||||
namespace: "threads",
|
||||
function: "unknown",
|
||||
@@ -745,6 +782,13 @@ fn handle_token_usage(params: Map<String, Value>) -> ControllerFuture {
|
||||
})
|
||||
}
|
||||
|
||||
fn handle_transcript_get(params: Map<String, Value>) -> ControllerFuture {
|
||||
Box::pin(async move {
|
||||
let p = parse::<ops::TranscriptGetRequest>(params)?;
|
||||
to_json(ops::transcript_get(p).await?)
|
||||
})
|
||||
}
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
fn parse<T: DeserializeOwned>(params: Map<String, Value>) -> Result<T, String> {
|
||||
|
||||
@@ -22,6 +22,7 @@ const ALL_FUNCTIONS: &[&str] = &[
|
||||
"task_board_get",
|
||||
"task_board_put",
|
||||
"token_usage",
|
||||
"transcript_get",
|
||||
];
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
//! In-memory, mtime-keyed projection cache for the transcript view.
|
||||
//!
|
||||
//! The settled transcript is derived from `session_raw/*.jsonl` on every
|
||||
//! request. Re-projecting a long thread on each page fetch is wasteful, so we
|
||||
//! memoize per-thread projections keyed on the backing files' `(path, mtime,
|
||||
//! len)` signature. An append (new turn, interrupted partial, sub-agent file)
|
||||
//! changes the signature and transparently invalidates the entry — there are
|
||||
//! **no disk writes** and no explicit invalidation call. The cache is bounded
|
||||
//! (LRU over a few dozen threads) so a long-lived core can't grow it without
|
||||
//! limit.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::collections::VecDeque;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Arc, Mutex, OnceLock};
|
||||
use std::time::SystemTime;
|
||||
|
||||
use super::project;
|
||||
use super::types::ProjectedTranscript;
|
||||
|
||||
const LOG_PREFIX: &str = "[threads][transcript][cache]";
|
||||
|
||||
/// Number of distinct threads whose projections are retained. Beyond this the
|
||||
/// least-recently-used entry is evicted.
|
||||
const CACHE_CAPACITY: usize = 32;
|
||||
|
||||
/// Signature of one backing file — changes on any append/rewrite.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
struct FileSig {
|
||||
path: PathBuf,
|
||||
mtime: Option<SystemTime>,
|
||||
len: u64,
|
||||
}
|
||||
|
||||
fn file_sig(path: &Path) -> FileSig {
|
||||
let (mtime, len) = match std::fs::metadata(path) {
|
||||
Ok(meta) => (meta.modified().ok(), meta.len()),
|
||||
Err(_) => (None, 0),
|
||||
};
|
||||
FileSig {
|
||||
path: path.to_path_buf(),
|
||||
mtime,
|
||||
len,
|
||||
}
|
||||
}
|
||||
|
||||
struct CacheEntry {
|
||||
signature: Vec<FileSig>,
|
||||
projected: Arc<ProjectedTranscript>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct CacheInner {
|
||||
entries: HashMap<String, CacheEntry>,
|
||||
/// LRU order — front is least-recently-used.
|
||||
order: VecDeque<String>,
|
||||
}
|
||||
|
||||
/// Bounded per-thread projection cache.
|
||||
#[derive(Default)]
|
||||
pub struct TranscriptViewCache {
|
||||
inner: Mutex<CacheInner>,
|
||||
}
|
||||
|
||||
impl TranscriptViewCache {
|
||||
/// Project `thread_id`'s transcript, serving a cached result when the
|
||||
/// backing files are unchanged. `None` when the thread has no transcript.
|
||||
pub fn get_or_project(
|
||||
&self,
|
||||
workspace_dir: &Path,
|
||||
thread_id: &str,
|
||||
) -> Option<Arc<ProjectedTranscript>> {
|
||||
let (root_path, sub_paths) = project::resolve_files(workspace_dir, thread_id)?;
|
||||
let signature: Vec<FileSig> = std::iter::once(file_sig(&root_path))
|
||||
.chain(sub_paths.iter().map(|p| file_sig(p)))
|
||||
.collect();
|
||||
|
||||
{
|
||||
let mut inner = self.inner.lock().ok()?;
|
||||
if let Some(entry) = inner.entries.get(thread_id) {
|
||||
if entry.signature == signature {
|
||||
let projected = entry.projected.clone();
|
||||
touch(&mut inner, thread_id);
|
||||
log::debug!(
|
||||
"{LOG_PREFIX} hit thread={thread_id} items={}",
|
||||
projected.items.len()
|
||||
);
|
||||
return Some(projected);
|
||||
}
|
||||
log::debug!("{LOG_PREFIX} miss (signature changed) thread={thread_id}");
|
||||
} else {
|
||||
log::debug!("{LOG_PREFIX} miss (cold) thread={thread_id}");
|
||||
}
|
||||
}
|
||||
|
||||
let projected = Arc::new(project::project_from_files(
|
||||
thread_id, &root_path, &sub_paths,
|
||||
));
|
||||
|
||||
let mut inner = self.inner.lock().ok()?;
|
||||
inner.entries.insert(
|
||||
thread_id.to_string(),
|
||||
CacheEntry {
|
||||
signature,
|
||||
projected: projected.clone(),
|
||||
},
|
||||
);
|
||||
touch(&mut inner, thread_id);
|
||||
evict_if_needed(&mut inner);
|
||||
log::debug!(
|
||||
"{LOG_PREFIX} stored thread={thread_id} items={} cache_size={}",
|
||||
projected.items.len(),
|
||||
inner.entries.len()
|
||||
);
|
||||
Some(projected)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn len(&self) -> usize {
|
||||
self.inner.lock().unwrap().entries.len()
|
||||
}
|
||||
}
|
||||
|
||||
/// Move `thread_id` to the most-recently-used end of the LRU order.
|
||||
fn touch(inner: &mut CacheInner, thread_id: &str) {
|
||||
if let Some(pos) = inner.order.iter().position(|t| t == thread_id) {
|
||||
inner.order.remove(pos);
|
||||
}
|
||||
inner.order.push_back(thread_id.to_string());
|
||||
}
|
||||
|
||||
/// Evict least-recently-used entries until within [`CACHE_CAPACITY`].
|
||||
fn evict_if_needed(inner: &mut CacheInner) {
|
||||
while inner.entries.len() > CACHE_CAPACITY {
|
||||
let Some(victim) = inner.order.pop_front() else {
|
||||
break;
|
||||
};
|
||||
inner.entries.remove(&victim);
|
||||
log::debug!("{LOG_PREFIX} evicted thread={victim}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Process-wide cache singleton. The transcript view is read-only and derived,
|
||||
/// so one shared cache across RPC calls is correct.
|
||||
pub fn global() -> &'static TranscriptViewCache {
|
||||
static CACHE: OnceLock<TranscriptViewCache> = OnceLock::new();
|
||||
CACHE.get_or_init(TranscriptViewCache::default)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "cache_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,74 @@
|
||||
//! Cache hit/miss + invalidation tests for the transcript view cache.
|
||||
|
||||
use super::TranscriptViewCache;
|
||||
use crate::openhuman::agent::harness::session::transcript;
|
||||
use std::path::Path;
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn meta_line(thread_id: &str) -> String {
|
||||
format!(
|
||||
r#"{{"_meta":{{"version":1,"agent":"orchestrator","dispatcher":"native","created":"2026-07-21T00:00:00Z","updated":"2026-07-21T00:00:00Z","turn_count":1,"input_tokens":0,"output_tokens":0,"cached_input_tokens":0,"charged_amount_usd":0.0,"thread_id":"{thread_id}"}}}}"#
|
||||
)
|
||||
}
|
||||
|
||||
fn write_raw(workspace: &Path, stem: &str, thread_id: &str, body: &[&str]) -> std::path::PathBuf {
|
||||
let path = transcript::resolve_keyed_transcript_path(workspace, stem).unwrap();
|
||||
let mut buf = meta_line(thread_id);
|
||||
buf.push('\n');
|
||||
for line in body {
|
||||
buf.push_str(line);
|
||||
buf.push('\n');
|
||||
}
|
||||
std::fs::write(&path, buf).unwrap();
|
||||
path
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recomputes_when_file_grows() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let path = write_raw(
|
||||
dir.path(),
|
||||
"100_orchestrator",
|
||||
"thr_cache",
|
||||
&[r#"{"role":"user","content":"first"}"#],
|
||||
);
|
||||
let cache = TranscriptViewCache::default();
|
||||
|
||||
let a = cache
|
||||
.get_or_project(dir.path(), "thr_cache")
|
||||
.expect("first");
|
||||
assert_eq!(a.items.len(), 1);
|
||||
|
||||
// Second call, unchanged file → same cached Arc (hit).
|
||||
let b = cache
|
||||
.get_or_project(dir.path(), "thr_cache")
|
||||
.expect("second");
|
||||
assert!(
|
||||
std::sync::Arc::ptr_eq(&a, &b),
|
||||
"unchanged file must serve cached Arc"
|
||||
);
|
||||
|
||||
// Append a line → file length changes → signature invalidates → recompute.
|
||||
let mut appended = std::fs::read_to_string(&path).unwrap();
|
||||
appended.push_str(r#"{"role":"assistant","content":"second"}"#);
|
||||
appended.push('\n');
|
||||
std::fs::write(&path, appended).unwrap();
|
||||
|
||||
let c = cache
|
||||
.get_or_project(dir.path(), "thr_cache")
|
||||
.expect("third");
|
||||
assert!(!std::sync::Arc::ptr_eq(&a, &c), "grown file must recompute");
|
||||
assert_eq!(
|
||||
c.items.len(),
|
||||
2,
|
||||
"recomputed projection reflects the append"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_thread_returns_none() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let cache = TranscriptViewCache::default();
|
||||
assert!(cache.get_or_project(dir.path(), "ghost").is_none());
|
||||
assert_eq!(cache.len(), 0);
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
//! Transcript-derived view: project the append-only `session_raw/*.jsonl`
|
||||
//! source of truth into typed display items for the chat renderer, with
|
||||
//! newest-first pagination over a bounded in-memory cache.
|
||||
//!
|
||||
//! Entry point: [`get_page`] (used by the `threads.transcript_get` RPC).
|
||||
|
||||
mod cache;
|
||||
mod project;
|
||||
pub mod types;
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use serde::Serialize;
|
||||
|
||||
pub use types::{DisplayItem, ProjectedTranscript, ToolCallStatus};
|
||||
|
||||
const LOG_PREFIX: &str = "[threads][transcript]";
|
||||
|
||||
/// Default page size — roughly one screen of chat items.
|
||||
pub const DEFAULT_LIMIT: usize = 50;
|
||||
/// Hard upper bound on a single page so a client can't request an unbounded
|
||||
/// projection slice.
|
||||
pub const MAX_LIMIT: usize = 500;
|
||||
|
||||
/// One newest-first page of a thread's projected transcript.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct TranscriptPage {
|
||||
pub thread_id: String,
|
||||
/// Display items for this page, **newest-first**.
|
||||
pub items: Vec<DisplayItem>,
|
||||
/// Total top-level items available for the thread.
|
||||
pub total: usize,
|
||||
/// Opaque cursor to pass back for the next (older) page; `null` at the end.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub next_cursor: Option<String>,
|
||||
/// `true` when more (older) items remain beyond this page.
|
||||
pub has_more: bool,
|
||||
/// `false` when the thread has no persisted transcript yet (empty page).
|
||||
pub has_transcript: bool,
|
||||
}
|
||||
|
||||
/// Project `thread_id`'s transcript and return one newest-first page.
|
||||
///
|
||||
/// `cursor` is the opaque token returned as `next_cursor` by a previous call
|
||||
/// (an offset from the newest item); `None`/empty starts at the newest item.
|
||||
/// `limit` defaults to [`DEFAULT_LIMIT`] and is clamped to [`MAX_LIMIT`].
|
||||
pub fn get_page(
|
||||
workspace_dir: &Path,
|
||||
thread_id: &str,
|
||||
cursor: Option<&str>,
|
||||
limit: Option<usize>,
|
||||
) -> TranscriptPage {
|
||||
let limit = limit.unwrap_or(DEFAULT_LIMIT).clamp(1, MAX_LIMIT);
|
||||
let offset = parse_cursor(cursor);
|
||||
|
||||
let Some(projected) = cache::global().get_or_project(workspace_dir, thread_id) else {
|
||||
log::debug!("{LOG_PREFIX} get_page thread={thread_id}: no transcript");
|
||||
return TranscriptPage {
|
||||
thread_id: thread_id.to_string(),
|
||||
items: Vec::new(),
|
||||
total: 0,
|
||||
next_cursor: None,
|
||||
has_more: false,
|
||||
has_transcript: false,
|
||||
};
|
||||
};
|
||||
|
||||
let total = projected.items.len();
|
||||
let start = offset.min(total);
|
||||
let end = (offset + limit).min(total);
|
||||
// Newest-first: item `offset` is the newest, walking backwards from the end.
|
||||
let items: Vec<DisplayItem> = (start..end)
|
||||
.map(|i| projected.items[total - 1 - i].clone())
|
||||
.collect();
|
||||
let has_more = end < total;
|
||||
let next_cursor = has_more.then(|| end.to_string());
|
||||
|
||||
log::debug!(
|
||||
"{LOG_PREFIX} get_page thread={thread_id} total={total} offset={offset} returned={} has_more={has_more}",
|
||||
items.len()
|
||||
);
|
||||
|
||||
TranscriptPage {
|
||||
thread_id: thread_id.to_string(),
|
||||
items,
|
||||
total,
|
||||
next_cursor,
|
||||
has_more,
|
||||
has_transcript: true,
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse the opaque cursor into a numeric offset (0 on absent/invalid).
|
||||
fn parse_cursor(cursor: Option<&str>) -> usize {
|
||||
cursor
|
||||
.map(str::trim)
|
||||
.filter(|c| !c.is_empty())
|
||||
.and_then(|c| c.parse::<usize>().ok())
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,499 @@
|
||||
//! Project raw session-transcript records into typed display items.
|
||||
//!
|
||||
//! Turns the append-only log's [`DisplayRecord`]s (message lines, compaction
|
||||
//! markers, interrupted partials) into the frontend's chat vocabulary
|
||||
//! ([`DisplayItem`]), sanitizing injected scaffolding as it goes. Sub-agent
|
||||
//! sibling files are discovered and nested one level deep.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use crate::openhuman::agent::harness::session::transcript::{
|
||||
self, CompactionMarker, DisplayMessage, DisplayRecord,
|
||||
};
|
||||
|
||||
use super::types::{DisplayItem, ProjectedTranscript, ToolCallFailure, ToolCallStatus};
|
||||
|
||||
const LOG_PREFIX: &str = "[threads][transcript]";
|
||||
|
||||
/// Max sub-agent nesting depth the projection descends. The plan calls for
|
||||
/// one level of recursion; we allow a small bound so a delegated worker that
|
||||
/// itself delegates still surfaces, without unbounded fan-out.
|
||||
const MAX_SUBAGENT_DEPTH: usize = 3;
|
||||
|
||||
/// The scaffolding line injected onto every user message (see
|
||||
/// `agent::prompts::current_datetime_line`). Stripped at projection so the UI
|
||||
/// shows the user's actual words, not the per-turn time stamp.
|
||||
const DATETIME_PREFIX: &str = "Current Date & Time:";
|
||||
|
||||
/// A legacy/alternate channel-context prefix. Kept for defensiveness; the
|
||||
/// live injector currently only prepends [`DATETIME_PREFIX`].
|
||||
const CHANNEL_CONTEXT_PREFIX: &str = "[Channel context]";
|
||||
|
||||
/// Resolve a thread's root transcript, discover its sub-agent siblings, and
|
||||
/// project everything into display items. Returns `None` when the thread has
|
||||
/// no root transcript yet (brand-new thread / first turn not persisted).
|
||||
pub fn project_thread(workspace_dir: &Path, thread_id: &str) -> Option<ProjectedTranscript> {
|
||||
let (root_path, sub_paths) = resolve_files(workspace_dir, thread_id)?;
|
||||
Some(project_from_files(thread_id, &root_path, &sub_paths))
|
||||
}
|
||||
|
||||
/// Resolve the on-disk file set backing a thread's transcript view: the root
|
||||
/// transcript path plus every sub-agent sibling file. `None` when the thread
|
||||
/// has no root transcript yet. Exposed so the cache can key on these paths
|
||||
/// (and their mtimes/lengths) without re-projecting.
|
||||
pub fn resolve_files(workspace_dir: &Path, thread_id: &str) -> Option<(PathBuf, Vec<PathBuf>)> {
|
||||
let root_path = transcript::find_root_transcript_for_thread(workspace_dir, thread_id)?;
|
||||
let root_stem = root_path.file_stem()?.to_str()?.to_string();
|
||||
let sub_paths = discover_subagent_files(workspace_dir, &root_stem);
|
||||
Some((root_path, sub_paths))
|
||||
}
|
||||
|
||||
/// Project a thread from an already-resolved file set (root + sub-agent
|
||||
/// siblings). Missing/unreadable files degrade to empty rather than failing.
|
||||
pub fn project_from_files(
|
||||
thread_id: &str,
|
||||
root_path: &Path,
|
||||
sub_paths: &[PathBuf],
|
||||
) -> ProjectedTranscript {
|
||||
let root_stem = root_path
|
||||
.file_stem()
|
||||
.and_then(|s| s.to_str())
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
|
||||
log::debug!(
|
||||
"{LOG_PREFIX} projecting thread={thread_id} root={} subagent_files={}",
|
||||
root_path.display(),
|
||||
sub_paths.len()
|
||||
);
|
||||
|
||||
// Read the root display records once: they feed both the top-level items
|
||||
// and the per-turn timestamp ranges used to anchor sub-agent trails.
|
||||
let (mut items, segments) = match transcript::read_transcript_display(root_path) {
|
||||
Ok(d) => (project_records(&d.records), turn_segments(&d.records)),
|
||||
Err(err) => {
|
||||
log::warn!(
|
||||
"{LOG_PREFIX} failed to read root transcript {}: {err}",
|
||||
root_path.display()
|
||||
);
|
||||
(Vec::new(), Vec::new())
|
||||
}
|
||||
};
|
||||
|
||||
let subagents = build_subagent_items(sub_paths, &root_stem, 0, &segments);
|
||||
log::debug!(
|
||||
"{LOG_PREFIX} projected thread={thread_id} top_level_items={} subagents={}",
|
||||
items.len(),
|
||||
subagents.len()
|
||||
);
|
||||
items.extend(subagents);
|
||||
|
||||
ProjectedTranscript {
|
||||
thread_id: thread_id.to_string(),
|
||||
items,
|
||||
}
|
||||
}
|
||||
|
||||
/// Discover every sub-agent transcript file for `root_stem` under
|
||||
/// `session_raw/`. Sub-agent stems are `{root_stem}__…`; results are sorted so
|
||||
/// the timestamp-prefixed suffixes order by creation time.
|
||||
fn discover_subagent_files(workspace_dir: &Path, root_stem: &str) -> Vec<PathBuf> {
|
||||
let raw_dir = workspace_dir.join("session_raw");
|
||||
let prefix = format!("{root_stem}__");
|
||||
let Ok(entries) = fs::read_dir(&raw_dir) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let mut paths: Vec<PathBuf> = entries
|
||||
.flatten()
|
||||
.map(|e| e.path())
|
||||
.filter(|p| p.extension().and_then(|s| s.to_str()) == Some("jsonl"))
|
||||
.filter(|p| {
|
||||
p.file_stem()
|
||||
.and_then(|s| s.to_str())
|
||||
.is_some_and(|stem| stem.starts_with(&prefix))
|
||||
})
|
||||
.collect();
|
||||
paths.sort();
|
||||
paths
|
||||
}
|
||||
|
||||
/// Build nested [`DisplayItem::Subagent`] items for the direct children of
|
||||
/// `parent_stem` among `all_sub_paths`, recursing one level per depth up to
|
||||
/// [`MAX_SUBAGENT_DEPTH`]. Attachment is flat (ordered by file timestamp): the
|
||||
/// transcript doesn't record a robust delegation-call → file link, so we nest
|
||||
/// by stem lineage rather than guessing the parent tool call.
|
||||
///
|
||||
/// Each item is anchored to a parent turn via [`anchor_request_id`] so the
|
||||
/// frontend can render the trail under the turn that spawned it rather than the
|
||||
/// most recent turn. `segments` are the root turns' start timestamps.
|
||||
fn build_subagent_items(
|
||||
all_sub_paths: &[PathBuf],
|
||||
parent_stem: &str,
|
||||
depth: usize,
|
||||
segments: &[(String, i64)],
|
||||
) -> Vec<DisplayItem> {
|
||||
if depth >= MAX_SUBAGENT_DEPTH {
|
||||
return Vec::new();
|
||||
}
|
||||
let child_prefix = format!("{parent_stem}__");
|
||||
let mut out = Vec::new();
|
||||
for path in all_sub_paths {
|
||||
let Some(stem) = path.file_stem().and_then(|s| s.to_str()) else {
|
||||
continue;
|
||||
};
|
||||
let Some(rest) = stem.strip_prefix(&child_prefix) else {
|
||||
continue;
|
||||
};
|
||||
// Direct child only — no further `__` in the remainder.
|
||||
if rest.contains("__") {
|
||||
continue;
|
||||
}
|
||||
let display = match transcript::read_transcript_display(path) {
|
||||
Ok(d) => d,
|
||||
Err(err) => {
|
||||
log::warn!(
|
||||
"{LOG_PREFIX} failed to read sub-agent transcript {}: {err}",
|
||||
path.display()
|
||||
);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let mut items = project_records(&display.records);
|
||||
items.extend(build_subagent_items(
|
||||
all_sub_paths,
|
||||
stem,
|
||||
depth + 1,
|
||||
segments,
|
||||
));
|
||||
// Prefer the archetype id from meta; fall back to the stem suffix.
|
||||
let id = if display.meta.agent_name.is_empty() {
|
||||
rest.to_string()
|
||||
} else {
|
||||
display.meta.agent_name.clone()
|
||||
};
|
||||
// Anchor to the parent turn active at the sub-agent's spawn time.
|
||||
let request_id = anchor_request_id(child_spawn_unix(rest), segments);
|
||||
log::debug!("{LOG_PREFIX} subagent id={id} stem={rest} anchored request_id={request_id:?}");
|
||||
out.push(DisplayItem::Subagent {
|
||||
id,
|
||||
request_id,
|
||||
items,
|
||||
});
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The root turns' start timestamps as `(request_id, unix_seconds)` in file
|
||||
/// order — one entry per turn boundary. Built from the first timestamped line
|
||||
/// of each `request_id` run. Turns whose lines carry no `request_id` or no
|
||||
/// parseable timestamp contribute nothing (legacy/CLI transcripts yield an
|
||||
/// empty list, so sub-agents there stay unanchored).
|
||||
fn turn_segments(records: &[DisplayRecord]) -> Vec<(String, i64)> {
|
||||
let mut segments: Vec<(String, i64)> = Vec::new();
|
||||
let mut last_request_id: Option<String> = None;
|
||||
for record in records {
|
||||
let DisplayRecord::Message(msg) = record else {
|
||||
continue;
|
||||
};
|
||||
let (Some(rid), Some(ts)) = (msg.request_id.as_deref(), msg.ts.as_deref()) else {
|
||||
continue;
|
||||
};
|
||||
if last_request_id.as_deref() == Some(rid) {
|
||||
continue;
|
||||
}
|
||||
let Some(unix) = parse_rfc3339_unix(ts) else {
|
||||
continue;
|
||||
};
|
||||
segments.push((rid.to_string(), unix));
|
||||
last_request_id = Some(rid.to_string());
|
||||
}
|
||||
segments
|
||||
}
|
||||
|
||||
/// Extract a sub-agent's spawn unix timestamp (seconds) from its file-stem
|
||||
/// suffix. Stems are `{unix_ts}_{agent_id}`; the leading integer is the agent
|
||||
/// build/spawn time (see the transcript module's stem docs). `None` for
|
||||
/// non-numeric legacy stems.
|
||||
fn child_spawn_unix(stem_suffix: &str) -> Option<i64> {
|
||||
stem_suffix
|
||||
.split('_')
|
||||
.next()
|
||||
.and_then(|s| s.parse::<i64>().ok())
|
||||
}
|
||||
|
||||
/// Parse an RFC-3339 timestamp into unix seconds.
|
||||
fn parse_rfc3339_unix(ts: &str) -> Option<i64> {
|
||||
chrono::DateTime::parse_from_rfc3339(ts)
|
||||
.ok()
|
||||
.map(|dt| dt.timestamp())
|
||||
}
|
||||
|
||||
/// Anchor a sub-agent to the parent turn that was active at its `child_unix`
|
||||
/// spawn time: the last turn segment whose start is `<= child_unix`.
|
||||
///
|
||||
/// Fallbacks (documented heuristic, since sub-agent files carry no explicit
|
||||
/// delegation-call back-link):
|
||||
/// - No segments (legacy/CLI root): `None` — the item stays unanchored and the
|
||||
/// frontend leaves it under the current turn cursor, as before.
|
||||
/// - Unknown spawn time (non-numeric stem): the newest turn (best effort).
|
||||
/// - Spawn time precedes every turn start: the first turn.
|
||||
fn anchor_request_id(child_unix: Option<i64>, segments: &[(String, i64)]) -> Option<String> {
|
||||
if segments.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let Some(child_unix) = child_unix else {
|
||||
return segments.last().map(|(rid, _)| rid.clone());
|
||||
};
|
||||
let mut chosen = &segments[0];
|
||||
for seg in segments {
|
||||
if seg.1 <= child_unix {
|
||||
chosen = seg;
|
||||
}
|
||||
}
|
||||
Some(chosen.0.clone())
|
||||
}
|
||||
|
||||
/// Project one file's display records into display items, in file order.
|
||||
///
|
||||
/// - System lines are dropped (they carry the tool-policy preamble and other
|
||||
/// scaffolding that must never render as a chat item).
|
||||
/// - `reasoning_content` on an assistant line becomes a [`DisplayItem::Reasoning`]
|
||||
/// preceding its message.
|
||||
/// - Assistant `tool_calls` register pending [`DisplayItem::ToolCall`]s; a later
|
||||
/// `role:"tool"` line pairs to one by id, falling back to FIFO order.
|
||||
/// - Interrupted partials and compaction markers pass through as their items.
|
||||
/// - A [`DisplayItem::TurnBoundary`] is emitted whenever `request_id` changes.
|
||||
pub fn project_records(records: &[DisplayRecord]) -> Vec<DisplayItem> {
|
||||
let mut items: Vec<DisplayItem> = Vec::new();
|
||||
// Pending tool calls awaiting a result line: (call_id, index into `items`).
|
||||
let mut pending: VecDeque<(String, usize)> = VecDeque::new();
|
||||
let mut last_request_id: Option<String> = None;
|
||||
|
||||
for record in records {
|
||||
match record {
|
||||
DisplayRecord::Message(msg) => {
|
||||
maybe_emit_turn_boundary(msg, &mut last_request_id, &mut items);
|
||||
project_message(msg, &mut items, &mut pending);
|
||||
}
|
||||
DisplayRecord::Compaction(marker) => {
|
||||
project_compaction(marker, &mut items);
|
||||
// A compaction supersedes prior context; drop stale pending
|
||||
// pairings so a post-compaction result never binds to them.
|
||||
pending.clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
items
|
||||
}
|
||||
|
||||
fn maybe_emit_turn_boundary(
|
||||
msg: &DisplayMessage,
|
||||
last_request_id: &mut Option<String>,
|
||||
items: &mut Vec<DisplayItem>,
|
||||
) {
|
||||
let Some(rid) = msg.request_id.as_deref() else {
|
||||
return;
|
||||
};
|
||||
if last_request_id.as_deref() != Some(rid) {
|
||||
items.push(DisplayItem::TurnBoundary {
|
||||
request_id: rid.to_string(),
|
||||
});
|
||||
*last_request_id = Some(rid.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
fn project_message(
|
||||
msg: &DisplayMessage,
|
||||
items: &mut Vec<DisplayItem>,
|
||||
pending: &mut VecDeque<(String, usize)>,
|
||||
) {
|
||||
// Interrupted partial: display-only, carries its own thinking.
|
||||
if msg.interrupted {
|
||||
items.push(DisplayItem::InterruptedPartial {
|
||||
text: msg.message.content.clone(),
|
||||
thinking: msg.reasoning_content.clone(),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
match msg.message.role.as_str() {
|
||||
"system" => {
|
||||
// Scaffolding (tool-policy preamble, etc.) — never a display item.
|
||||
log::debug!("{LOG_PREFIX} sanitize: dropped system line from projection");
|
||||
}
|
||||
"user" => {
|
||||
let raw = msg.message.content.clone();
|
||||
let sanitized = sanitize_user_content(&raw);
|
||||
if sanitized.is_some() {
|
||||
log::debug!("{LOG_PREFIX} sanitize: stripped injected prefix from user message");
|
||||
}
|
||||
items.push(DisplayItem::UserMessage {
|
||||
content: raw,
|
||||
display_content: sanitized,
|
||||
request_id: msg.request_id.clone(),
|
||||
});
|
||||
}
|
||||
"assistant" => project_assistant(msg, items, pending),
|
||||
"tool" => project_tool_result(msg, items, pending),
|
||||
other => {
|
||||
log::debug!("{LOG_PREFIX} projecting unknown role {other:?} as assistant message");
|
||||
items.push(DisplayItem::AssistantMessage {
|
||||
content: msg.message.content.clone(),
|
||||
interim: false,
|
||||
request_id: msg.request_id.clone(),
|
||||
model: msg.turn_usage.as_ref().map(|tu| tu.model.clone()),
|
||||
iteration: msg.iteration,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn project_assistant(
|
||||
msg: &DisplayMessage,
|
||||
items: &mut Vec<DisplayItem>,
|
||||
pending: &mut VecDeque<(String, usize)>,
|
||||
) {
|
||||
// Reasoning precedes the message it belongs to.
|
||||
if let Some(reasoning) = msg.reasoning_content.as_deref() {
|
||||
if !reasoning.trim().is_empty() {
|
||||
items.push(DisplayItem::Reasoning {
|
||||
text: reasoning.to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let tool_calls = msg
|
||||
.turn_usage
|
||||
.as_ref()
|
||||
.map(|tu| tu.tool_calls.as_slice())
|
||||
.unwrap_or_default();
|
||||
let interim = !tool_calls.is_empty();
|
||||
|
||||
// The assistant's prose (if any) shows before its tool calls.
|
||||
if !msg.message.content.trim().is_empty() {
|
||||
items.push(DisplayItem::AssistantMessage {
|
||||
content: msg.message.content.clone(),
|
||||
interim,
|
||||
request_id: msg.request_id.clone(),
|
||||
model: msg.turn_usage.as_ref().map(|tu| tu.model.clone()),
|
||||
iteration: msg.iteration,
|
||||
});
|
||||
}
|
||||
|
||||
for call in tool_calls {
|
||||
let args = parse_tool_args(&call.arguments);
|
||||
items.push(DisplayItem::ToolCall {
|
||||
call_id: call.id.clone(),
|
||||
name: call.name.clone(),
|
||||
args,
|
||||
result: None,
|
||||
status: ToolCallStatus::Running,
|
||||
failure: None,
|
||||
});
|
||||
pending.push_back((call.id.clone(), items.len() - 1));
|
||||
}
|
||||
}
|
||||
|
||||
fn project_tool_result(
|
||||
msg: &DisplayMessage,
|
||||
items: &mut Vec<DisplayItem>,
|
||||
pending: &mut VecDeque<(String, usize)>,
|
||||
) {
|
||||
let result = msg.message.content.clone();
|
||||
// A failed tool line (`ToolResult::is_error`, stamped at persistence) pairs
|
||||
// to an error row with a failure payload instead of a false success.
|
||||
let (status, failure) = if msg.failure {
|
||||
(
|
||||
ToolCallStatus::Error,
|
||||
Some(ToolCallFailure {
|
||||
detail: msg.failure_detail.clone(),
|
||||
}),
|
||||
)
|
||||
} else {
|
||||
(ToolCallStatus::Success, None)
|
||||
};
|
||||
// Pair by explicit call id first, else FIFO.
|
||||
let idx = msg
|
||||
.message
|
||||
.id
|
||||
.as_deref()
|
||||
.and_then(|id| take_pending_by_id(pending, id))
|
||||
.or_else(|| pending.pop_front().map(|(_, idx)| idx));
|
||||
|
||||
if let Some(idx) = idx {
|
||||
if let Some(DisplayItem::ToolCall {
|
||||
result: slot,
|
||||
status: status_slot,
|
||||
failure: failure_slot,
|
||||
..
|
||||
}) = items.get_mut(idx)
|
||||
{
|
||||
*slot = Some(result);
|
||||
*status_slot = status;
|
||||
*failure_slot = failure;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Orphan result (no matching assistant tool_call recorded) — surface it as
|
||||
// a best-effort completed tool row so the output is not lost.
|
||||
log::debug!("{LOG_PREFIX} tool result with no pending call — emitting orphan tool row");
|
||||
items.push(DisplayItem::ToolCall {
|
||||
call_id: msg.message.id.clone().unwrap_or_default(),
|
||||
name: "tool".to_string(),
|
||||
args: None,
|
||||
result: Some(result),
|
||||
status,
|
||||
failure,
|
||||
});
|
||||
}
|
||||
|
||||
/// Remove and return the pending entry whose call id matches `id`, if any.
|
||||
fn take_pending_by_id(pending: &mut VecDeque<(String, usize)>, id: &str) -> Option<usize> {
|
||||
let pos = pending.iter().position(|(cid, _)| cid == id)?;
|
||||
pending.remove(pos).map(|(_, idx)| idx)
|
||||
}
|
||||
|
||||
fn project_compaction(marker: &CompactionMarker, items: &mut Vec<DisplayItem>) {
|
||||
items.push(DisplayItem::Compaction {
|
||||
replaced_count: 0,
|
||||
kept_count: marker.replacement.len(),
|
||||
ts: marker.ts.clone(),
|
||||
request_id: marker.request_id.clone(),
|
||||
});
|
||||
}
|
||||
|
||||
/// Parse a tool call's raw argument string into JSON when possible; a
|
||||
/// non-JSON string is wrapped so the frontend still receives structured args.
|
||||
fn parse_tool_args(raw: &str) -> Option<serde_json::Value> {
|
||||
let trimmed = raw.trim();
|
||||
if trimmed.is_empty() {
|
||||
return None;
|
||||
}
|
||||
match serde_json::from_str::<serde_json::Value>(trimmed) {
|
||||
Ok(value) => Some(value),
|
||||
Err(_) => Some(serde_json::Value::String(trimmed.to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
/// Strip the injected scaffolding prefix from a user message, returning the
|
||||
/// sanitized body only when a prefix was actually present (so the caller can
|
||||
/// tag rather than mutate — the raw `content` is preserved alongside).
|
||||
fn sanitize_user_content(content: &str) -> Option<String> {
|
||||
let trimmed_start = content.trim_start();
|
||||
if trimmed_start.starts_with(DATETIME_PREFIX)
|
||||
|| trimmed_start.starts_with(CHANNEL_CONTEXT_PREFIX)
|
||||
{
|
||||
// The injector prepends the scaffolding line followed by a blank line,
|
||||
// then the user's actual text. Strip the first paragraph.
|
||||
if let Some(idx) = content.find("\n\n") {
|
||||
let body = content[idx + 2..].to_string();
|
||||
return Some(body);
|
||||
}
|
||||
// No body after the prefix — the whole message was scaffolding.
|
||||
return Some(String::new());
|
||||
}
|
||||
None
|
||||
}
|
||||
@@ -0,0 +1,435 @@
|
||||
//! Projection + pagination + sanitization tests for the transcript view.
|
||||
|
||||
use super::project::{project_records, project_thread};
|
||||
use super::types::{DisplayItem, ToolCallStatus};
|
||||
use super::{get_page, DEFAULT_LIMIT};
|
||||
use crate::openhuman::agent::harness::session::transcript::{self, read_transcript_display};
|
||||
use crate::openhuman::inference::provider::ChatMessage;
|
||||
use std::path::{Path, PathBuf};
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn meta_line(thread_id: &str) -> String {
|
||||
format!(
|
||||
r#"{{"_meta":{{"version":1,"agent":"orchestrator","dispatcher":"native","created":"2026-07-21T00:00:00Z","updated":"2026-07-21T00:00:10Z","turn_count":1,"input_tokens":30,"output_tokens":13,"cached_input_tokens":0,"charged_amount_usd":0.003,"thread_id":"{thread_id}"}}}}"#
|
||||
)
|
||||
}
|
||||
|
||||
/// Write a raw JSONL transcript (meta header + given body lines) into
|
||||
/// `session_raw/{stem}.jsonl` and return the path.
|
||||
fn write_raw(workspace: &Path, stem: &str, thread_id: &str, body: &[&str]) -> PathBuf {
|
||||
let path = transcript::resolve_keyed_transcript_path(workspace, stem).expect("resolve");
|
||||
let mut buf = meta_line(thread_id);
|
||||
buf.push('\n');
|
||||
for line in body {
|
||||
buf.push_str(line);
|
||||
buf.push('\n');
|
||||
}
|
||||
std::fs::write(&path, buf).expect("write raw transcript");
|
||||
path
|
||||
}
|
||||
|
||||
/// A full turn: system scaffolding, a user prompt with the injected datetime
|
||||
/// prefix, an assistant tool-calling step (reasoning + tool_calls), a tool
|
||||
/// result, then the final assistant answer.
|
||||
fn full_turn_body() -> Vec<&'static str> {
|
||||
vec![
|
||||
r#"{"role":"system","content":"[tool-policy preamble] you may use tools ..."}"#,
|
||||
r#"{"role":"user","content":"Current Date & Time: 2026-07-21 09:00:00 UTC\n\nWhat's the weather in NYC?","request_id":"req-1"}"#,
|
||||
r#"{"role":"assistant","content":"Let me check.","provider":"anthropic","model":"claude-x","usage":{"input":10,"output":5,"cached_input":0,"cost_usd":0.001},"ts":"2026-07-21T09:00:01Z","reasoning_content":"I should call the weather tool.","tool_calls":[{"id":"call-1","name":"get_weather","arguments":"{\"city\":\"NYC\"}"}],"iteration":1,"request_id":"req-1"}"#,
|
||||
r#"{"role":"tool","content":"72F and sunny","id":"call-1","request_id":"req-1"}"#,
|
||||
r#"{"role":"assistant","content":"It's 72F and sunny in NYC.","provider":"anthropic","model":"claude-x","usage":{"input":20,"output":8,"cached_input":0,"cost_usd":0.002},"ts":"2026-07-21T09:00:02Z","iteration":2,"request_id":"req-1"}"#,
|
||||
]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn projects_turn_with_tools_reasoning_and_sanitization() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let path = write_raw(dir.path(), "100_orchestrator", "thr_w", &full_turn_body());
|
||||
let display = read_transcript_display(&path).unwrap();
|
||||
let items = project_records(&display.records);
|
||||
|
||||
// Expected order: turnBoundary, userMessage, reasoning, assistant(interim),
|
||||
// toolCall(paired), assistant(final). System line dropped.
|
||||
assert_eq!(items.len(), 6, "unexpected items: {items:#?}");
|
||||
|
||||
match &items[0] {
|
||||
DisplayItem::TurnBoundary { request_id } => assert_eq!(request_id, "req-1"),
|
||||
other => panic!("expected turnBoundary, got {other:?}"),
|
||||
}
|
||||
match &items[1] {
|
||||
DisplayItem::UserMessage {
|
||||
content,
|
||||
display_content,
|
||||
request_id,
|
||||
} => {
|
||||
assert!(content.starts_with("Current Date & Time:"), "raw kept");
|
||||
assert_eq!(
|
||||
display_content.as_deref(),
|
||||
Some("What's the weather in NYC?"),
|
||||
"datetime prefix stripped into displayContent"
|
||||
);
|
||||
assert_eq!(request_id.as_deref(), Some("req-1"));
|
||||
}
|
||||
other => panic!("expected userMessage, got {other:?}"),
|
||||
}
|
||||
match &items[2] {
|
||||
DisplayItem::Reasoning { text } => assert_eq!(text, "I should call the weather tool."),
|
||||
other => panic!("expected reasoning, got {other:?}"),
|
||||
}
|
||||
match &items[3] {
|
||||
DisplayItem::AssistantMessage {
|
||||
content, interim, ..
|
||||
} => {
|
||||
assert_eq!(content, "Let me check.");
|
||||
assert!(*interim, "tool-calling assistant step is interim");
|
||||
}
|
||||
other => panic!("expected interim assistantMessage, got {other:?}"),
|
||||
}
|
||||
match &items[4] {
|
||||
DisplayItem::ToolCall {
|
||||
call_id,
|
||||
name,
|
||||
args,
|
||||
result,
|
||||
status,
|
||||
failure,
|
||||
} => {
|
||||
assert_eq!(call_id, "call-1");
|
||||
assert_eq!(name, "get_weather");
|
||||
assert_eq!(
|
||||
args.as_ref()
|
||||
.and_then(|v| v.get("city"))
|
||||
.and_then(|v| v.as_str()),
|
||||
Some("NYC")
|
||||
);
|
||||
assert_eq!(result.as_deref(), Some("72F and sunny"), "paired by id");
|
||||
assert_eq!(*status, ToolCallStatus::Success);
|
||||
assert!(failure.is_none(), "successful tool carries no failure");
|
||||
}
|
||||
other => panic!("expected toolCall, got {other:?}"),
|
||||
}
|
||||
match &items[5] {
|
||||
DisplayItem::AssistantMessage {
|
||||
content, interim, ..
|
||||
} => {
|
||||
assert_eq!(content, "It's 72F and sunny in NYC.");
|
||||
assert!(!*interim, "final answer is not interim");
|
||||
}
|
||||
other => panic!("expected final assistantMessage, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn projects_compaction_and_interrupted_partial() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let body = vec![
|
||||
r#"{"role":"user","content":"hi","request_id":"req-1"}"#,
|
||||
r#"{"kind":"compaction","replacement":[{"role":"user","content":"summary so far"}],"ts":"2026-07-21T09:05:00Z","request_id":"req-2"}"#,
|
||||
r#"{"role":"assistant","content":"partial ans","interrupted":true,"reasoning_content":"mid-thought","iteration":3,"request_id":"req-2"}"#,
|
||||
];
|
||||
let path = write_raw(dir.path(), "200_orchestrator", "thr_c", &body);
|
||||
let display = read_transcript_display(&path).unwrap();
|
||||
let items = project_records(&display.records);
|
||||
|
||||
let has_compaction = items.iter().any(|i| {
|
||||
matches!(
|
||||
i,
|
||||
DisplayItem::Compaction { kept_count, .. } if *kept_count == 1
|
||||
)
|
||||
});
|
||||
assert!(has_compaction, "compaction projected: {items:#?}");
|
||||
|
||||
let partial = items
|
||||
.iter()
|
||||
.find_map(|i| match i {
|
||||
DisplayItem::InterruptedPartial { text, thinking } => Some((text, thinking)),
|
||||
_ => None,
|
||||
})
|
||||
.expect("interrupted partial projected");
|
||||
assert_eq!(partial.0, "partial ans");
|
||||
assert_eq!(partial.1.as_deref(), Some("mid-thought"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_file_without_version_or_request_id_projects() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
// Legacy meta: no `version`, messages carry no `request_id`.
|
||||
let path = transcript::resolve_keyed_transcript_path(dir.path(), "300_orchestrator").unwrap();
|
||||
let raw = concat!(
|
||||
r#"{"_meta":{"agent":"orchestrator","dispatcher":"native","created":"2026-01-01T00:00:00Z","updated":"2026-01-01T00:00:00Z","turn_count":1,"input_tokens":0,"output_tokens":0,"cached_input_tokens":0,"charged_amount_usd":0.0,"thread_id":"thr_legacy"}}"#,
|
||||
"\n",
|
||||
r#"{"role":"user","content":"plain question"}"#,
|
||||
"\n",
|
||||
r#"{"role":"assistant","content":"plain answer"}"#,
|
||||
"\n",
|
||||
);
|
||||
std::fs::write(&path, raw).unwrap();
|
||||
let display = read_transcript_display(&path).unwrap();
|
||||
let items = project_records(&display.records);
|
||||
|
||||
// No request_id → no turn boundary; user + assistant still project, and an
|
||||
// un-prefixed user message keeps no displayContent (nothing to strip).
|
||||
assert!(
|
||||
!items
|
||||
.iter()
|
||||
.any(|i| matches!(i, DisplayItem::TurnBoundary { .. })),
|
||||
"legacy lines have no request_id, so no boundary"
|
||||
);
|
||||
match items.first() {
|
||||
Some(DisplayItem::UserMessage {
|
||||
content,
|
||||
display_content,
|
||||
..
|
||||
}) => {
|
||||
assert_eq!(content, "plain question");
|
||||
assert_eq!(
|
||||
display_content.as_deref(),
|
||||
None,
|
||||
"no prefix, no displayContent"
|
||||
);
|
||||
}
|
||||
other => panic!("expected userMessage first, got {other:?}"),
|
||||
}
|
||||
assert!(items.iter().any(
|
||||
|i| matches!(i, DisplayItem::AssistantMessage { content, .. } if content == "plain answer")
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn subagent_file_projects_as_nested_item() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let root_stem = "400_orchestrator";
|
||||
write_raw(
|
||||
dir.path(),
|
||||
root_stem,
|
||||
"thr_s",
|
||||
&[r#"{"role":"user","content":"delegate please","request_id":"req-1"}"#],
|
||||
);
|
||||
// Sub-agent sibling shares the root stem with a `__` suffix.
|
||||
write_raw(
|
||||
dir.path(),
|
||||
&format!("{root_stem}__100_coder"),
|
||||
"thr_s",
|
||||
&[
|
||||
r#"{"role":"assistant","content":"sub work done","provider":"anthropic","model":"claude-x","usage":{"input":5,"output":3,"cached_input":0,"cost_usd":0.0},"ts":"2026-07-21T09:10:00Z","iteration":1}"#,
|
||||
],
|
||||
);
|
||||
|
||||
let projected = project_thread(dir.path(), "thr_s").expect("project thread");
|
||||
let subagent = projected
|
||||
.items
|
||||
.iter()
|
||||
.find_map(|i| match i {
|
||||
DisplayItem::Subagent { id, items, .. } => Some((id, items)),
|
||||
_ => None,
|
||||
})
|
||||
.expect("subagent item present");
|
||||
assert_eq!(subagent.0, "orchestrator");
|
||||
assert!(subagent.1.iter().any(
|
||||
|i| matches!(i, DisplayItem::AssistantMessage { content, .. } if content == "sub work done")
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_page_paginates_newest_first_with_cursor() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
// Five plain user messages → five top-level items.
|
||||
let body: Vec<String> = (0..5)
|
||||
.map(|i| format!(r#"{{"role":"user","content":"msg-{i}"}}"#))
|
||||
.collect();
|
||||
let body_refs: Vec<&str> = body.iter().map(String::as_str).collect();
|
||||
write_raw(dir.path(), "500_orchestrator", "thr_p", &body_refs);
|
||||
|
||||
// First page: newest first, limit 2 → msg-4, msg-3.
|
||||
let page1 = get_page(dir.path(), "thr_p", None, Some(2));
|
||||
assert_eq!(page1.total, 5);
|
||||
assert!(page1.has_more);
|
||||
assert_eq!(page1.items.len(), 2);
|
||||
assert!(
|
||||
matches!(&page1.items[0], DisplayItem::UserMessage { content, .. } if content == "msg-4")
|
||||
);
|
||||
assert!(
|
||||
matches!(&page1.items[1], DisplayItem::UserMessage { content, .. } if content == "msg-3")
|
||||
);
|
||||
|
||||
let cursor = page1.next_cursor.clone().expect("next cursor");
|
||||
let page2 = get_page(dir.path(), "thr_p", Some(&cursor), Some(2));
|
||||
assert!(
|
||||
matches!(&page2.items[0], DisplayItem::UserMessage { content, .. } if content == "msg-2")
|
||||
);
|
||||
|
||||
// Walk to the end.
|
||||
let last = get_page(dir.path(), "thr_p", page2.next_cursor.as_deref(), Some(2));
|
||||
assert!(!last.has_more, "final page exhausts the thread");
|
||||
assert!(last.next_cursor.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_tool_line_projects_error_status_with_failure_payload() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
// Assistant issues a tool call; the paired tool result line carries the
|
||||
// additive `failure` flag (stamped at persistence from `is_error`).
|
||||
let body = vec![
|
||||
r#"{"role":"assistant","content":"trying","provider":"anthropic","model":"m","usage":{"input":1,"output":1,"cached_input":0,"cost_usd":0.0},"ts":"2026-07-21T09:00:01Z","tool_calls":[{"id":"call-9","name":"shell","arguments":"{\"cmd\":\"boom\"}"}],"iteration":1,"request_id":"req-1"}"#,
|
||||
r#"{"role":"tool","content":"error: command not found","id":"call-9","request_id":"req-1","failure":true,"failure_detail":"error: command not found"}"#,
|
||||
];
|
||||
let path = write_raw(dir.path(), "600_orchestrator", "thr_f", &body);
|
||||
let display = read_transcript_display(&path).unwrap();
|
||||
let items = project_records(&display.records);
|
||||
|
||||
let tool = items
|
||||
.iter()
|
||||
.find_map(|i| match i {
|
||||
DisplayItem::ToolCall {
|
||||
status, failure, ..
|
||||
} => Some((status, failure)),
|
||||
_ => None,
|
||||
})
|
||||
.expect("toolCall projected");
|
||||
assert_eq!(*tool.0, ToolCallStatus::Error, "failed tool → error status");
|
||||
let failure = tool.1.as_ref().expect("failure payload present");
|
||||
assert_eq!(failure.detail.as_deref(), Some("error: command not found"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_failure_metadata_round_trips_write_to_display_line() {
|
||||
// Full write path: a failed tool ChatMessage stamped with failure metadata
|
||||
// must serialise the additive `failure` line field and read back as a failed
|
||||
// display message — proving the harness → transcript → projection seam.
|
||||
let dir = TempDir::new().unwrap();
|
||||
let now = "2026-07-21T09:00:00Z".to_string();
|
||||
let meta = transcript::TranscriptMeta {
|
||||
agent_name: "orchestrator".into(),
|
||||
agent_id: Some("orchestrator".into()),
|
||||
agent_type: Some("root".into()),
|
||||
dispatcher: "native".into(),
|
||||
provider: Some("anthropic".into()),
|
||||
model: Some("m".into()),
|
||||
created: now.clone(),
|
||||
updated: now,
|
||||
turn_count: 1,
|
||||
input_tokens: 0,
|
||||
output_tokens: 0,
|
||||
cached_input_tokens: 0,
|
||||
charged_amount_usd: 0.0,
|
||||
thread_id: Some("thr_rt".into()),
|
||||
task_id: None,
|
||||
};
|
||||
|
||||
let mut tool_msg = ChatMessage {
|
||||
id: Some("call-1".into()),
|
||||
role: "tool".into(),
|
||||
content: r#"{"tool_call_id":"call-1","content":"boom"}"#.into(),
|
||||
extra_metadata: None,
|
||||
};
|
||||
transcript::attach_tool_failure_metadata(&mut tool_msg, Some("boom: exit 1"));
|
||||
|
||||
let messages = vec![
|
||||
ChatMessage {
|
||||
id: None,
|
||||
role: "user".into(),
|
||||
content: "do it".into(),
|
||||
extra_metadata: None,
|
||||
},
|
||||
tool_msg,
|
||||
];
|
||||
let path = transcript::resolve_keyed_transcript_path(dir.path(), "700_orchestrator").unwrap();
|
||||
transcript::write_transcript(&path, &messages, &meta, None).unwrap();
|
||||
|
||||
let display = read_transcript_display(&path).unwrap();
|
||||
let failed = display
|
||||
.records
|
||||
.iter()
|
||||
.find_map(|r| match r {
|
||||
transcript::DisplayRecord::Message(m) if m.message.role == "tool" => Some(m),
|
||||
_ => None,
|
||||
})
|
||||
.expect("tool display message present");
|
||||
assert!(
|
||||
failed.failure,
|
||||
"failure flag survived the write/read round trip"
|
||||
);
|
||||
assert_eq!(failed.failure_detail.as_deref(), Some("boom: exit 1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn subagent_anchors_to_parent_turn_by_spawn_timestamp() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let root_stem = "800_orchestrator";
|
||||
let thread_id = "thr_anchor";
|
||||
|
||||
let t1 = chrono::DateTime::from_timestamp(1_000_000, 0)
|
||||
.unwrap()
|
||||
.to_rfc3339();
|
||||
let t2 = chrono::DateTime::from_timestamp(2_000_000, 0)
|
||||
.unwrap()
|
||||
.to_rfc3339();
|
||||
|
||||
// Two turns: req-1 (assistant ts t1), req-2 (assistant ts t2).
|
||||
let root_body = vec![
|
||||
r#"{"role":"user","content":"one","request_id":"req-1"}"#.to_string(),
|
||||
format!(
|
||||
r#"{{"role":"assistant","content":"a1","provider":"anthropic","model":"m","usage":{{"input":1,"output":1,"cached_input":0,"cost_usd":0.0}},"ts":"{t1}","iteration":1,"request_id":"req-1"}}"#
|
||||
),
|
||||
r#"{"role":"user","content":"two","request_id":"req-2"}"#.to_string(),
|
||||
format!(
|
||||
r#"{{"role":"assistant","content":"a2","provider":"anthropic","model":"m","usage":{{"input":1,"output":1,"cached_input":0,"cost_usd":0.0}},"ts":"{t2}","iteration":1,"request_id":"req-2"}}"#
|
||||
),
|
||||
];
|
||||
let root_refs: Vec<&str> = root_body.iter().map(String::as_str).collect();
|
||||
write_raw(dir.path(), root_stem, thread_id, &root_refs);
|
||||
|
||||
// Sub-agent stems encode the spawn unix timestamp: coder spawned during
|
||||
// turn 1 (1_000_050), planner during turn 2 (2_000_050).
|
||||
write_raw(
|
||||
dir.path(),
|
||||
&format!("{root_stem}__1000050_coder"),
|
||||
thread_id,
|
||||
&[r#"{"role":"assistant","content":"coder work"}"#],
|
||||
);
|
||||
write_raw(
|
||||
dir.path(),
|
||||
&format!("{root_stem}__2000050_planner"),
|
||||
thread_id,
|
||||
&[r#"{"role":"assistant","content":"planner work"}"#],
|
||||
);
|
||||
|
||||
let projected = project_thread(dir.path(), thread_id).expect("project thread");
|
||||
// The seeded sub-agent files share the `orchestrator` meta agent name, so
|
||||
// key the anchoring by each sub-agent's inner work content instead of `id`.
|
||||
let mut anchors: Vec<(String, Option<String>)> = projected
|
||||
.items
|
||||
.iter()
|
||||
.filter_map(|i| match i {
|
||||
DisplayItem::Subagent {
|
||||
request_id, items, ..
|
||||
} => {
|
||||
let marker = items.iter().find_map(|inner| match inner {
|
||||
DisplayItem::AssistantMessage { content, .. } => Some(content.clone()),
|
||||
_ => None,
|
||||
})?;
|
||||
Some((marker, request_id.clone()))
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
anchors.sort();
|
||||
|
||||
assert_eq!(
|
||||
anchors,
|
||||
vec![
|
||||
("coder work".to_string(), Some("req-1".to_string())),
|
||||
("planner work".to_string(), Some("req-2".to_string())),
|
||||
],
|
||||
"each sub-agent anchors to the turn active at its spawn time"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_page_missing_thread_is_empty_not_error() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let page = get_page(dir.path(), "no_such_thread", None, Some(DEFAULT_LIMIT));
|
||||
assert!(!page.has_transcript);
|
||||
assert_eq!(page.total, 0);
|
||||
assert!(page.items.is_empty());
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
//! Typed display items for the transcript projection RPC
|
||||
//! (`threads.transcript_get`).
|
||||
//!
|
||||
//! These mirror the frontend's existing chat vocabulary (user/assistant
|
||||
//! bubbles, reasoning drawer, tool timeline rows, sub-agent activity) so the
|
||||
//! Phase C renderer can map them onto the same components. Serde is camelCase
|
||||
//! on the wire — the frontend reads `displayContent`, `callId`, `requestId`,
|
||||
//! etc.
|
||||
|
||||
use serde::Serialize;
|
||||
|
||||
/// Terminal state of a projected tool call. Mirrors the live timeline's
|
||||
/// `ToolTimelineStatus` vocabulary (`running` / `success` / `error`) so the
|
||||
/// settled projection and the live stream render identically.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ToolCallStatus {
|
||||
/// Call issued but no result line has been paired yet.
|
||||
Running,
|
||||
/// A result line was paired to the call.
|
||||
Success,
|
||||
/// A result line the projection identified as a **failure**: the persisted
|
||||
/// tool line carried the additive `failure` flag (stamped at turn-loop
|
||||
/// persistence from the tool's `ToolResult::is_error` outcome). Paired with
|
||||
/// a [`ToolCallFailure`] payload on the item.
|
||||
Error,
|
||||
}
|
||||
|
||||
/// Failure payload attached to an errored [`DisplayItem::ToolCall`]. Minimal by
|
||||
/// design: the persisted transcript only records that the call failed plus an
|
||||
/// optional short reason. The frontend mapper expands this into its richer
|
||||
/// `ToolFailureExplanation` shape (`class` / `category` / `causePlain` /
|
||||
/// `nextAction`) for the `ToolFailureLines` renderer.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ToolCallFailure {
|
||||
/// Short, single-line reason for the failure, when the writer captured one.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub detail: Option<String>,
|
||||
}
|
||||
|
||||
/// One item in a projected transcript, in the frontend's display vocabulary.
|
||||
///
|
||||
/// `#[serde(tag = "kind")]` gives each variant a camelCase discriminator
|
||||
/// (`userMessage`, `assistantMessage`, …) and every field is camelCase.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize)]
|
||||
#[serde(
|
||||
tag = "kind",
|
||||
rename_all = "camelCase",
|
||||
rename_all_fields = "camelCase"
|
||||
)]
|
||||
pub enum DisplayItem {
|
||||
/// A user prompt. `content` is the raw persisted content (may carry the
|
||||
/// injected `Current Date & Time:` scaffolding line); `displayContent` is
|
||||
/// the sanitized version to show, present only when it differs from raw.
|
||||
UserMessage {
|
||||
content: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
display_content: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
request_id: Option<String>,
|
||||
},
|
||||
/// An assistant answer. `interim: true` marks a non-terminal tool-calling
|
||||
/// step within a multi-iteration turn (not the final answer bubble).
|
||||
AssistantMessage {
|
||||
content: String,
|
||||
#[serde(default, skip_serializing_if = "is_false")]
|
||||
interim: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
request_id: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
model: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
iteration: Option<u32>,
|
||||
},
|
||||
/// The model's reasoning/thinking that preceded an assistant message.
|
||||
Reasoning { text: String },
|
||||
/// A tool invocation with its paired result, when available.
|
||||
ToolCall {
|
||||
call_id: String,
|
||||
name: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
args: Option<serde_json::Value>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
result: Option<String>,
|
||||
status: ToolCallStatus,
|
||||
/// Present only when `status` is `Error` — the failure payload the
|
||||
/// frontend expands for the `ToolFailureLines` renderer.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
failure: Option<ToolCallFailure>,
|
||||
},
|
||||
/// A delegated sub-agent run, with its own nested projected items.
|
||||
///
|
||||
/// `request_id` anchors the whole sub-agent trail to the parent turn that
|
||||
/// spawned it. Sub-agent transcripts are sibling files with no explicit
|
||||
/// back-link to the delegating tool call, so the projection derives this by
|
||||
/// matching the sub-agent's spawn timestamp (encoded in its file stem)
|
||||
/// against the parent turns' timestamp ranges (see
|
||||
/// `project::anchor_request_id`). Absent for legacy/CLI transcripts whose
|
||||
/// lines carry no `request_id`.
|
||||
Subagent {
|
||||
id: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
request_id: Option<String>,
|
||||
items: Vec<DisplayItem>,
|
||||
},
|
||||
/// A turn boundary — emitted when the `request_id` changes between lines.
|
||||
TurnBoundary { request_id: String },
|
||||
/// A partial assistant answer captured when a turn was interrupted.
|
||||
InterruptedPartial {
|
||||
text: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
thinking: Option<String>,
|
||||
},
|
||||
/// A context-compaction marker: the reduced set replaced everything before
|
||||
/// it. Counts describe what the record superseded/installed.
|
||||
Compaction {
|
||||
replaced_count: usize,
|
||||
kept_count: usize,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
ts: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
request_id: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
#[allow(clippy::trivially_copy_pass_by_ref)]
|
||||
fn is_false(b: &bool) -> bool {
|
||||
!*b
|
||||
}
|
||||
|
||||
/// A projected transcript for one thread, before pagination. Chronological
|
||||
/// (file) order; the RPC layer paginates newest-first.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ProjectedTranscript {
|
||||
pub thread_id: String,
|
||||
/// All top-level display items in chronological order.
|
||||
pub items: Vec<DisplayItem>,
|
||||
}
|
||||
@@ -558,6 +558,69 @@ impl TurnStateMirror {
|
||||
self.state.active_subagent = None;
|
||||
self.state.updated_at = chrono::Utc::now().to_rfc3339();
|
||||
self.flush();
|
||||
self.persist_interrupted_partial();
|
||||
}
|
||||
|
||||
/// Append the partial streamed answer of an interrupted turn to the session
|
||||
/// transcript so the derived display view (Phase B) can surface it even
|
||||
/// after the live turn_state snapshot is gone. Display-only: the
|
||||
/// model-context reader skips `interrupted:true` lines.
|
||||
///
|
||||
/// Guard: the root transcript file must already exist. An interrupted
|
||||
/// **first** turn has no session file yet (the harness has not persisted a
|
||||
/// turn), so there is nothing to append to — that case stays recoverable
|
||||
/// from the turn_state snapshot alone, as today. We log and skip it.
|
||||
fn persist_interrupted_partial(&self) {
|
||||
let partial = self.state.streaming_text.trim();
|
||||
if partial.is_empty() {
|
||||
return;
|
||||
}
|
||||
let thread_id = self.state.thread_id.trim();
|
||||
if thread_id.is_empty() {
|
||||
return;
|
||||
}
|
||||
let workspace_dir = self.store.workspace_dir();
|
||||
let Some(path) =
|
||||
crate::openhuman::agent::harness::session::transcript::find_root_transcript_for_thread(
|
||||
workspace_dir,
|
||||
thread_id,
|
||||
)
|
||||
else {
|
||||
log::debug!(
|
||||
"{MIRROR_LOG_PREFIX} no root transcript for thread={thread_id} yet — leaving interrupted partial ({} chars) in turn_state snapshot only",
|
||||
partial.len()
|
||||
);
|
||||
return;
|
||||
};
|
||||
let request_id = if self.state.request_id.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(self.state.request_id.as_str())
|
||||
};
|
||||
let thinking = self.state.thinking.trim();
|
||||
let reasoning = if thinking.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(thinking)
|
||||
};
|
||||
match crate::openhuman::agent::harness::session::transcript::append_interrupted_partial(
|
||||
&path,
|
||||
partial,
|
||||
request_id,
|
||||
Some(self.state.iteration),
|
||||
reasoning,
|
||||
) {
|
||||
Ok(()) => log::debug!(
|
||||
"{MIRROR_LOG_PREFIX} appended interrupted partial ({} chars, thinking={} chars) for thread={thread_id} request_id={} to {}",
|
||||
partial.len(),
|
||||
thinking.len(),
|
||||
self.state.request_id,
|
||||
path.display()
|
||||
),
|
||||
Err(err) => log::warn!(
|
||||
"{MIRROR_LOG_PREFIX} failed to append interrupted partial for thread={thread_id}: {err}"
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
fn flush(&mut self) {
|
||||
|
||||
@@ -674,3 +674,138 @@ fn subagent_transcript_persists_interleaved_prose_and_tools() {
|
||||
"no snake_case fields on the wire"
|
||||
);
|
||||
}
|
||||
|
||||
// ── Interrupted-partial → session transcript wiring (Task 1) ──────────
|
||||
|
||||
use crate::openhuman::agent::harness::session::transcript::{
|
||||
self, read_transcript, read_transcript_display, DisplayRecord, TranscriptMeta,
|
||||
};
|
||||
use crate::openhuman::inference::provider::ChatMessage;
|
||||
|
||||
fn seed_root_transcript(workspace: &std::path::Path, thread_id: &str) -> std::path::PathBuf {
|
||||
let stem = "100_orchestrator".to_string();
|
||||
let path = transcript::resolve_keyed_transcript_path(workspace, &stem).expect("resolve path");
|
||||
let meta = TranscriptMeta {
|
||||
agent_name: "orchestrator".into(),
|
||||
agent_id: None,
|
||||
agent_type: Some("root".into()),
|
||||
dispatcher: "native".into(),
|
||||
provider: None,
|
||||
model: None,
|
||||
created: "2026-07-21T00:00:00Z".into(),
|
||||
updated: "2026-07-21T00:00:00Z".into(),
|
||||
turn_count: 1,
|
||||
input_tokens: 0,
|
||||
output_tokens: 0,
|
||||
cached_input_tokens: 0,
|
||||
charged_amount_usd: 0.0,
|
||||
thread_id: Some(thread_id.to_string()),
|
||||
task_id: None,
|
||||
};
|
||||
transcript::write_transcript(&path, &[ChatMessage::user("hello there")], &meta, None)
|
||||
.expect("seed transcript");
|
||||
path
|
||||
}
|
||||
|
||||
/// When a streaming turn is interrupted and a root transcript already exists,
|
||||
/// `finish()` appends the partial streamed answer (display-only) to the file.
|
||||
#[test]
|
||||
fn finish_appends_interrupted_partial_to_existing_transcript() {
|
||||
let dir = tempdir().expect("tempdir");
|
||||
let thread_id = "thr_abc";
|
||||
let path = seed_root_transcript(dir.path(), thread_id);
|
||||
|
||||
let store = TurnStateStore::new(dir.path().to_path_buf());
|
||||
let mut m = TurnStateMirror::new(store, thread_id, "req-9");
|
||||
m.observe(&AgentProgress::IterationStarted {
|
||||
iteration: 2,
|
||||
max_iterations: 25,
|
||||
});
|
||||
m.observe(&AgentProgress::ThinkingDelta {
|
||||
delta: "hmm".into(),
|
||||
iteration: 2,
|
||||
});
|
||||
m.observe(&AgentProgress::TextDelta {
|
||||
delta: "half an ".into(),
|
||||
iteration: 2,
|
||||
});
|
||||
m.observe(&AgentProgress::TextDelta {
|
||||
delta: "answer".into(),
|
||||
iteration: 2,
|
||||
});
|
||||
// No TurnCompleted — the bridge exits, marking the turn interrupted.
|
||||
m.finish();
|
||||
|
||||
// Model context must NOT carry the partial.
|
||||
let model = read_transcript(&path).expect("read model context");
|
||||
assert!(
|
||||
!model
|
||||
.messages
|
||||
.iter()
|
||||
.any(|msg| msg.content.contains("half an answer")),
|
||||
"interrupted partial must be excluded from the model context"
|
||||
);
|
||||
|
||||
// Display projection carries the flagged partial with request_id + thinking.
|
||||
let display = read_transcript_display(&path).expect("read display");
|
||||
let partial = display
|
||||
.records
|
||||
.iter()
|
||||
.find_map(|r| match r {
|
||||
DisplayRecord::Message(msg) if msg.interrupted => Some(msg),
|
||||
_ => None,
|
||||
})
|
||||
.expect("display must include the interrupted partial");
|
||||
assert_eq!(partial.message.content, "half an answer");
|
||||
assert_eq!(partial.request_id.as_deref(), Some("req-9"));
|
||||
assert_eq!(partial.iteration, Some(2));
|
||||
assert_eq!(partial.reasoning_content.as_deref(), Some("hmm"));
|
||||
}
|
||||
|
||||
/// A completed turn never writes an interrupted partial.
|
||||
#[test]
|
||||
fn finish_after_completion_writes_no_partial() {
|
||||
let dir = tempdir().expect("tempdir");
|
||||
let thread_id = "thr_done";
|
||||
let path = seed_root_transcript(dir.path(), thread_id);
|
||||
|
||||
let store = TurnStateStore::new(dir.path().to_path_buf());
|
||||
let mut m = TurnStateMirror::new(store, thread_id, "req-done");
|
||||
m.observe(&AgentProgress::TextDelta {
|
||||
delta: "final answer".into(),
|
||||
iteration: 1,
|
||||
});
|
||||
m.observe(&AgentProgress::TurnCompleted { iterations: 1 });
|
||||
m.finish();
|
||||
|
||||
let display = read_transcript_display(&path).expect("read display");
|
||||
assert!(
|
||||
!display
|
||||
.records
|
||||
.iter()
|
||||
.any(|r| matches!(r, DisplayRecord::Message(msg) if msg.interrupted)),
|
||||
"a completed turn must not append an interrupted partial"
|
||||
);
|
||||
}
|
||||
|
||||
/// An interrupted FIRST turn (no root transcript file yet) is a no-op — the
|
||||
/// partial stays in the turn_state snapshot only, and finish() does not panic.
|
||||
#[test]
|
||||
fn finish_first_turn_without_transcript_is_noop() {
|
||||
let dir = tempdir().expect("tempdir");
|
||||
let store = TurnStateStore::new(dir.path().to_path_buf());
|
||||
let mut m = TurnStateMirror::new(store, "thr_new", "req-first");
|
||||
m.observe(&AgentProgress::TextDelta {
|
||||
delta: "orphan partial".into(),
|
||||
iteration: 1,
|
||||
});
|
||||
// Must not panic even though no session_raw transcript exists.
|
||||
m.finish();
|
||||
// The snapshot itself still records the interrupted turn.
|
||||
let listed = TurnStateStore::new(dir.path().to_path_buf())
|
||||
.get("thr_new")
|
||||
.expect("get")
|
||||
.expect("snapshot present");
|
||||
assert_eq!(listed.lifecycle, TurnLifecycle::Interrupted);
|
||||
assert_eq!(listed.streaming_text, "orphan partial");
|
||||
}
|
||||
|
||||
@@ -54,6 +54,13 @@ impl TurnStateStore {
|
||||
Self { workspace_dir }
|
||||
}
|
||||
|
||||
/// Workspace root this store persists under. Exposed so the mirror can
|
||||
/// resolve sibling session transcripts (append the interrupted partial to
|
||||
/// `session_raw/{root}.jsonl`) without re-plumbing the path.
|
||||
pub fn workspace_dir(&self) -> &std::path::Path {
|
||||
&self.workspace_dir
|
||||
}
|
||||
|
||||
/// Atomically write the snapshot for `state.request_id` under
|
||||
/// `state.thread_id`. On a `Completed` write, prune the thread's completed
|
||||
/// turns to the newest [`COMPLETED_RETENTION`].
|
||||
|
||||
Reference in New Issue
Block a user