1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
//! Acceptance test closing the same coverage-gap class in TR-4
//! (`.volter/tracker/markdown/TR-4.md`) that `tr2_dedup_guarantor.rs` and
//! `tr3_diff_guarantor.rs` already closed for `DuplicateOutput` and
//! `FileReadDiffed`: every existing `OutputNormalized` test
//! (`reduce_normalize.rs`) mints AND inverts against the SAME in-memory
//! `Session`/message vector (`project`/`project_messages` called directly on
//! a hand-built slice). None of them drive `OutputNormalized` through the
//! path the control guarantor actually re-runs: live `Agent` ->
//! recorder(disk sidecar) -> **reload from disk** -> offline
//! `verify_log`/`invert`.
//!
//! THE GAP: TR-12's regression (`tr12_cap_supersession.rs`) proved that a
//! hash minted from an in-memory copy of `history` can silently diverge from
//! what a reloaded sidecar recomputes, once `cap_tool_output` mutates the
//! bytes behind the mint site. `OutputNormalized`'s mint site (`reduce.rs`'s
//! T30/TR-4 pass, run from `Agent::build_request_messages` over
//! `self.history[1..]`) sits in exactly the same position in the pipeline —
//! nothing in the existing TR-4 suite would have caught an equivalent
//! divergence for `OutputNormalized` specifically, because none of those
//! tests ever touch a recorder, a sidecar file, or a disk reload. This test
//! fills that gap, reusing the exact harness idiom `tr2_dedup_guarantor.rs`/
//! `tr3_diff_guarantor.rs` established (`Agent::with_parts` + a scripted
//! `Provider`, a fixed-payload tool, `SidecarWriter`/`SessionStore`, offline
//! reload via `Session::from_sidecar_str`, `verify_log` + `invert`) — with a
//! tool named `"bash"` (one of [`supercode_harness::reduce::normalize::NORMALIZE_TOOLS`])
//! so the T30/TR-4 candidate rule (keyed on tool IDENTITY, `detect_normalize_candidates`)
//! actually fires, exactly like `tr3_diff_guarantor.rs` used a real
//! `read_file` call so TR-3's own path-keyed candidate rule would fire.
//!
//! dev/01-dev/02 (guarantor-path regression): a live agent runs a `"bash"`
//! tool call whose raw output is a synthetic ANSI/CR-redraw-heavy progress
//! bar (comfortably above `terminal_output_min_savings`'s floor once
//! normalized) — minting an `OutputNormalized` reduction. The sidecar and
//! reduction log are persisted and then reloaded from disk via
//! `SessionStore` (the exact primitive `cli/main.rs`'s
//! `show-reductions`/`convert`/`inspect` use) — never the live
//! `Agent`/in-memory `Session`. `verify_log` and `invert` both run OFFLINE
//! against that reloaded state and must pass clean, with `invert` restoring
//! the full RAW captured bytes byte-exact (never the normalized/rendered
//! text) — the "lossy view over lossless sidecar" contract (SPEC.md B10).
use std::path::PathBuf;
use std::sync::atomic::{AtomicUsize, Ordering};
use async_trait::async_trait;
use supercode_harness::reduce::{
invert, project_messages, verify_log, ReductionKind, ReductionPolicy,
};
use supercode_harness::session::Session;
use supercode_harness::sidecar::SidecarWriter;
use supercode_harness::store::SessionStore;
use supercode_harness::{
Agent, ChatMessage, ChatRequest, Config, FunctionCall, Provider, Role, ToolCall, Usage,
};
fn temp_dir(tag: &str) -> PathBuf {
static N: AtomicUsize = AtomicUsize::new(0);
let dir = std::env::temp_dir().join(format!(
"supercode-tr4-guarantor-{tag}-{}-{}",
std::process::id(),
N.fetch_add(1, Ordering::SeqCst)
));
std::fs::create_dir_all(&dir).unwrap();
dir
}
/// A synthetic `cargo build`-style progress bar: many `\r`-overwritten
/// frames wrapped in SGR color codes, ending with a final frame and a plain
/// newline. Deterministic and self-contained (no real terminal capture
/// needed) — mirrors the fixture style documented in TR-4.md's dev/01, just
/// generated in-line so this test has no file-fixture dependency.
fn ansi_progress_bar(frames: usize) -> String {
let mut raw = String::new();
for i in 0..frames {
let pct = (i * 100) / frames.max(1);
raw.push_str(&format!(
"\x1b[2K\r\x1b[32mDownloading widget-crate: {pct:>3}%\x1b[0m"
));
}
raw.push_str("\x1b[2K\r\x1b[32mDownloading widget-crate: 100%\x1b[0m\n");
raw.push_str("Done.\n");
raw
}
/// A tool named `"bash"` (one of
/// [`supercode_harness::reduce::normalize::NORMALIZE_TOOLS`]) that always returns the
/// SAME fixed raw ANSI/CR-heavy payload — the T30/TR-4 normalize candidate,
/// analogous to `tr2_dedup_guarantor.rs`'s `DupTool`.
struct FixedBashTool(String);
#[async_trait]
impl supercode_harness::tools::Tool for FixedBashTool {
fn name(&self) -> &str {
"bash"
}
fn description(&self) -> &str {
"x"
}
fn parameters(&self) -> serde_json::Value {
serde_json::json!({"type": "object"})
}
async fn execute(
&self,
_a: serde_json::Value,
_c: &supercode_harness::tools::ToolContext,
) -> supercode_harness::Result<String> {
Ok(self.0.clone())
}
}
fn bash_call_msg(id: &str) -> ChatMessage {
ChatMessage {
role: Role::Assistant,
content: None,
content_parts: None,
tool_calls: Some(vec![ToolCall {
id: id.to_string(),
kind: "function".to_string(),
function: FunctionCall {
name: "bash".to_string(),
arguments: serde_json::json!({"command": "cargo build"}).to_string(),
},
}]),
tool_call_id: None,
name: None,
metadata: Default::default(),
}
}
/// One `bash` tool call, then a plain-text reply ends the turn (mirrors
/// `tr3_diff_guarantor.rs`'s `ReadEditReReadThenPlain` shape, simplified to a
/// single call since T30/TR-4 needs no second turn to fire).
struct BashThenPlain {
calls: AtomicUsize,
}
#[async_trait]
impl Provider for BashThenPlain {
async fn complete(
&self,
_req: &ChatRequest,
_on_delta: &(dyn for<'a> Fn(&'a str) + Send + Sync),
) -> supercode_harness::Result<(ChatMessage, Usage)> {
let n = self.calls.fetch_add(1, Ordering::SeqCst);
match n {
0 => Ok((bash_call_msg("c0"), Usage::default())),
_ => Ok((
ChatMessage::assistant(format!("done {n}")),
Usage::default(),
)),
}
}
}
/// dev/01-dev/02 (guarantor-path regression, TR-4 coverage-gap closure): a
/// live agent run with a recorder + `ReductionPolicy` both active produces an
/// `OutputNormalized` reduction from a real `"bash"`-named tool call;
/// `verify_log` AND `invert` then run OFFLINE against the sidecar and
/// reduction log reloaded from disk (never the live `Agent`/in-memory
/// `Session`). Both must pass clean, and `invert` must restore the full RAW
/// captured bytes byte-exact — never the normalized text.
#[tokio::test]
async fn output_normalized_survives_live_agent_disk_reload_offline_verify_and_invert() {
let dir = temp_dir("dev01-dev02");
let store_dir = dir.join("store");
let store = SessionStore::open(&store_dir).unwrap();
let name = "tr4-dev01";
let sidecar_path = store.sidecar_path(name);
// 100 CR-overwritten, SGR-colored progress frames: comfortably above
// `terminal_output_min_savings`'s default 128B floor once collapsed down
// to its final rendered line, and comfortably below the default A7
// `tool_output_trigger_bytes` (8,192B) so this test's mint is
// unambiguously T30/TR-4's own pass, not A7's.
let raw = ansi_progress_bar(100);
assert!(
raw.len() < ReductionPolicy::default().tool_output_trigger_bytes,
"fixture must stay under the A7 trigger so only OutputNormalized can fire: {}B",
raw.len()
);
let config = Config::builder().cwd(dir.clone()).build();
let mut reg = supercode_harness::tools::ToolRegistry::new();
reg.register(FixedBashTool(raw.clone()));
let mut agent = Agent::with_parts(
config,
Box::new(BashThenPlain {
calls: AtomicUsize::new(0),
}),
reg,
);
let empty_session = Session::from_claude_code_str("").unwrap();
let writer = SidecarWriter::create(&sidecar_path, &empty_session).unwrap();
agent.set_recorder(writer);
let policy = ReductionPolicy::default();
agent.set_reduction_policy(policy.clone());
let reply = agent.send("build the widget crate").await.unwrap();
assert!(reply.starts_with("done"), "unexpected reply: {reply}");
let log_live = agent.reduction_log().clone();
let normalized: Vec<_> = log_live
.reductions
.iter()
.filter(|r| matches!(r.kind, ReductionKind::OutputNormalized { .. }))
.collect();
assert_eq!(
normalized.len(),
1,
"the noisy bash output must mint exactly one OutputNormalized reduction: {:?}",
log_live.reductions
);
let norm_idx = normalized[0].ptr.addr.index;
let (orig_bytes, norm_bytes) = match normalized[0].kind {
ReductionKind::OutputNormalized {
original_bytes,
normalized_bytes,
} => (original_bytes, normalized_bytes),
_ => unreachable!(),
};
assert_eq!(orig_bytes, raw.len());
assert!(
orig_bytes.saturating_sub(norm_bytes) >= policy.terminal_output_min_savings,
"the mint must have cleared the savings floor: {orig_bytes}B -> {norm_bytes}B"
);
// Never claimed by any other pass this run.
assert_eq!(
log_live
.reductions
.iter()
.filter(|r| r.ptr.addr.index == norm_idx)
.count(),
1,
"the normalized message must carry exactly one reduction, not double-claimed"
);
// Persist the reduction log the way the CLI does (`sessions
// show-reductions`/`convert`/`inspect` all call `store.save_reduction_log`
// via the resume/chat path).
store.save_reduction_log(name, &log_live).unwrap();
// --- OFFLINE from here: fresh reads from disk, no live Agent/Session ---
let sidecar_jsonl = store
.load_sidecar(name)
.unwrap()
.expect("sidecar must exist on disk");
let sidecar = Session::from_sidecar_str(&sidecar_jsonl).unwrap();
let log_reloaded = store
.load_reduction_log(name)
.unwrap()
.expect("reduction log must exist on disk");
// Confirm the reloaded-from-disk log still carries the OutputNormalized
// reduction before verifying/inverting against it.
assert!(
log_reloaded
.reductions
.iter()
.any(|r| matches!(r.kind, ReductionKind::OutputNormalized { .. })),
"the reloaded-from-disk log must still carry the OutputNormalized reduction: {:?}",
log_reloaded.reductions
);
// The sidecar itself must hold the RAW (unnormalized) bytes at this
// index -- A3's "sidecar keeps everything" contract, independent of
// whatever the projected view showed.
assert_eq!(
sidecar.messages[norm_idx].content.as_deref(),
Some(raw.as_str()),
"the sidecar must retain the RAW captured bytes, never the normalized view text"
);
// The exact primitive `cli/main.rs`'s `show-reductions`/`convert`/
// `inspect` all call before doing anything else with a reduced session.
verify_log(&log_reloaded, &sidecar)
.expect("verify_log must pass clean against the reloaded-from-disk sidecar");
let (final_view, reprojected_log) = project_messages(&sidecar.messages, &policy, &log_reloaded);
assert_eq!(
reprojected_log, log_reloaded,
"re-projecting from the reloaded sidecar with its own log must not invent new reductions"
);
let inverted = invert(&final_view, &log_reloaded, &sidecar)
.expect("invert must pass clean against the reloaded-from-disk sidecar");
assert_eq!(inverted.len(), sidecar.messages.len());
for (a, b) in inverted.iter().zip(&sidecar.messages) {
assert_eq!(a.role, b.role);
assert_eq!(a.content, b.content);
}
// Byte-exact restore of the RAW captured bytes -- never the
// normalized/rendered text -- from the disk-reloaded sidecar.
assert_eq!(
inverted[norm_idx].content.as_deref(),
Some(raw.as_str()),
"invert must restore the RAW captured bytes byte-exact from disk, not the normalized view"
);
std::fs::remove_dir_all(&dir).ok();
}