Files
Kigi-CLI/crates/codegen/kigi-shell/src/session/streaming_capture.rs
T
ZacharyZhang-NY d6c20fc13f M0: compilable skeleton — Kigi 0.1.0 fork surgery
Hard fork of xai-org/grok-build (Apache-2.0) re-targeted as Kigi, an
unofficial Kimi Code CLI community build.

Rename & identity
- 72 xai-*/xai-grok-* crates -> kigi-* (explicit: xai-grok-pager-bin ->
  kigi-bin [binary `kigi`], xai-grok-pager -> kigi-tui; rest mechanical);
  ptyctl, ptyctl-cli, third_party/ unchanged; proto package
  xai.grok.tools.v1 -> kigi.tools.v1
- Config home ~/.kigi (KIGI_SHARE_DIR override), env prefix GROK_* ->
  KIGI_*, `kigi --version` carries the unofficial-community-build notice
- clap identity, help text, startup banner, prompt templates rebranded
  (templates re-encrypted)

Deletions (PRD removal list #5/#6/#7/#9/#10)
- voice input (xai-grok-voice) and all TUI wiring
- telemetry: Mixpanel client, external OTel stream, Sentry, OTLP layers,
  trace/GCS/S3 upload queues (kigi-file-utils halved), workspace upload
  module & dc_log, heap-profile uploader, auth-diagnostics uploader,
  session-analytics halves of feedback; local zero-egress observability
  preserved in new kigi-log crate (unified log, --debug firehose,
  subsystem file logs, opt-in instrumentation)
- announcements (crate, remote-settings fields, TUI surfaces)
- plugin marketplace (crate, sources/browse/CTA/extensions-modal tab);
  direct plugin install/uninstall/update via kigi-agent git_install kept
- relay/gateway/assets endpoints and features (agent relay, headless
  relay transport, gateway bridge, LeaderEnvUrls); leader IPC socket now
  ~/.kigi/leader.sock + KIGI_LEADER_SOCKET, no ws-url derivation
- functional types rehomed instead of deleted: PermissionMode ->
  kigi-config-types, McpInitStrategy -> kigi-mcp, PrCreationSource ->
  session signals, TerminalDiagnostics -> kigi-pager-render, agent_id ->
  shell util

Endpoints
- kigi-env rewritten: single production KigiEndpoints {coding_api_base_url
  https://api.kimi.com/coding/v1 (KIGI_CODE_BASE_URL), oauth_host
  https://auth.kimi.com (KIGI_OAUTH_HOST), update_base_url (GitHub
  Releases API), upgrade_page_url}; GrokBuildEnvironment enum deleted

Toolchain & workspace hygiene
- Rust 1.97.0 pinned; edition 2024; full cargo update; git2 hoisted to
  workspace at 0.21 (Option->Result API migration), quick-xml 0.41
- Root Cargo.toml hand-maintained (PRD §8.1): version 0.1.0 inherited by
  all members, members sorted, unused deps pruned
- cargo-deny advisories gate (deny.toml with documented transitive
  exceptions); CI workflow (check/clippy/fmt/deny/test, macOS+Linux)
- cross-crate test seams re-gated behind `test-support` cargo feature;
  insta snapshot baselines renamed to the kigi_tui prefix
- clippy --workspace --all-targets: zero warnings; fmt clean

Fixes surfaced by the port
- updater probe/installer divergence (bin/kigi vs bin/grok symlink set)
- idle model-metadata refresh dead under KIGI_CODE_BASE_URL override
  (new is_effective_coding_endpoint_url, loopback+override aware)
- macOS symlinked-TMPDIR fixture canonicalization (foreign_sessions,
  fast-worktree); RSS measurement tests serialized via serial_test

Docs & legal (Apache §4)
- NOTICE added (upstream attribution + change statement); THIRD-PARTY
  notices sustained; kigi-tools ported-code notices extended; README,
  CONTRIBUTING, SECURITY, AGENTS.md rewritten

Out of scope for M0 (tracked): Kimi auth/inference (M1), search/fetch,
command parity, config import (M2), Computer Hub excision & final
brand-token sweep (M2), distribution & self-update rewrite (M3).
2026-07-17 05:31:01 -04:00

623 lines
28 KiB
Rust

//! Out-of-band per-turn capture of the model's streamed reasoning + text.
//!
//! Lives apart from `chat_state`: the model never sees this. It exists purely
//! for trace export (`{session_id}/turn_N/streaming_partial.json`) when the
//! canonical assistant turn never reached `record_assistant_response` — a user
//! cancel mid-stream, a sampler terminal error (e.g. `MaxTokensTruncation`), or
//! a doomloop where every generation returns reasoning-only and the turn errors
//! `reasoning_only`.
use std::fmt::Write;
use crate::session::acp_session::CapturePhase;
/// Hard cap on total bytes (reasoning + text) accumulated across all of a
/// turn's stream segments in a single `StreamingTurnCapture`. Past this we
/// mark `truncated = true` and stop appending so a runaway extended-thinking
/// turn (or a long doomloop) cannot blow memory.
pub(crate) const STREAMING_CAPTURE_MAX_BYTES: usize = 8_000_000;
/// Doom-loop recovery stamp on one generation: what the server reported and
/// what the recovery did about it. Raw trigger labels only — never content.
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
pub(crate) struct DoomLoopSegmentStamp {
/// Raw trigger labels the abort/accept acted on.
pub(crate) doom_loop_triggers: Vec<String>,
/// 1-based doom-resample attempt number within the turn.
pub(crate) attempt: u32,
/// Chunk index the mid-stream abort fired at; `None` for
/// terminal-response detections.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(crate) aborted_at_chunk: Option<u64>,
/// `"resampled"` (generation discarded, retried) or
/// `"accepted_after_budget"` (generation committed with signals).
pub(crate) action: String,
}
/// One generation within a turn: the reasoning + text the model streamed for a
/// single inference call. A doomloop turn produces several of these (one
/// reasoning-only generation per retry); a normal turn produces one.
#[derive(Debug, Default, Clone, serde::Serialize, serde::Deserialize)]
pub(crate) struct StreamSegment {
/// Stream-start timestamp (ms epoch) for this generation.
pub(crate) started_at_ms: Option<i64>,
/// Reasoning channel text for this generation.
pub(crate) reasoning_text: String,
/// Text channel content for this generation.
pub(crate) response_text: String,
/// Count of reasoning chunks received.
pub(crate) reasoning_chunks: u32,
/// Count of text chunks received.
pub(crate) text_chunks: u32,
/// Lifecycle phase this generation was last in.
pub(crate) phase: CapturePhase,
/// Doom-loop recovery stamp, when recovery acted on this generation.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(crate) doom_loop: Option<DoomLoopSegmentStamp>,
}
/// Per-turn snapshot of the model's streamed generations, retained out-of-band
/// from `chat_state`. The in-progress generation lives in the flat fields
/// (`reasoning_text` / `response_text` / `reasoning_chunks` / `text_chunks` /
/// `started_at_ms` / `phase`); finalized prior generations live in `segments`.
///
/// At upload-finalize the flat fields are rebuilt as a joined view of the
/// retained `segments`. This duplicates each retained generation's reasoning
/// (once under `segments[i]`, once joined in the flat `reasoning_text`) — a
/// deliberate back-compat tradeoff bounded by the byte cap: the currently
/// deployed trace viewer reads only the flat fields, so the joined view makes
/// the full doomloop visible without a frontend deploy, while `segments`
/// carries the structured per-attempt breakdown for newer readers.
///
/// Uploaded as `{session_id}/turn_N/streaming_partial.json` by
/// `upload_streaming_partial` whenever a non-completed turn end produces a
/// non-empty capture.
#[derive(Debug, Default, Clone, serde::Serialize, serde::Deserialize)]
pub(crate) struct StreamingTurnCapture {
/// `promptId` of the turn that produced this capture.
pub(crate) prompt_id: Option<String>,
/// Turn number for the trace artifact path.
pub(crate) turn_number: u64,
/// Resolved model id reported by the sampler, if available.
pub(crate) model_id: Option<String>,
/// Stream-start timestamp (ms epoch); the first retained generation's after
/// finalize.
pub(crate) started_at_ms: Option<i64>,
/// Reasoning channel text. The in-progress generation's during streaming;
/// the retained generations' joined view after finalize.
pub(crate) reasoning_text: String,
/// Text channel content. The in-progress generation's during streaming; the
/// retained generations' joined view after finalize.
pub(crate) response_text: String,
/// Count of reasoning chunks (summed across retained generations after
/// finalize).
pub(crate) reasoning_chunks: u32,
/// Count of text chunks (summed across retained generations after finalize).
pub(crate) text_chunks: u32,
/// `true` if the retained reasoning was clipped at
/// `STREAMING_CAPTURE_MAX_BYTES` (recomputed at finalize from the retained
/// segments; committed generations are cleared on `Completed` and never
/// counted, so a clip inside one is not carried).
pub(crate) truncated: bool,
/// Why the capture was taken — set when the consumer takes it.
/// e.g. `"user_cancel"`, `"sampler_error:max_tokens_truncation"`.
pub(crate) reason: Option<String>,
/// Which streaming lifecycle phase the model was last in (the last retained
/// generation's after finalize). See [`CapturePhase`].
pub(crate) phase: CapturePhase,
/// Finalized prior generations of this turn (one per inference call). The
/// in-progress generation lives in the flat fields above until
/// `start_stream` or `finalize_for_upload` folds it in here. A doomloop turn
/// ends with several reasoning-only segments; a normal turn keeps none.
#[serde(default)]
pub(crate) segments: Vec<StreamSegment>,
/// Number of model generations (inference calls) observed for this turn —
/// one per `StreamStarted`, counted independently of how many segments are
/// retained for upload, so a byte-capped doomloop still reports its true
/// attempt count. A large value is the doomloop signature.
#[serde(default)]
pub(crate) attempt_count: u32,
/// Reasoning-token count the sampler reported for the TERMINAL empty
/// response (the last attempt), not the doomloop sum — recorded even when
/// the reasoning text hit the byte cap.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(crate) reasoning_tokens: Option<u32>,
/// Completion-token count from the terminal empty response.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(crate) completion_tokens: Option<u32>,
/// `finish_reason` from the terminal empty response, if any.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(crate) finish_reason: Option<String>,
/// Sampler empty-response classification, e.g. `reasoning_only`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(crate) empty_reason: Option<String>,
/// Doom-loop stamp for the in-progress generation; folded into its
/// [`StreamSegment`] by `push_current_segment`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(crate) doom_loop: Option<DoomLoopSegmentStamp>,
}
impl StreamingTurnCapture {
/// Empty means nothing worth uploading. A terminal reasoning-only empty
/// response stamps the token magnitude (`reasoning_tokens` / `empty_reason`
/// / ...) even when no reasoning text was streamed to the shell, so those
/// fields keep the capture non-empty — otherwise the take gate would drop
/// the only record of the doomloop's size.
pub(crate) fn is_empty(&self) -> bool {
self.reasoning_text.is_empty()
&& self.response_text.is_empty()
&& self.segments.is_empty()
&& self.reasoning_tokens.is_none()
&& self.completion_tokens.is_none()
&& self.finish_reason.is_none()
&& self.empty_reason.is_none()
}
/// Reset the capture in-place and stamp the new turn's identifiers. Called
/// from `StreamStarted` only when the prompt id changes (or from the first
/// chunk if `StreamStarted` was dropped), so a same-turn restart — a
/// doomloop's next reasoning-only generation — never wipes the segments
/// already accumulated for this turn.
pub(crate) fn begin_turn(&mut self, prompt_id: Option<String>, turn_number: u64) {
*self = Self {
prompt_id,
turn_number,
..Self::default()
};
}
/// Open a fresh in-progress generation within the current turn: fold the
/// previous in-progress slot into `segments` (if it streamed anything),
/// stamp the new generation's start time, and count the attempt. Same-turn
/// restarts call this without `begin_turn`, so each generation is retained
/// and counted separately.
pub(crate) fn start_stream(&mut self, started_at_ms: i64) {
self.push_current_segment();
self.started_at_ms = Some(started_at_ms);
self.attempt_count += 1;
}
/// Fold the in-progress slot (the flat fields) into `segments` and clear it
/// for the next generation. A slot that streamed nothing is skipped —
/// unless it carries a doom-loop stamp, which folds text-free (the same
/// rule as `clear_current_segment`) so a stamp can never linger and
/// mislabel a later generation. Only uncommitted generations reach this —
/// a generation that emits `Completed` is discarded via
/// `clear_current_segment` instead.
pub(crate) fn push_current_segment(&mut self) {
if self.reasoning_text.is_empty()
&& self.response_text.is_empty()
&& self.phase != CapturePhase::ToolCall
&& self.doom_loop.is_none()
{
return;
}
self.segments.push(StreamSegment {
started_at_ms: self.started_at_ms,
reasoning_text: std::mem::take(&mut self.reasoning_text),
response_text: std::mem::take(&mut self.response_text),
reasoning_chunks: self.reasoning_chunks,
text_chunks: self.text_chunks,
phase: self.phase,
doom_loop: self.doom_loop.take(),
});
self.reasoning_chunks = 0;
self.text_chunks = 0;
self.phase = CapturePhase::default();
}
/// Stamp the in-progress generation with a doom-loop recovery action.
/// Telemetry only — the stamp rides into this generation's
/// [`StreamSegment`] when it is folded.
pub(crate) fn stamp_doom_loop(&mut self, stamp: DoomLoopSegmentStamp) {
self.doom_loop = Some(stamp);
}
/// Whether any retained generation carries a doom-loop stamp.
pub(crate) fn has_doom_loop_segments(&self) -> bool {
self.doom_loop.is_some() || self.segments.iter().any(|s| s.doom_loop.is_some())
}
/// Discard the in-progress generation without folding it into `segments`.
/// Called on `Completed`: that generation committed to `afterStateHistory`,
/// so its reasoning must neither be uploaded nor count against the byte cap
/// of later generations. A doom-stamped committed generation (a
/// budget-spent accept) keeps a TEXT-FREE segment so the stamp survives
/// for traces — the text itself lives in the committed history.
pub(crate) fn clear_current_segment(&mut self) {
if let Some(stamp) = self.doom_loop.take() {
self.segments.push(StreamSegment {
started_at_ms: self.started_at_ms,
reasoning_text: String::new(),
response_text: String::new(),
reasoning_chunks: self.reasoning_chunks,
text_chunks: self.text_chunks,
phase: self.phase,
doom_loop: Some(stamp),
});
}
self.reasoning_text.clear();
self.response_text.clear();
self.reasoning_chunks = 0;
self.text_chunks = 0;
self.phase = CapturePhase::default();
// Recompute `truncated` from what remains: dropping the slot's bytes may
// bring the turn back under the cap, so `append`'s sticky early-return
// must not keep suppressing later uncommitted generations because a
// since-discarded committed generation tripped the cap.
self.truncated = self.total_bytes() >= STREAMING_CAPTURE_MAX_BYTES;
}
/// Total reasoning + text bytes accumulated for the turn so far, across
/// every finalized segment plus the in-progress slot.
fn total_bytes(&self) -> usize {
let segment_bytes: usize = self
.segments
.iter()
.map(|s| s.reasoning_text.len() + s.response_text.len())
.sum();
self.reasoning_text.len() + self.response_text.len() + segment_bytes
}
/// Append text to the in-progress generation, respecting the total byte cap
/// across all of the turn's segments; clipped portions set `truncated`.
pub(crate) fn append(&mut self, channel_is_reasoning: bool, text: &str) {
// Record the phase before the cap check so it stays accurate even when
// the bytes themselves are clipped — the model is still in this phase
// regardless of whether we retained the text.
self.phase = if channel_is_reasoning {
CapturePhase::Reasoning
} else {
CapturePhase::ResponseText
};
// Already capped: skip the O(segments) byte recount on the rest of a
// runaway turn's chunks.
if self.truncated {
return;
}
let total = self.total_bytes();
if total >= STREAMING_CAPTURE_MAX_BYTES {
self.truncated = true;
return;
}
let remaining = STREAMING_CAPTURE_MAX_BYTES - total;
let to_append = if text.len() <= remaining {
text
} else {
self.truncated = true;
// Slice on a char boundary to keep the JSON serialization valid
// even when the cap lands mid-codepoint.
let mut cut = remaining;
while cut > 0 && !text.is_char_boundary(cut) {
cut -= 1;
}
&text[..cut]
};
if channel_is_reasoning {
self.reasoning_text.push_str(to_append);
self.reasoning_chunks += 1;
} else {
self.response_text.push_str(to_append);
self.text_chunks += 1;
}
}
/// Consolidate the turn for upload by folding the in-progress slot into
/// `segments`. `segments` only ever holds uncommitted generations (a
/// committed one is discarded on `Completed`), so every retained generation
/// — a doomloop retry or a cancel / error mid-stream — is uploaded
/// regardless of whether it carried reasoning, response text, or a tool
/// call. The flat back-compat fields are rebuilt from the segments; when
/// there are none the capture is left empty (no upload).
pub(crate) fn finalize_for_upload(&mut self) {
self.push_current_segment();
// `truncated` reflects only the retained reasoning: committed
// generations were cleared on `Completed` (never counted), and the
// in-progress slot was just folded in, so `total_bytes()` is exactly
// the kept bytes.
self.truncated = self.total_bytes() >= STREAMING_CAPTURE_MAX_BYTES;
let mut reasoning = String::new();
let mut response = String::new();
let mut reasoning_chunks = 0u32;
let mut text_chunks = 0u32;
for (i, seg) in self.segments.iter().enumerate() {
append_attempt(&mut reasoning, i, &seg.reasoning_text);
append_attempt(&mut response, i, &seg.response_text);
reasoning_chunks += seg.reasoning_chunks;
text_chunks += seg.text_chunks;
}
self.reasoning_text = reasoning;
self.response_text = response;
self.reasoning_chunks = reasoning_chunks;
self.text_chunks = text_chunks;
self.started_at_ms = self.segments.first().and_then(|s| s.started_at_ms);
self.phase = self.segments.last().map(|s| s.phase).unwrap_or_default();
}
}
/// Append `text` to `buf` as the `index`-th attempt's slice, inserting a
/// human-readable attempt separator before every non-empty slice after the
/// first. Empty slices contribute nothing (and no separator).
fn append_attempt(buf: &mut String, index: usize, text: &str) {
if text.is_empty() {
return;
}
if !buf.is_empty() {
let _ = write!(buf, "\n\n--- attempt {} ---\n\n", index + 1);
}
buf.push_str(text);
}
#[cfg(test)]
mod streaming_turn_capture_tests {
use super::{
CapturePhase, DoomLoopSegmentStamp, STREAMING_CAPTURE_MAX_BYTES, StreamingTurnCapture,
};
/// A doom stamp on a TEXTLESS slot (mid-stream abort before any delta
/// reached the shell) must still fold on the resample's `start_stream` —
/// the textless early-return must not let it linger and mislabel or be
/// overwritten by a later generation.
#[test]
fn textless_doom_stamp_folds_instead_of_leaking_to_next_generation() {
let mut cap = StreamingTurnCapture::default();
cap.begin_turn(Some("p1".to_owned()), 1);
cap.start_stream(1);
cap.stamp_doom_loop(DoomLoopSegmentStamp {
doom_loop_triggers: vec!["tail_repetition:8@thinking".to_string()],
attempt: 1,
aborted_at_chunk: Some(0),
action: "resampled".to_string(),
});
// Resample begins with no text streamed for the doomed attempt.
cap.start_stream(2);
cap.append(true, "fresh reasoning");
cap.push_current_segment();
assert_eq!(cap.segments.len(), 2, "stamp-only slot folded");
let stamped = cap.segments[0].doom_loop.as_ref().expect("stamp preserved");
assert_eq!(stamped.action, "resampled");
assert_eq!(stamped.attempt, 1);
assert!(cap.segments[0].reasoning_text.is_empty(), "text-free");
assert!(
cap.segments[1].doom_loop.is_none(),
"the fresh generation is not mislabeled by the lingering stamp"
);
assert_eq!(cap.segments[1].reasoning_text, "fresh reasoning");
}
/// Doom stamps ride the fold into segments, survive JSON round-trip, and
/// a stamped committed slot keeps a text-free segment on clear.
#[test]
fn doom_loop_stamp_folds_and_round_trips() {
let mut cap = StreamingTurnCapture::default();
cap.begin_turn(Some("p1".to_owned()), 1);
cap.start_stream(1);
cap.append(true, "loop loop");
cap.stamp_doom_loop(DoomLoopSegmentStamp {
doom_loop_triggers: vec!["tail_repetition:8@thinking".to_string()],
attempt: 1,
aborted_at_chunk: Some(42),
action: "resampled".to_string(),
});
// Resample folds the doomed slot with its stamp.
cap.start_stream(2);
cap.append(true, "fresh");
cap.stamp_doom_loop(DoomLoopSegmentStamp {
doom_loop_triggers: vec!["tail_repetition:4@thinking".to_string()],
attempt: 2,
aborted_at_chunk: None,
action: "accepted_after_budget".to_string(),
});
// Committed generation: text discarded, stamp retained text-free.
cap.clear_current_segment();
assert_eq!(cap.segments.len(), 2);
assert_eq!(
cap.segments[0]
.doom_loop
.as_ref()
.map(|s| s.action.as_str()),
Some("resampled")
);
assert_eq!(cap.segments[0].reasoning_text, "loop loop");
assert_eq!(
cap.segments[1]
.doom_loop
.as_ref()
.map(|s| s.action.as_str()),
Some("accepted_after_budget")
);
assert!(cap.segments[1].reasoning_text.is_empty());
assert!(cap.has_doom_loop_segments());
let json = serde_json::to_string(&cap).unwrap();
let back: StreamingTurnCapture = serde_json::from_str(&json).unwrap();
assert_eq!(back.segments[0].doom_loop, cap.segments[0].doom_loop);
// Unstamped captures serialize without the field at all.
let plain = serde_json::to_string(&StreamingTurnCapture::default()).unwrap();
assert!(!plain.contains("doom_loop"));
}
#[test]
fn same_turn_stream_starts_accumulate_segments() {
// Two `StreamStarted`s for the SAME prompt (a doomloop's two
// reasoning-only generations) must accumulate as two segments, not wipe
// each other — the original bug left only the last generation.
let mut cap = StreamingTurnCapture::default();
cap.begin_turn(Some("p1".to_owned()), 1);
cap.start_stream(10);
cap.append(true, "reasoning attempt one");
cap.start_stream(20);
cap.append(true, "reasoning attempt two");
cap.finalize_for_upload();
assert_eq!(cap.segments.len(), 2, "both same-turn generations kept");
assert_eq!(cap.attempt_count, 2);
assert_eq!(cap.segments[0].reasoning_text, "reasoning attempt one");
assert_eq!(cap.segments[1].reasoning_text, "reasoning attempt two");
assert!(cap.reasoning_text.contains("reasoning attempt one"));
assert!(cap.reasoning_text.contains("reasoning attempt two"));
assert!(cap.reasoning_text.contains("--- attempt 2 ---"));
assert_eq!(cap.started_at_ms, Some(10));
}
#[test]
fn finalize_keeps_only_uncommitted_generations() {
// A committed generation (Completed → slot cleared) never enters
// segments. Every uncommitted generation is kept regardless of content:
// reasoning (doomloop), response text (cancel/error mid-answer), or a
// tool call.
let mut cap = StreamingTurnCapture::default();
cap.begin_turn(Some("p1".to_owned()), 1);
cap.start_stream(1);
cap.append(true, "committed reasoning");
cap.append(false, "committed answer");
cap.clear_current_segment();
cap.start_stream(2);
cap.append(true, "doomloop reasoning");
cap.start_stream(3);
cap.append(false, "answer cut off");
cap.start_stream(4);
cap.append(true, "reasoning then a tool");
cap.phase = CapturePhase::ToolCall;
cap.finalize_for_upload();
assert_eq!(cap.segments.len(), 3, "committed cleared, uncommitted kept");
assert_eq!(cap.segments[0].reasoning_text, "doomloop reasoning");
assert_eq!(cap.segments[1].response_text, "answer cut off");
assert_eq!(cap.segments[2].phase, CapturePhase::ToolCall);
assert_eq!(cap.attempt_count, 4, "all four generations counted");
assert!(cap.reasoning_text.contains("doomloop reasoning"));
assert!(cap.response_text.contains("answer cut off"));
assert!(!cap.is_empty());
}
#[test]
fn finalize_with_only_committed_generations_is_empty() {
// A normal turn whose generation committed (Completed → slot cleared)
// leaves nothing to upload.
let mut cap = StreamingTurnCapture::default();
cap.begin_turn(Some("p1".to_owned()), 1);
cap.start_stream(1);
cap.append(true, "reasoning");
cap.append(false, "the answer");
cap.clear_current_segment();
cap.finalize_for_upload();
assert!(cap.segments.is_empty());
assert!(cap.is_empty());
}
#[test]
fn token_metadata_capture_is_not_empty() {
// A terminal reasoning-only empty response stamps the token magnitude
// even when no reasoning text reached the shell. That capture must
// still upload, so the take gate's `is_empty` check must not discard it.
let mut cap = StreamingTurnCapture::default();
cap.begin_turn(Some("p1".to_owned()), 1);
cap.empty_reason = Some("reasoning_only".to_owned());
cap.reasoning_tokens = Some(4096);
cap.finalize_for_upload();
assert!(cap.segments.is_empty());
assert!(
!cap.is_empty(),
"stamped token magnitude must survive upload"
);
}
#[test]
fn byte_cap_counts_across_segments() {
// The cap is enforced over the whole turn, not per generation: a
// finalized segment's bytes count against a later generation's room.
let mut cap = StreamingTurnCapture::default();
cap.begin_turn(Some("p1".to_owned()), 1);
cap.start_stream(1);
let big = "a".repeat(STREAMING_CAPTURE_MAX_BYTES * 3 / 4);
cap.append(true, &big);
assert!(!cap.truncated, "first generation fits under the cap");
cap.start_stream(2);
cap.append(true, &big);
assert!(
cap.truncated,
"the prior segment's bytes must count against the cap"
);
}
#[test]
fn clear_on_commit_resets_cap_so_later_generation_appends() {
// A committed generation that trips the cap is discarded on the commit
// path; clearing it must reset `truncated` so a following uncommitted
// (doomloop) generation's reasoning is still retained, not suppressed by
// the sticky early-return in `append`.
let mut cap = StreamingTurnCapture::default();
cap.begin_turn(Some("p1".to_owned()), 1);
cap.start_stream(1);
cap.append(true, &"a".repeat(STREAMING_CAPTURE_MAX_BYTES + 1));
assert!(cap.truncated, "the committed generation tripped the cap");
cap.clear_current_segment();
assert!(
!cap.truncated,
"clearing the committed slot resets the cap flag"
);
cap.start_stream(2);
cap.append(true, "doomloop reasoning after a capped commit");
cap.finalize_for_upload();
assert_eq!(cap.segments.len(), 1);
assert_eq!(
cap.segments[0].reasoning_text,
"doomloop reasoning after a capped commit"
);
assert!(!cap.truncated);
assert!(!cap.is_empty());
}
#[test]
fn new_prompt_id_resets_to_single_turn() {
// A genuinely new turn (different prompt id) wipes the prior turn's
// segments rather than appending to them.
let mut cap = StreamingTurnCapture::default();
cap.begin_turn(Some("p1".to_owned()), 1);
cap.start_stream(1);
cap.append(true, "first turn reasoning");
cap.start_stream(2);
cap.append(true, "first turn second gen");
cap.begin_turn(Some("p2".to_owned()), 2);
cap.start_stream(3);
cap.append(true, "second turn reasoning");
cap.finalize_for_upload();
assert_eq!(cap.prompt_id.as_deref(), Some("p2"));
assert_eq!(cap.turn_number, 2);
assert_eq!(cap.segments.len(), 1);
assert_eq!(cap.attempt_count, 1);
assert!(cap.reasoning_text.contains("second turn reasoning"));
assert!(!cap.reasoning_text.contains("first turn"));
}
#[test]
fn token_stamps_survive_finalize() {
// The terminal-attempt token magnitude stamped by `handle_sampling_failure`
// must ride through `finalize_for_upload`.
let mut cap = StreamingTurnCapture::default();
cap.begin_turn(Some("p1".to_owned()), 1);
cap.start_stream(1);
cap.append(true, "doomloop reasoning");
cap.reasoning_tokens = Some(4096);
cap.completion_tokens = Some(12);
cap.finish_reason = Some("stop".to_owned());
cap.empty_reason = Some("reasoning_only".to_owned());
cap.finalize_for_upload();
assert!(!cap.is_empty(), "the uncommitted reasoning segment is kept");
assert_eq!(cap.reasoning_tokens, Some(4096));
assert_eq!(cap.completion_tokens, Some(12));
assert_eq!(cap.finish_reason.as_deref(), Some("stop"));
assert_eq!(cap.empty_reason.as_deref(), Some("reasoning_only"));
}
}