supercode-harness 0.4.6

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
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
//! Real-SQLite conformance tests for the OpenCode loader (PARITY-1/3/4
//! REOPEN — Fable-5 review of `parity/opencode-native` @13ca171).
//!
//! Unlike `opencode_interop.rs` (which exercises the envelope-form/
//! export-document read surfaces against a hand-written `.jsonl`/`.json`
//! fixture), this file EXERCISES `Session::from_opencode_sqlite` /
//! `opencode_sqlite_store_stats` / `opencode_sqlite_session_ids` /
//! `audit_dir(Corpus::OpenCode)`'s SQLite branch against a genuine SQLite
//! database: `tests/fixtures/opencode_fixture/opencode.db`.
//!
//! That fixture is NOT hand-authored DDL — see
//! `tests/fixtures/gen_opencode_fixture.py`'s doc comment: the schema is
//! copied verbatim from the real, generated
//! `packages/core/src/database/schema.gen.ts` in `sst/opencode@fd9ee43`
//! (the authoritative schema pin for this work), and the row `data` JSON
//! shapes match the real `SessionV1.{Info,Part,...}` schema at the same
//! pin. It encodes the hard cases from the review: every legitimately
//! nullable `session` column actually NULL, a `revert` column carrying the
//! V2 `Revert.State` extra `files` field, a `todo` row (real schema DOES
//! have `time_created`/`time_updated` — D1 was reconfirmed false on this
//! point), a non-image (`https:`) file part next to a `data:`-URI image
//! file part, an `ignored:true` text part, a completed AND an errored tool
//! call, a second (older) session for multi-session detection, and a
//! `session_diff` JSON sidecar mirroring a real revert.

use std::path::{Path, PathBuf};

use serde_json::Value;
use supercode_harness::audit::{audit_dir, Corpus};
use supercode_harness::session::{
    opencode_sqlite_primary_id, opencode_sqlite_session_ids, opencode_sqlite_store_stats, Session,
};
use supercode_harness::Role;

const SESSION_A: &str = "ses_fixtureAAAAAAAAAAAAAAA1";
const SESSION_B: &str = "ses_fixtureBBBBBBBBBBBBBBB1";

fn fixture_db() -> PathBuf {
    Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/opencode_fixture/opencode.db")
}

fn fixture_dir() -> PathBuf {
    Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/opencode_fixture")
}

// ---- store-level counts (PARITY-3 AC01) ------------------------------------

#[test]
fn store_stats_report_real_counts() {
    let stats = opencode_sqlite_store_stats(&fixture_db()).expect("store stats must not fail");
    assert_eq!(stats.sessions, 2, "session A + session B");
    assert_eq!(stats.messages, 4, "2 messages/session x 2 sessions");
    assert_eq!(
        stats.parts, 9,
        "4 (user A) + 3 (asst A) + 1 (user B) + 1 (asst B)"
    );
    assert_eq!(stats.todos, 1, "one todo row, session A only");
}

// ---- D6: multi-session detection -------------------------------------------

#[test]
fn multi_session_store_lists_both_sessions_and_picks_the_newer_as_primary() {
    let ids = opencode_sqlite_session_ids(&fixture_db()).expect("must list session ids");
    assert_eq!(ids.len(), 2);
    assert!(ids.contains(&SESSION_A.to_string()));
    assert!(ids.contains(&SESSION_B.to_string()));

    let primary = opencode_sqlite_primary_id(&fixture_db()).expect("must pick a primary session");
    assert_eq!(
        primary, SESSION_A,
        "session A is newer (time_created/time_updated) — must be auto-picked"
    );
}

// ---- D1 (re-confirmed NOT a defect): every legitimately-nullable column
// loads fine — no crash, no panic, no `Err` — and `cost`/`tokens_*` (real
// schema: NOT NULL DEFAULT 0) read back correctly as their real values. ----

#[test]
fn session_a_loads_with_every_nullable_column_actually_null() {
    // Explicit session id, so this exercises `from_opencode_sqlite` with
    // `Some(id)`, independent of primary-selection.
    let session =
        Session::from_opencode_sqlite(&fixture_db(), Some(SESSION_A)).expect("must load cleanly");
    assert_eq!(session.meta.session_id.as_deref(), Some(SESSION_A));

    // The session-info envelope (opencode_headers[0]) carries the raw
    // column values — assert the NULL-able ones round-tripped as JSON
    // null/absent, not a crash and not a bogus default silently swallowed.
    let info = &session.meta.opencode_headers[0];
    assert!(
        info.get("workspaceID").is_none_or(Value::is_null),
        "workspace_id must load as null, not crash"
    );
    assert!(info.get("path").is_none_or(Value::is_null));
    assert!(info.get("agent").is_none_or(Value::is_null));
    assert_eq!(
        info.get("model").cloned().unwrap_or(Value::Null),
        Value::Null,
        "model column was NULL — opencode_json_col(None) must yield Null, not error"
    );

    // `cost`/`tokens_*` are NOT NULL DEFAULT 0 in the real schema (D1's
    // premise that they're nullable was refuted against
    // `sst/opencode@fd9ee43`'s `schema.gen.ts`/`sql.ts` — see the build
    // report) — assert the real fixture values load correctly as numbers.
    assert_eq!(info.get("cost").and_then(Value::as_f64), Some(0.0421));
    assert_eq!(
        info.get("tokens")
            .and_then(|t| t.get("input"))
            .and_then(Value::as_i64),
        Some(120)
    );
}

#[test]
fn session_b_secondary_loads_with_model_and_agent_present() {
    let session =
        Session::from_opencode_sqlite(&fixture_db(), Some(SESSION_B)).expect("must load cleanly");
    let info = &session.meta.opencode_headers[0];
    assert_eq!(info.get("agent").and_then(Value::as_str), Some("build"));
    assert!(
        info.get("model").is_some_and(|m| !m.is_null()),
        "session B's model column is non-NULL and must decode"
    );
}

// ---- S9c: `revert`'s extra `files` field survives raw -----------------------

#[test]
fn revert_column_files_field_survives_raw_verbatim() {
    let session =
        Session::from_opencode_sqlite(&fixture_db(), Some(SESSION_A)).expect("must load cleanly");
    let info = &session.meta.opencode_headers[0];
    let files = info
        .get("revert")
        .and_then(|r| r.get("files"))
        .and_then(Value::as_array)
        .expect("revert.files must survive in the raw envelope (S9c)");
    assert_eq!(files.len(), 1);
    assert_eq!(
        files[0].get("path").and_then(Value::as_str),
        Some("src/a.txt")
    );
}

// ---- D2: message/part row timestamps carried in the envelope --------------

#[test]
fn message_and_part_envelope_lines_carry_row_timestamps() {
    let session =
        Session::from_opencode_sqlite(&fixture_db(), Some(SESSION_A)).expect("must load cleanly");
    let mut saw_message_ts = false;
    let mut saw_part_ts = false;
    for line in &session.raw {
        let env: Value = serde_json::from_str(line).unwrap();
        let Some(key) = env.get("key").and_then(Value::as_array) else {
            continue;
        };
        let kind = key.first().and_then(Value::as_str).unwrap_or("");
        let value = env.get("value").cloned().unwrap_or(Value::Null);
        match kind {
            "message" => {
                assert!(
                    value.get("time_created").and_then(Value::as_i64).is_some(),
                    "message envelope must carry time_created (D2): {value}"
                );
                assert!(
                    value.get("time_updated").and_then(Value::as_i64).is_some(),
                    "message envelope must carry time_updated (D2): {value}"
                );
                saw_message_ts = true;
            }
            "part" => {
                assert!(
                    value.get("time_created").and_then(Value::as_i64).is_some(),
                    "part envelope must carry time_created (D2): {value}"
                );
                assert!(
                    value.get("time_updated").and_then(Value::as_i64).is_some(),
                    "part envelope must carry time_updated (D2): {value}"
                );
                saw_part_ts = true;
            }
            _ => {}
        }
    }
    assert!(
        saw_message_ts,
        "must have scanned at least one message line"
    );
    assert!(saw_part_ts, "must have scanned at least one part line");
}

// ---- D3: session_diff sidecar is read and included -------------------------

#[test]
fn session_diff_sidecar_is_read_into_headers_and_raw() {
    let session =
        Session::from_opencode_sqlite(&fixture_db(), Some(SESSION_A)).expect("must load cleanly");
    let found = session.meta.opencode_headers.iter().any(|h| {
        h.get("key")
            .and_then(Value::as_array)
            .and_then(|k| k.first())
            .and_then(Value::as_str)
            == Some("session_diff")
    });
    assert!(
        found,
        "session_diff sidecar (D3) must appear in opencode_headers: {:?}",
        session.meta.opencode_headers
    );

    let raw_found = session.raw.iter().any(|line| {
        serde_json::from_str::<Value>(line)
            .ok()
            .and_then(|v| {
                v.get("key")
                    .and_then(Value::as_array)
                    .and_then(|k| k.first())
                    .cloned()
            })
            .and_then(|k| k.as_str().map(|s| s == "session_diff"))
            .unwrap_or(false)
    });
    assert!(
        raw_found,
        "session_diff must also appear in raw envelope lines"
    );

    // Session B never reverted — no sidecar file exists for it, and that
    // must be a silent no-op (not an error).
    let session_b =
        Session::from_opencode_sqlite(&fixture_db(), Some(SESSION_B)).expect("must load cleanly");
    let found_b = session_b.meta.opencode_headers.iter().any(|h| {
        h.get("key")
            .and_then(Value::as_array)
            .and_then(|k| k.first())
            == Some(&Value::from("session_diff"))
    });
    assert!(
        !found_b,
        "session B has no session_diff sidecar — must not fabricate one"
    );
}

// ---- D4: raw_is_verbatim must be false for the SQLite-sourced path --------

#[test]
fn sqlite_sourced_session_is_never_claimed_verbatim() {
    let session =
        Session::from_opencode_sqlite(&fixture_db(), Some(SESSION_A)).expect("must load cleanly");
    assert!(
        !session.raw_is_verbatim,
        "a SQLite `.db` source has no verbatim line-oriented form — \
         `convert opencode.db --to opencode` must not claim byte-identical (D4)"
    );
}

// ---- canonical messages: non-image file, ignored text, tool call pair -----

#[test]
fn canonical_messages_split_tool_call_and_result_for_both_outcomes() {
    let session =
        Session::from_opencode_sqlite(&fixture_db(), Some(SESSION_A)).expect("must load cleanly");

    // User turn: ignored text excluded, non-image file NOT turned into an
    // image_url content part (only ordinary text content), image data-URI
    // file part IS turned into a content_parts image_url entry.
    let user = session
        .messages
        .iter()
        .find(|m| m.role == Role::User)
        .expect("must have a user message");
    // Multimodal turn (an image content_part present) → text lands as a
    // `{"type":"text", ...}` entry inside `content_parts`, not the plain
    // `content` string field. Concatenate all text entries for the
    // assertion, exactly like `assert_messages_eq_multimodal`'s sibling
    // helpers in `opencode_interop.rs` do.
    let full_text = user.content.clone().unwrap_or_default()
        + &user
            .content_parts
            .as_ref()
            .map(|parts| {
                parts
                    .iter()
                    .filter(|p| p.get("type").and_then(Value::as_str) == Some("text"))
                    .filter_map(|p| p.get("text").and_then(Value::as_str))
                    .collect::<Vec<_>>()
                    .join("")
            })
            .unwrap_or_default();
    assert!(full_text.contains("Please check this file"));
    assert!(
        !full_text.contains("internal scratch note"),
        "an ignored:true text part must never be replayed: {full_text:?}"
    );
    let images = user
        .content_parts
        .as_ref()
        .map(|parts| {
            parts
                .iter()
                .filter(|p| p.get("type").and_then(Value::as_str) == Some("image_url"))
                .count()
        })
        .unwrap_or(0);
    assert_eq!(
        images, 1,
        "only the data:-URI image file part becomes image_url"
    );
    // The non-image (https:) file part must NOT appear as any content_part
    // at all — it is raw-only residue (D5), never guessed into a shape.
    let non_image_urls = user
        .content_parts
        .as_ref()
        .map(|parts| {
            parts
                .iter()
                .filter(|p| {
                    p.get("image_url")
                        .and_then(|iu| iu.get("url"))
                        .and_then(Value::as_str)
                        .is_some_and(|u| u.starts_with("https:"))
                })
                .count()
        })
        .unwrap_or(0);
    assert_eq!(
        non_image_urls, 0,
        "an https: file part must never become an image_url"
    );

    // Assistant turn: one tool_call (completed one drives the call; the
    // errored one becomes its own paired Tool message), a Tool message with
    // the completed output, and a Tool message with the error text.
    let tool_messages: Vec<_> = session
        .messages
        .iter()
        .filter(|m| m.role == Role::Tool)
        .collect();
    assert_eq!(
        tool_messages.len(),
        2,
        "one completed + one errored tool result"
    );
    let has_completed = tool_messages.iter().any(|m| {
        m.content
            .as_deref()
            .is_some_and(|c| c.contains("hello world"))
    });
    let has_error = tool_messages.iter().any(|m| {
        m.content
            .as_deref()
            .is_some_and(|c| c.contains("permission denied"))
    });
    assert!(
        has_completed,
        "completed tool output must survive: {tool_messages:?}"
    );
    assert!(
        has_error,
        "errored tool output must survive: {tool_messages:?}"
    );
}

// ---- D5: audit classification honesty --------------------------------------

#[test]
fn audit_classifies_non_image_file_and_ignored_text_as_dropped_not_normalized() {
    let report = audit_dir(&fixture_dir(), Corpus::OpenCode, None);
    let records = &report.records;

    let (residue_cov, residue_tally) = records
        .get("part/file:residue")
        .expect("non-image file part must be tallied separately from the canonicalized case");
    assert_eq!(
        format!("{residue_cov:?}"),
        "Dropped",
        "an https: file part is raw-only residue, not replayed — must not be Normalized"
    );
    assert!(residue_tally.count >= 1);

    let (image_cov, _) = records
        .get("part/file")
        .expect("the data:-URI image file part IS canonicalized");
    assert_eq!(format!("{image_cov:?}"), "Normalized");

    let (ignored_cov, _) = records
        .get("part/text:ignored")
        .expect("an ignored:true text part must be tallied separately");
    assert_eq!(format!("{ignored_cov:?}"), "Dropped");

    let (plain_text_cov, _) = records
        .get("part/text")
        .expect("non-ignored text part tallied");
    assert_eq!(format!("{plain_text_cov:?}"), "Normalized");

    // session_diff sidecar must be counted by audit too (D3 feeding D5's
    // "audit under-counts" complaint).
    assert!(
        records.contains_key("session_diff"),
        "session_diff must be enumerated by audit: {:?}",
        records.keys().collect::<Vec<_>>()
    );

    // Tool names tallied (D5's "also tally tool NAMES" ask).
    assert!(
        report.tools.contains_key("bash"),
        "tools: {:?}",
        report.tools
    );
    assert!(
        report.tools.contains_key("edit"),
        "tools: {:?}",
        report.tools
    );
}

// ---- PARITY-16 regression: malformed .db still fails cleanly --------------

#[test]
fn malformed_db_path_fails_with_clear_diagnostic_not_a_panic() {
    let dir = std::env::temp_dir().join(format!("sc-opencode-malformed-{}", std::process::id()));
    std::fs::create_dir_all(&dir).unwrap();
    let bogus = dir.join("opencode.db");
    // SQLite magic-prefixed but truncated/corrupt — must not panic, must
    // return a clear Err (not a generic UTF-8 read error, not a crash).
    std::fs::write(&bogus, b"SQLite format 3\0not a real database body").unwrap();
    let result = Session::from_opencode_sqlite(&bogus, None);
    assert!(
        result.is_err(),
        "a corrupt .db must fail cleanly, not silently succeed"
    );
    let msg = result.unwrap_err().to_string();
    assert!(
        !msg.to_lowercase().contains("utf-8"),
        "diagnostic must be SQLite-aware, not a generic UTF-8 read error: {msg}"
    );
    let _ = std::fs::remove_file(&bogus);
    let _ = std::fs::remove_dir(&dir);
}

// ---- D3: lean (real v1.2.15-style) `session` table missing newer columns --

/// A REAL `opencode` v1.2.15 store's `session` table does NOT have
/// `workspace_id`/`path`/`metadata`/`cost`/`tokens_*`/`agent`/`model` —
/// those columns were added by a later schema generation. Before the D3 fix,
/// `opencode_row_session_info` read every one of those unconditionally via
/// `Row::get(name)`, which rusqlite hard-errors on an absent column
/// ("Invalid column name") — aborting the whole session load, confirmed
/// against a real store (`~/.local/share/opencode/opencode.db`). This test
/// builds a from-scratch lean fixture with ONLY the v1.2.15 columns (no
/// `CREATE TABLE` copy-paste from the newer `gen_opencode_fixture.py` schema)
/// so the regression is pinned directly against `rusqlite`, independent of
/// the checked-in fixture file.
#[test]
fn lean_v1_2_15_session_table_without_newer_columns_still_loads() {
    let dir = std::env::temp_dir().join(format!("sc-opencode-lean-v1215-{}", std::process::id()));
    std::fs::create_dir_all(&dir).unwrap();
    let db_path = dir.join("opencode.db");
    let _ = std::fs::remove_file(&db_path);

    let conn = rusqlite::Connection::open(&db_path).expect("open lean fixture db");

    // Real v1.2.15 `session` table columns ONLY — no workspace_id, path,
    // metadata, cost, tokens_*, agent, model.
    conn.execute_batch(
        "
        CREATE TABLE session (
          id TEXT PRIMARY KEY,
          project_id TEXT NOT NULL,
          parent_id TEXT,
          slug TEXT NOT NULL,
          directory TEXT NOT NULL,
          title TEXT NOT NULL,
          version TEXT NOT NULL,
          share_url TEXT,
          summary_additions INTEGER,
          summary_deletions INTEGER,
          summary_files INTEGER,
          summary_diffs TEXT,
          revert TEXT,
          permission TEXT,
          time_created INTEGER NOT NULL,
          time_updated INTEGER NOT NULL,
          time_compacting INTEGER,
          time_archived INTEGER
        );
        CREATE TABLE message (
          id TEXT PRIMARY KEY,
          session_id TEXT NOT NULL,
          time_created INTEGER NOT NULL,
          time_updated INTEGER NOT NULL,
          data TEXT NOT NULL
        );
        CREATE TABLE part (
          id TEXT PRIMARY KEY,
          message_id TEXT NOT NULL,
          session_id TEXT NOT NULL,
          time_created INTEGER NOT NULL,
          time_updated INTEGER NOT NULL,
          data TEXT NOT NULL
        );
        CREATE TABLE todo (
          session_id TEXT NOT NULL,
          content TEXT NOT NULL,
          status TEXT NOT NULL,
          priority TEXT NOT NULL,
          position INTEGER NOT NULL,
          time_created INTEGER NOT NULL,
          time_updated INTEGER NOT NULL
        );
        ",
    )
    .expect("create lean v1.2.15-style schema");

    let session_id = "ses_leanV1215Fixture0000001";
    let msg_id = "msg_leanV1215Fixture0000001";
    let part_id = "prt_leanV1215Fixture0000001";
    let t0: i64 = 1_750_000_000_000;

    conn.execute(
        "INSERT INTO session (id, project_id, parent_id, slug, directory, title, version, \
         share_url, summary_additions, summary_deletions, summary_files, summary_diffs, \
         revert, permission, time_created, time_updated, time_compacting, time_archived) \
         VALUES (?1, ?2, NULL, ?3, ?4, ?5, ?6, NULL, NULL, NULL, NULL, NULL, NULL, NULL, ?7, ?8, NULL, NULL)",
        rusqlite::params![
            session_id,
            "prj_leanV1215Fixture0000001",
            "lean-v1215-session",
            "/tmp/lean-v1215-project",
            "Lean v1.2.15 Fixture Session",
            "1.0.0",
            t0,
            t0 + 1_000,
        ],
    )
    .expect("insert lean session row");

    let message_data = serde_json::json!({
        "role": "user",
        "time": {"created": t0},
    });
    conn.execute(
        "INSERT INTO message (id, session_id, time_created, time_updated, data) VALUES (?1, ?2, ?3, ?4, ?5)",
        rusqlite::params![msg_id, session_id, t0, t0 + 100, message_data.to_string()],
    )
    .expect("insert lean message row");

    let part_data = serde_json::json!({"type": "text", "text": "hi"});
    conn.execute(
        "INSERT INTO part (id, message_id, session_id, time_created, time_updated, data) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
        rusqlite::params![part_id, msg_id, session_id, t0, t0, part_data.to_string()],
    )
    .expect("insert lean part row");

    conn.close().expect("close lean fixture db");

    // Before the D3 fix: Err("... Invalid column name: workspace_id").
    // After: Ok, with the one user message/text part present.
    let session = Session::from_opencode_sqlite(&db_path, None)
        .expect("a real v1.2.15-style store (missing newer session columns) must still load");
    assert_eq!(session.meta.session_id.as_deref(), Some(session_id));
    assert_eq!(session.messages.len(), 1, "must have the one user message");
    let user = &session.messages[0];
    assert_eq!(user.role, supercode_harness::Role::User);
    assert_eq!(user.content.as_deref(), Some("hi"));

    let _ = std::fs::remove_file(&db_path);
    let _ = std::fs::remove_dir(&dir);
}

// ---- directory-argument regression: `Session::load` on an OpenCode DATA-ROOT
// directory (not the `.db` file itself) must resolve the store, matching
// what `audit --format opencode` already did via
// `detect_opencode_storage_surface` — instead of the old cryptic
// `io error: Is a directory (os error 21)` from treating the directory path
// as a file. -------------------------------------------------------------

#[test]
fn load_on_a_data_root_directory_resolves_the_sqlite_store() {
    let dir = std::env::temp_dir().join(format!("sc-opencode-dataroot-{}", std::process::id()));
    std::fs::create_dir_all(&dir).unwrap();
    let db_path = dir.join("opencode.db");
    let _ = std::fs::remove_file(&db_path);

    let conn = rusqlite::Connection::open(&db_path).expect("open lean fixture db");
    conn.execute_batch(
        "
        CREATE TABLE session (
          id TEXT PRIMARY KEY,
          project_id TEXT NOT NULL,
          parent_id TEXT,
          slug TEXT NOT NULL,
          directory TEXT NOT NULL,
          title TEXT NOT NULL,
          version TEXT NOT NULL,
          share_url TEXT,
          summary_additions INTEGER,
          summary_deletions INTEGER,
          summary_files INTEGER,
          summary_diffs TEXT,
          revert TEXT,
          permission TEXT,
          time_created INTEGER NOT NULL,
          time_updated INTEGER NOT NULL,
          time_compacting INTEGER,
          time_archived INTEGER
        );
        CREATE TABLE message (
          id TEXT PRIMARY KEY,
          session_id TEXT NOT NULL,
          time_created INTEGER NOT NULL,
          time_updated INTEGER NOT NULL,
          data TEXT NOT NULL
        );
        CREATE TABLE part (
          id TEXT PRIMARY KEY,
          message_id TEXT NOT NULL,
          session_id TEXT NOT NULL,
          time_created INTEGER NOT NULL,
          time_updated INTEGER NOT NULL,
          data TEXT NOT NULL
        );
        CREATE TABLE todo (
          session_id TEXT NOT NULL,
          content TEXT NOT NULL,
          status TEXT NOT NULL,
          priority TEXT NOT NULL,
          position INTEGER NOT NULL,
          time_created INTEGER NOT NULL,
          time_updated INTEGER NOT NULL
        );
        ",
    )
    .expect("create lean schema");

    let session_id = "ses_dataRootDirFixture000001";
    let msg_id = "msg_dataRootDirFixture000001";
    let part_id = "prt_dataRootDirFixture000001";
    let t0: i64 = 1_750_000_000_000;

    conn.execute(
        "INSERT INTO session (id, project_id, parent_id, slug, directory, title, version, \
         share_url, summary_additions, summary_deletions, summary_files, summary_diffs, \
         revert, permission, time_created, time_updated, time_compacting, time_archived) \
         VALUES (?1, ?2, NULL, ?3, ?4, ?5, ?6, NULL, NULL, NULL, NULL, NULL, NULL, NULL, ?7, ?8, NULL, NULL)",
        rusqlite::params![
            session_id,
            "prj_dataRootDirFixture000001",
            "data-root-dir-session",
            "/tmp/data-root-dir-project",
            "Data Root Dir Fixture Session",
            "1.0.0",
            t0,
            t0 + 1_000,
        ],
    )
    .expect("insert session row");

    let message_data = serde_json::json!({
        "role": "user",
        "time": {"created": t0},
    });
    conn.execute(
        "INSERT INTO message (id, session_id, time_created, time_updated, data) VALUES (?1, ?2, ?3, ?4, ?5)",
        rusqlite::params![msg_id, session_id, t0, t0 + 100, message_data.to_string()],
    )
    .expect("insert message row");

    let part_data = serde_json::json!({"type": "text", "text": "hello from a data-root dir"});
    conn.execute(
        "INSERT INTO part (id, message_id, session_id, time_created, time_updated, data) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
        rusqlite::params![part_id, msg_id, session_id, t0, t0, part_data.to_string()],
    )
    .expect("insert part row");

    conn.close().expect("close fixture db");

    // Before the fix: `Session::load(&dir)` called `looks_like_sqlite(&dir)`
    // (false — not a file) then `read_utf8_or_diagnose(&dir)`, which
    // surfaced the raw `io error: Is a directory (os error 21)` instead of
    // resolving the store.
    let session = Session::load(&dir)
        .expect("Session::load on an OpenCode data-root directory must resolve the store");
    assert_eq!(session.meta.session_id.as_deref(), Some(session_id));
    assert_eq!(session.messages.len(), 1);
    assert_eq!(
        session.messages[0].content.as_deref(),
        Some("hello from a data-root dir")
    );

    let _ = std::fs::remove_file(&db_path);
    let _ = std::fs::remove_dir(&dir);
}

#[test]
fn load_on_an_empty_directory_gives_a_clear_error_not_a_raw_errno() {
    let dir = std::env::temp_dir().join(format!("sc-opencode-emptydir-{}", std::process::id()));
    let _ = std::fs::remove_dir_all(&dir);
    std::fs::create_dir_all(&dir).unwrap();

    let result = Session::load(&dir);
    assert!(
        result.is_err(),
        "an empty directory with no session file or OpenCode store must fail"
    );
    let msg = result.unwrap_err().to_string();
    assert!(
        !msg.to_lowercase().contains("os error 21"),
        "must not surface the raw os-error-21 errno text: {msg}"
    );
    assert!(
        msg.contains("no session file or OpenCode store"),
        "must explain what's missing: {msg}"
    );
    assert!(
        msg.contains(dir.to_string_lossy().as_ref()),
        "must name the path: {msg}"
    );

    let _ = std::fs::remove_dir(&dir);
}