supercode-harness 0.4.12

The optional native Supercode agent and tool harness
Documentation
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
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
//! UX-30 dev/02 — `Agent::set_model` actually changes what's sent on the
//! wire, not just a label somewhere. A mock `Provider` records the
//! `req.model` it was handed on each call; this drives two real `send()`
//! turns through the same agent, switching the model in between via
//! `set_model`, and asserts the SECOND request carried the NEW model while
//! the first carried the original — proving `run_loop` reads
//! `Config::model` fresh per request rather than baking it in at
//! construction. Same `ScriptedProvider` idiom as `tests/agent_loop.rs`.

use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::{Arc, Mutex};

use async_trait::async_trait;
use supercode_harness::{Agent, ChatMessage, ChatRequest, Config, Provider, Usage};

struct RecordingProvider {
    calls: AtomicUsize,
    seen_models: Arc<Mutex<Vec<String>>>,
}

#[async_trait]
impl Provider for RecordingProvider {
    async fn complete(
        &self,
        req: &ChatRequest,
        _on_delta: &(dyn for<'a> Fn(&'a str) + Send + Sync),
    ) -> supercode_harness::Result<(ChatMessage, Usage)> {
        self.calls.fetch_add(1, Ordering::SeqCst);
        self.seen_models.lock().unwrap().push(req.model.clone());
        Ok((ChatMessage::assistant("ok"), Usage::default()))
    }
}

#[tokio::test]
async fn set_model_changes_the_model_on_the_very_next_request() {
    let config = Config::builder().model("vendor/model-a").build();
    let seen = Arc::new(Mutex::new(Vec::new()));
    let provider = Box::new(RecordingProvider {
        calls: AtomicUsize::new(0),
        seen_models: seen.clone(),
    });
    let mut agent = Agent::with_provider(config, provider);

    assert_eq!(agent.model(), "vendor/model-a");
    agent.send("first").await.unwrap();

    agent.set_model("vendor/model-b");
    assert_eq!(agent.model(), "vendor/model-b");
    agent.send("second").await.unwrap();

    let seen = seen.lock().unwrap();
    assert_eq!(
        seen.as_slice(),
        &["vendor/model-a".to_string(), "vendor/model-b".to_string()],
        "the second request must carry the switched model, not a stale copy"
    );
}

#[tokio::test]
async fn set_model_before_any_request_is_honored_from_the_first_send() {
    let config = Config::builder().model("vendor/model-a").build();
    let seen = Arc::new(Mutex::new(Vec::new()));
    let provider = Box::new(RecordingProvider {
        calls: AtomicUsize::new(0),
        seen_models: seen.clone(),
    });
    let mut agent = Agent::with_provider(config, provider);

    agent.set_model("vendor/model-c");
    agent.send("hi").await.unwrap();

    assert_eq!(
        seen.lock().unwrap().as_slice(),
        &["vendor/model-c".to_string()]
    );
}

// =============================================================================
// P4c (COMPOSABLE-HARNESS-DESIGN.md §5.2 "P4" core NEW-significant item,
// §1.10/§3.1 `core.model_switch.allow_switch`, dep 8): `Agent::switch_model`
// — the persisted `model_change` record + reasoning-artifact filtering on
// top of the UX-30 dev/02 mechanics proven above.
// =============================================================================

use supercode_harness::model_change::ModelChangeRecord;
use supercode_harness::session::Session;

/// A `Session` carrying one assistant message with model-A's reasoning
/// artifacts attached — both as `metadata` (the shape `Session`'s real
/// Claude Code/Codex importers actually produce, per `session.rs`) and as a
/// `content_parts` reasoning block (the defensive/future-provider shape) —
/// so a single fixture exercises both `filter_reasoning_artifacts` branches.
fn session_with_reasoning_artifacts() -> Session {
    let mut session = Session::from_claude_code_str("").unwrap();
    let mut assistant = ChatMessage::assistant("here's my answer");
    assistant.metadata.insert(
        "thinking".to_string(),
        "model-A's private chain of thought".to_string(),
    );
    assistant
        .metadata
        .insert("thinking_signature".to_string(), "sig-abc".to_string());
    assistant.content_parts = Some(vec![
        serde_json::json!({"type": "text", "text": "here's my answer"}),
        serde_json::json!({"type": "thinking", "text": "model-A's private chain of thought"}),
    ]);
    session.messages = vec![ChatMessage::user("question"), assistant];
    session
}

/// Default off: `switch_model` with `allow_switch` unset (the default) is
/// byte-identical to `set_model` — no record, no filtering. This is the
/// item-9 default-off proof.
#[tokio::test]
async fn switch_model_default_off_is_byte_identical_to_set_model() {
    let config = Config::builder().model("vendor/model-a").build();
    assert!(!config.model_switch_allow_switch);
    let mut agent = Agent::with_provider(
        config,
        Box::new(RecordingProvider {
            calls: AtomicUsize::new(0),
            seen_models: Arc::new(Mutex::new(Vec::new())),
        }),
    );
    agent.load_session(session_with_reasoning_artifacts());
    let before = agent.history().to_vec_metadata_snapshot();

    agent.switch_model("vendor/model-b");

    assert_eq!(agent.model(), "vendor/model-b");
    assert!(
        agent.model_change_records().is_empty(),
        "allow_switch=false must never create a model_change record"
    );
    // Reasoning artifacts are UNTOUCHED — the filter never ran.
    assert_eq!(agent.history().to_vec_metadata_snapshot(), before);
}

/// Happy path + the explicit item-9 proof requirement: with `allow_switch`
/// on, switching models (1) persists a typed `ModelChangeRecord`, and (2)
/// filters model-A's reasoning artifacts out of `history` so they can never
/// reach model-B's context on the next request built from it.
#[tokio::test]
async fn switch_model_on_filters_reasoning_artifacts_and_records_the_switch() {
    let config = Config::builder()
        .model("vendor/model-a")
        .model_switch_allow_switch(true)
        .build();
    let seen = Arc::new(Mutex::new(Vec::new()));
    let mut agent = Agent::with_provider(
        config,
        Box::new(RecordingProvider {
            calls: AtomicUsize::new(0),
            seen_models: seen.clone(),
        }),
    );
    agent.load_session(session_with_reasoning_artifacts());

    // Sanity: the fixture really does carry reasoning artifacts before the
    // switch (metadata never reaches the wire regardless, but this proves
    // the filter has actual work to do, not a vacuous pass).
    let assistant_before = agent
        .history()
        .iter()
        .find(|m| m.content.as_deref() == Some("here's my answer"))
        .unwrap();
    assert!(assistant_before.metadata.contains_key("thinking"));
    assert!(assistant_before
        .content_parts
        .as_ref()
        .unwrap()
        .iter()
        .any(|p| p["type"] == "thinking"));

    agent.switch_model("vendor/model-b");

    // (1) Persisted record.
    let records = agent.model_change_records();
    assert_eq!(records.len(), 1);
    let r = &records[0];
    assert_eq!(r.from_model, "vendor/model-a");
    assert_eq!(r.to_model, "vendor/model-b");
    assert!(r.reasoning_filtered);
    assert!(
        r.reasoning_artifacts_filtered >= 1,
        "the fixture message should have been counted as touched"
    );

    // (2) model-A's reasoning never reaches model-B's context: `history` —
    // the exact source `run_loop` builds the NEXT request from — no longer
    // carries the metadata key OR the content_parts reasoning block.
    let assistant_after = agent
        .history()
        .iter()
        .find(|m| {
            m.content.as_deref() == Some("here's my answer")
                || m.content_parts
                    .as_ref()
                    .map(|p| p.iter().any(|x| x["type"] == "text"))
                    .unwrap_or(false)
        })
        .expect("the assistant message with reasoning stripped is still present");
    assert!(!assistant_after.metadata.contains_key("thinking"));
    assert!(!assistant_after.metadata.contains_key("thinking_signature"));
    let parts_after = assistant_after.content_parts.as_ref().unwrap();
    assert!(
        parts_after.iter().all(|p| p["type"] != "thinking"),
        "{parts_after:?}"
    );
    // The non-reasoning text part survives.
    assert!(parts_after.iter().any(|p| p["type"] == "text"));

    // Drive an actual request through model-B and confirm the (already-
    // wire-excluded) metadata point holds end-to-end too: the request the
    // NEW model receives carries the switched model id.
    agent.send("continue").await.unwrap();
    assert_eq!(
        seen.lock().unwrap().last(),
        Some(&"vendor/model-b".to_string())
    );
}

/// Boundary: switching to the model already in effect is a no-op — no
/// record is created (nothing actually changed, nothing to filter for).
#[tokio::test]
async fn switch_model_to_the_same_model_is_a_no_op_boundary() {
    let config = Config::builder()
        .model("vendor/model-a")
        .model_switch_allow_switch(true)
        .build();
    let mut agent = Agent::with_provider(
        config,
        Box::new(RecordingProvider {
            calls: AtomicUsize::new(0),
            seen_models: Arc::new(Mutex::new(Vec::new())),
        }),
    );
    agent.switch_model("vendor/model-a");
    assert_eq!(agent.model(), "vendor/model-a");
    assert!(agent.model_change_records().is_empty());
}

/// "round-trip losslessly through the store" (item 9's explicit
/// requirement) proven at the `Agent` level, not just `model_change.rs`'s
/// own unit tests: records `switch_model` produces survive a real
/// save/load cycle through `SessionStore`.
#[tokio::test]
async fn switch_model_records_round_trip_through_the_store() {
    let config = Config::builder()
        .model("vendor/model-a")
        .model_switch_allow_switch(true)
        .build();
    let mut agent = Agent::with_provider(
        config,
        Box::new(RecordingProvider {
            calls: AtomicUsize::new(0),
            seen_models: Arc::new(Mutex::new(Vec::new())),
        }),
    );
    agent.load_session(session_with_reasoning_artifacts());
    agent.switch_model("vendor/model-b");
    agent.switch_model("vendor/model-c");
    assert_eq!(agent.model_change_records().len(), 2);

    let tmp = std::env::temp_dir().join(format!(
        "sc-p4c-model-change-store-{}-{}",
        std::process::id(),
        std::time::SystemTime::now()
            .duration_since(std::time::UNIX_EPOCH)
            .unwrap()
            .as_nanos()
    ));
    let store = supercode_harness::SessionStore::open(&tmp).unwrap();
    store.save("sess", "t", "[]").unwrap();
    agent.save_model_change_log(&store, "sess").unwrap();
    let loaded: Vec<ModelChangeRecord> = store.load_model_change_log("sess").unwrap();
    assert_eq!(loaded, agent.model_change_records());
    let _ = std::fs::remove_dir_all(&tmp);
}

// =============================================================================
// P4c-review (MEDIUM, proven): end-to-end reasoning leak through export.
// `filter_reasoning_artifacts` missed the `"thinking_blocks"` metadata key —
// the Claude Code importer's per-block reasoning list (`session.rs:3475-
// 3480`), which the Claude Code EXPORTER prefers over the legacy singular
// `thinking`/`thinking_signature` fields whenever present (`session.rs:5654-
// 5676`). Left unstripped, a mid-session `switch_model` did NOT actually
// prevent model-A's full signed chain-of-thought from being resurrected —
// re-attributed to model-B's session — the moment that session was exported
// back to Claude Code format. This test drives the exact failure mode:
// import-shaped `thinking_blocks` metadata -> `switch_model` A->B with
// `allow_switch` on -> export to Claude Code -> assert the exported BYTES
// (not just in-memory `history`) never contain model-A's reasoning text or
// signature.
// =============================================================================

use supercode_harness::SessionFormat;

/// A model-A secret chain-of-thought + its signature, kept as constants so
/// the leak assertion below and the fixture construction can't drift apart.
const MODEL_A_SECRET_COT: &str = "MODEL-A-SECRET-CHAIN-OF-THOUGHT-7f3a";
const MODEL_A_SECRET_SIGNATURE: &str = "MODEL-A-SECRET-SIGNATURE-9c21";

/// Mirrors exactly what `Session::from_claude_code_str`'s
/// `push_claude_assistant` writes at `session.rs:3475-3480` for a message
/// whose Claude Code record carried a `thinking` content block: a
/// `metadata["thinking_blocks"]` value that is a serialized JSON array of
/// `{"type":"thinking","thinking":<text>,"signature":<sig>}` objects — see
/// `session.rs:3336-3340`'s exact block shape. Deliberately does NOT also
/// set the legacy singular `"thinking"`/`"thinking_signature"` keys, so this
/// fixture isolates the `thinking_blocks` path: if `filter_reasoning_artifacts`
/// ever stops stripping `"thinking_blocks"` (e.g. it's reverted out of
/// `REASONING_METADATA_KEYS`), this is the ONLY metadata key carrying the
/// secret text, so the leak assertion below has nothing else masking it.
fn session_with_only_thinking_blocks_reasoning() -> Session {
    let mut session = Session::from_claude_code_str("").unwrap();
    let mut assistant = ChatMessage::assistant("here's my answer");
    assistant.metadata.insert(
        "thinking_blocks".to_string(),
        serde_json::json!([{
            "type": "thinking",
            "thinking": MODEL_A_SECRET_COT,
            "signature": MODEL_A_SECRET_SIGNATURE,
        }])
        .to_string(),
    );
    session.messages = vec![ChatMessage::user("question"), assistant];
    session
}

#[tokio::test]
async fn switch_model_then_claude_code_export_never_resurrects_model_a_thinking_blocks() {
    let config = Config::builder()
        .model("vendor/model-a")
        .model_switch_allow_switch(true)
        .build();
    let mut agent = Agent::with_provider(
        config,
        Box::new(RecordingProvider {
            calls: AtomicUsize::new(0),
            seen_models: Arc::new(Mutex::new(Vec::new())),
        }),
    );
    agent.load_session(session_with_only_thinking_blocks_reasoning());

    // Sanity: the fixture really carries the secret before the switch.
    let before = agent
        .history()
        .iter()
        .find(|m| m.content.as_deref() == Some("here's my answer"))
        .unwrap();
    assert_eq!(
        before
            .metadata
            .get("thinking_blocks")
            .map(|s| s.contains(MODEL_A_SECRET_COT)),
        Some(true)
    );

    agent.switch_model("vendor/model-b");
    let r = &agent.model_change_records()[0];
    assert!(r.reasoning_filtered);
    assert!(r.reasoning_artifacts_filtered >= 1);

    // Build a fresh Session from the post-switch history (exactly what a
    // real `/export` or cross-format translator would work from) and export
    // it to Claude Code — the format whose exporter specifically prefers
    // `thinking_blocks` over the legacy fields.
    let mut exported = Session::from_claude_code_str("").unwrap();
    exported.messages = agent.history().to_vec();
    let jsonl = exported.to_jsonl(SessionFormat::ClaudeCode).unwrap();

    assert!(
        !jsonl.contains(MODEL_A_SECRET_COT),
        "model-A's reasoning text leaked into the Claude Code export: {jsonl}"
    );
    assert!(
        !jsonl.contains(MODEL_A_SECRET_SIGNATURE),
        "model-A's reasoning signature leaked into the Claude Code export: {jsonl}"
    );

    // Contrast control: prove this assertion isn't vacuous by confirming the
    // secret WOULD have shown up in the export had the switch not run (i.e.
    // the CC exporter really does surface `thinking_blocks` when present).
    let mut unfiltered = session_with_only_thinking_blocks_reasoning();
    unfiltered.messages.remove(0); // drop the user turn, keep only the assistant message
    let unfiltered_jsonl = unfiltered.to_jsonl(SessionFormat::ClaudeCode).unwrap();
    assert!(
        unfiltered_jsonl.contains(MODEL_A_SECRET_COT),
        "contrast control failed: the CC exporter should surface an unfiltered \
         thinking_blocks message's reasoning text — {unfiltered_jsonl}"
    );
}

/// A helper extension trait so the "reasoning artifacts untouched" default-
/// off assertion above can compare a cheap, order-independent snapshot
/// instead of the whole `ChatMessage` (which isn't `PartialEq`).
trait MetadataSnapshot {
    fn to_vec_metadata_snapshot(&self) -> Vec<std::collections::BTreeMap<String, String>>;
}
impl MetadataSnapshot for [ChatMessage] {
    fn to_vec_metadata_snapshot(&self) -> Vec<std::collections::BTreeMap<String, String>> {
        self.iter().map(|m| m.metadata.clone()).collect()
    }
}