feat(transcript): derived transcript view — append-only session log as source of truth (#5086)

This commit is contained in:
Steven Enamakel
2026-07-21 16:03:01 +03:00
committed by GitHub
parent 49a0d3b1d5
commit 00846fdfd0
35 changed files with 4698 additions and 207 deletions
@@ -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()
);
}
+8 -1
View File
@@ -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
+12
View File
@@ -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::*;
+1
View File
@@ -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;
+51
View File
@@ -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.
+44
View File
@@ -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> {
+1
View File
@@ -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`].