diff --git a/AGENTS.md b/AGENTS.md index 4483bf8..de9afda 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -39,7 +39,7 @@ own mapping as an extension in its own module. │ │ │ ├── SessionModels.swift # SessionProvider, SessionSummary, transcripts, delete plans │ │ │ ├── SessionParsing.swift # Provider-agnostic, total parsing primitives │ │ │ ├── SessionProviderAdapter.swift # The protocol + the registry -│ │ │ ├── {Claude,ClaudeCowork,Codex,Grok,Cursor,Gemini,Antigravity,GrokBot,Muse}SessionAdapter.swift +│ │ │ ├── {Claude,ClaudeCowork,Codex,Grok,Cursor,Gemini,Antigravity,GrokBot,Muse,Devin,MistralVibe}SessionAdapter.swift │ │ │ ├── CodexTitleHydrator.swift # Titles from Codex's side index │ │ │ ├── AntigravityConversationIndex.swift │ │ │ ├── LiveSQLiteReader.swift # Read a store another process holds open @@ -161,8 +161,13 @@ Claude Cowork because another running app owns those stores, and for Grok Bot because the conversation itself lives on xAI's servers and this directory is only what the client replicated. Muse Code is read-only too: the CLI's own `session-index.db` points at every log, and a running `muse` keeps locks and -sockets inside the session directory. Removing from underneath a live SQLite -handle is how a store gets corrupted rather than emptied. Do not "fix" that. +sockets inside the session directory. Devin keeps every session as rows in one +database the CLI holds open, so a delete would be SQL against another +process's live store — that is `devin rm`'s job. Mistral Vibe caches its +listing, leases running sessions, nests sub-agent sessions inside the parent's +directory, and sweeps worktrees no listed session resumes into. Removing from +underneath a live SQLite handle is how a store gets corrupted rather than +emptied. Do not "fix" that. ## 7. Adding a harness @@ -170,6 +175,11 @@ handle is how a store gets corrupted rather than emptied. Do not "fix" that. `defaultHarness` + an honest `supportsDeletion`. 2. An adapter. `roots(homeDirectory:)` is both the discovery scope and the deleter's containment fence — keep it exactly as narrow as the store is. + If a session is not exactly one file — a sibling file changes on its own + (Mistral Vibe's `meta.json`), or many sessions share one store (Devin's + database, addressed as `/`) — override + `changeFingerprint(fileURL:)` so the index re-reads exactly the sessions + that moved. 3. Register in `SessionProviderRegistry.standard`. 4. Tests over a synthetic temp tree: metadata, transcript, deletion plan, and the malformed-input case. diff --git a/CHANGELOG.md b/CHANGELOG.md index 0ac5ee5..ca737b0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,60 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +### Added + +- **`DevinSessionAdapter`** — Cognition's Devin is a listed, readable, + searchable provider. The `devin` CLI and the Devin desktop app (whose + "Devin Local" agent drives the CLI over ACP) keep every session as rows in + one WAL-mode database, `~/.local/share/devin/cli/sessions.db`. Discovery + hands out one locator per visible session, `…/sessions.db/`, + which names a row rather than a file. Metadata comes from the `sessions` row + (title, working directory, unix-second timestamps) and a bounded window at + each end of the main chain (first typed prompt, last reply, the model that + generated it). The transcript is the one chain from + `sessions.main_chain_id` back to its root — not compaction's abandoned + prefixes, retried siblings, or sub-agent trees — with system blocks, + injected user turns, and reasoning dropped. Sessions Devin marks + `hidden = 1` (its own helper agents' runs) are not listed. +- **`MistralVibeSessionAdapter`** — Mistral AI's Vibe CLI (`vibe`) is a listed, + readable, searchable provider. A session is + `~/.vibe/logs/session/session__/`, and its file is + `messages.jsonl`; `meta.json` beside it supplies the id (which the directory + must agree with), title, working directory, start and end times, and the + model the active alias resolves to (`config.models[active_model].name`). + Without a recorded title, the first prompt a person typed is the title. + `injected` lines — compaction summaries, hook messages — stay out of the + transcript. Sub-agent sessions nested under a parent's `agents/` directory + are not listed. +- **`Harness.devin`** / **`SessionProvider.devin`** ("Devin") and + **`Harness.mistralVibe`** / **`SessionProvider.mistralVibe`** ("Mistral + Vibe"). Both are read-only, and resume with `devin --resume ` and + `vibe --resume `. Devin's ids are word pairs, validated against the + loose `[A-Za-z0-9._-]` charset; Vibe's are UUID-shaped hex. +- **`SessionProviderAdapter.changeFingerprint(fileURL:)`** and + **`SessionChangeFingerprint`** — what the incremental index compares to + decide whether a session must be re-read. The default is the file's + nanosecond mtime and size with a `-wal` sibling folded in, exactly as + before. Mistral Vibe folds `meta.json` in, so a rename re-indexes the row; + Devin answers per session, so a turn in one conversation re-reads only that + one. A host adapter that wraps another should forward it; a wrapper that + does not still indexes Devin, at the coarser granularity of the whole + database. +- The Rust lane knows both providers: raw values `devin` and `mistralVibe`, + the same harness storage keys, resume commands, and id validation. + +### Changed + +- `SessionIndexService` fingerprints each session through its adapter's + `changeFingerprint` rather than a `stat` of its own. +- `LiveSQLiteReader.read(at:snapshotFallback:_:)` can give up after the direct + read instead of copying the database, for a probe that runs once per + session against a store many sessions share. +- `LiveSQLiteReader` reads a WAL database whose last writer closed cleanly (no + `-wal` left behind) in place with `immutable=1`. A read-only connection + cannot recreate the journal, so that read used to fail and fall back to a + full snapshot copy; it now neither copies nor leaves a file behind. + ## [0.9.0] - 2026-09-17 Meta's Muse Code joins the session list: its sessions are listed, searched, diff --git a/README.md b/README.md index 79fbfd3..23ece26 100644 --- a/README.md +++ b/README.md @@ -104,6 +104,8 @@ point takes the home directory it should read. | Cursor | `~/.cursor/chats/**/store.db` | ✅ | ⚠️ when a turn recorded one | ❌ store stays open | | Grok Bot | `~/Library/Application Support/Grok Bot/sand-client-persistence` | ✅ | ❌ the run happens server-side | ❌ a cloud cache | | Muse Code | `~/.local/share/muse/sessions/YYYY/MM/DD//session.jsonl` | ✅ | ✅ | ❌ the CLI indexes and locks it | +| Devin | `~/.local/share/devin/cli/sessions.db` — one database, a row per session | ✅ | ✅ | ❌ rows in a live database | +| Mistral Vibe | `~/.vibe/logs/session/session_*/{meta.json,messages.jsonl}` | ✅ | ✅ | ❌ the CLI indexes and leases it | Where a ⚠️ appears the log genuinely does not carry the value — an aborted Cursor conversation records no model name at all, and old Gemini CLI chats @@ -113,11 +115,23 @@ an empty one. Grok Bot's ❌ is the stronger statement: the conversation runs on xAI's servers and the client's cache records no model, no token counts, and no cost for anyone to read. -Five providers are listed and readable but never deletable, because another +Seven providers are listed and readable but never deletable, because another running app — or, for Grok Bot, a server — owns the store. `SessionProvider.supportsDeletion` says so up front, and the adapters fail closed with `SessionDeleteError.providerIsReadOnly`. +Devin is the one store that is not a file per session: the `devin` CLI and the +Devin desktop app (whose "Devin Local" agent drives that CLI) keep every +conversation as rows in one SQLite database. Its adapter hands out a locator +per session, `…/sessions.db/`, which names a row rather than a +file, and answers `changeFingerprint` per session, so a turn in one +conversation re-reads that one. A session's messages are a forest — +compaction re-roots the system prefix, retries add siblings, sub-agents grow +trees of their own — and the transcript is the one chain from +`sessions.main_chain_id` back to its root. Sessions Devin marks `hidden` (its +own helper agents' runs) are not listed. Mistral Vibe's sub-agent sessions +nest inside their parent's directory and are not listed either. + ## Usage ### List and read @@ -153,8 +167,12 @@ await service.refreshIndex { done, total in print("\(done)/\(total)") } let hits = try await service.search("sqlite migration", harnesses: [.codex, .claudeCode]) ``` -The index is incremental: a file is re-read only when its fingerprint — -nanosecond mtime plus size, WAL sibling folded in — has moved. Full-text +The index is incremental: a session is re-read only when its adapter's +`changeFingerprint` has moved — for a file, nanosecond mtime plus size with a +WAL sibling folded in; for Mistral Vibe, `messages.jsonl` and `meta.json` +together; for a Devin session, its own row and node counters. A host that wraps +an adapter should forward `changeFingerprint`; one that does not still indexes +Devin, only at the coarser granularity of the whole database. Full-text search uses an FTS5 `trigram` tokenizer, the only built-in one that matches inside words, which is what makes substring search work for CJK and for identifiers alike. @@ -288,6 +306,7 @@ snapshot.quota?.usedPercent // 43.2, from Codex's own rate_limits | Codex, ChatGPT Work | `token_count`: `last_token_usage.input_tokens` and `info.model_context_window` | `measured` | | Grok Build | `signals.json`: `contextTokensUsed` and `contextWindowTokens` | `measured` | | Cursor, AntiGravity, Grok Bot, Gemini CLI, Muse Code | nothing on disk answers it | `nil` | +| Devin, Mistral Vibe | no live adapter yet; Devin records per-reply `metrics`, Vibe `stats.context_tokens` | `nil` | Claude Code computes both its window size *and* its category breakdown (messages, system tools, skills, MCP tools, memory files) in-process and writes @@ -339,6 +358,8 @@ table goes through `ArgvSanitizer`. | Grok Build | ✅ `GrokLiveAdapter` | `~/.grok/sessions///{events,updates}.jsonl`; liveness via `~/.grok/active_sessions.json` and the per-file writer locks | | Gemini CLI | — | `~/.gemini/tmp/*/chats` | | Muse Code | — | `~/.local/share/muse/sessions/**/session.jsonl` | +| Devin | — | `~/.local/share/devin/cli/sessions.db` (WAL) | +| Mistral Vibe | — | `~/.vibe/logs/session/session_*/messages.jsonl` + `meta.json` | | AntiGravity | ✅ `AntigravityLiveAdapter` | `~/.gemini/antigravity{-cli,}/conversations/*.db`; state from the SQL columns plus a shallow `step_payload` decode; liveness via `presence/.lock` | | Cursor | ✅ `CursorLiveAdapter` | `~/.cursor/chats/**/store.db` + `~/.cursor/projects//agent-transcripts`; liveness via `cursor-agent-worker-*.pid` and the store's WAL | | Grok Bot | ✅ `GrokBotLiveAdapter` | `~/Library/Application Support/Grok Bot/sand-client-persistence/.blob`; the roster slice supplies the name and the needs-you flag; liveness via the `Grok Bot` process and `~/.grokbot/local-exec-supervisor.json` | diff --git a/implementations/rust/crates/agent-session-core/README.md b/implementations/rust/crates/agent-session-core/README.md index 855e410..ea6e043 100644 --- a/implementations/rust/crates/agent-session-core/README.md +++ b/implementations/rust/crates/agent-session-core/README.md @@ -52,8 +52,8 @@ let recent = reader.list(&SessionListFilter { limit: 20, ..Default::default() }) | Contract | Value | | --- | --- | | Index schema version | `PRAGMA user_version = 5` (any other value ⇒ refuse) | -| Provider raw values | `claude`, `claudeCowork`, `codex`, `grok`, `cursor`, `gemini`, `antigravity`, `grokBot`, `muse` | -| Harness display names | `Codex`, `ChatGPT Work`, `Claude Code`, `Claude Cowork`, `Gemini CLI`, `AntiGravity`, `Grok Build`, `Cursor`, `Grok Bot` | +| Provider raw values | `claude`, `claudeCowork`, `codex`, `grok`, `cursor`, `gemini`, `antigravity`, `grokBot`, `muse`, `devin`, `mistralVibe` | +| Harness display names | `Codex`, `ChatGPT Work`, `Claude Code`, `Claude Cowork`, `Gemini CLI`, `AntiGravity`, `Grok Build`, `Cursor`, `Grok Bot`, `Muse Code`, `Devin`, `Mistral Vibe` | | Index timestamps | Unix epoch seconds (INTEGER) | | FTS query | one double-quoted phrase, `""` escaping; needles < 3 chars use escaped `LIKE` | -| Resume validation | uuid charset for claude/codex/cursor/antigravity/grokBot/muse ids; `[A-Za-z0-9._-]` for grok/gemini; max 200 chars | +| Resume validation | uuid charset for claude/codex/cursor/antigravity/grokBot/muse/mistralVibe ids; `[A-Za-z0-9._-]` for grok/gemini/devin; max 200 chars | diff --git a/implementations/rust/crates/agent-session-core/src/index.rs b/implementations/rust/crates/agent-session-core/src/index.rs index 1d94249..1f39b27 100644 --- a/implementations/rust/crates/agent-session-core/src/index.rs +++ b/implementations/rust/crates/agent-session-core/src/index.rs @@ -759,6 +759,8 @@ impl SessionIndexReader { "Cursor" => "cursor", "Grok Bot" => "grokBot", "Muse Code" => "museCode", + "Devin" => "devin", + "Mistral Vibe" => "mistralVibe", raw => raw, } } @@ -773,7 +775,9 @@ impl SessionIndexReader { WHEN 'gemini' THEN 'geminiCLI' \ WHEN 'antigravity' THEN 'antigravity' \ WHEN 'grokBot' THEN 'grokBot' \ - WHEN 'muse' THEN 'museCode' END)" + WHEN 'muse' THEN 'museCode' \ + WHEN 'devin' THEN 'devin' \ + WHEN 'mistralVibe' THEN 'mistralVibe' END)" } fn dedup_nonempty_strings( @@ -1008,6 +1012,43 @@ mod tests { assert_eq!(rows[0].harness.as_deref(), Some("codex")); } + #[test] + fn devin_and_mistral_vibe_rows_list_and_filter_by_either_name() { + let dir = tempfile::tempdir().unwrap(); + let path = fixture_index(dir.path()); + let conn = Connection::open(&path).unwrap(); + conn.execute_batch( + "INSERT INTO sessions(id, provider, session_id, harness, title, created_at, last_active_at, source_path) \ + VALUES(4, 'devin', 'quiet-harbor', NULL, 'devin row', 1700004000, 1700004000, \ + '/Users/example/.local/share/devin/cli/sessions.db/quiet-harbor'); \ + INSERT INTO sessions(id, provider, session_id, harness, title, created_at, last_active_at, source_path) \ + VALUES(5, 'mistralVibe', '5c0ffee1-7a1b-4c2d-8e3f-0123456789ab', 'mistralVibe', 'vibe row', \ + 1700005000, 1700005000, \ + '/Users/example/.vibe/logs/session/session_20260101_000000_5c0ffee1/messages.jsonl');", + ) + .unwrap(); + drop(conn); + + let reader = SessionIndexReader::open(&path).unwrap(); + for (harness, provider, title) in [ + ("Devin", SessionProvider::Devin, "devin row"), + ("devin", SessionProvider::Devin, "devin row"), + ("Mistral Vibe", SessionProvider::MistralVibe, "vibe row"), + ("mistralVibe", SessionProvider::MistralVibe, "vibe row"), + ] { + let rows = reader + .list(&SessionListFilter { + harnesses: Some(vec![harness.to_string()]), + limit: 10, + ..Default::default() + }) + .unwrap(); + assert_eq!(rows.len(), 1, "{harness}"); + assert_eq!(rows[0].provider, provider); + assert_eq!(rows[0].title.as_deref(), Some(title)); + } + } + #[test] fn null_harness_uses_provider_default_before_filtering_and_offset() { let dir = tempfile::tempdir().unwrap(); diff --git a/implementations/rust/crates/agent-session-core/src/provider.rs b/implementations/rust/crates/agent-session-core/src/provider.rs index bb28bd3..5728d3b 100644 --- a/implementations/rust/crates/agent-session-core/src/provider.rs +++ b/implementations/rust/crates/agent-session-core/src/provider.rs @@ -23,10 +23,14 @@ pub enum SessionProvider { GrokBot, #[serde(rename = "muse")] Muse, + #[serde(rename = "devin")] + Devin, + #[serde(rename = "mistralVibe")] + MistralVibe, } impl SessionProvider { - pub const ALL: [SessionProvider; 9] = [ + pub const ALL: [SessionProvider; 11] = [ SessionProvider::Claude, SessionProvider::ClaudeCowork, SessionProvider::Codex, @@ -36,6 +40,8 @@ impl SessionProvider { SessionProvider::Antigravity, SessionProvider::GrokBot, SessionProvider::Muse, + SessionProvider::Devin, + SessionProvider::MistralVibe, ]; /// Storage raw value — identical to Swift's `rawValue`. @@ -50,6 +56,8 @@ impl SessionProvider { SessionProvider::Antigravity => "antigravity", SessionProvider::GrokBot => "grokBot", SessionProvider::Muse => "muse", + SessionProvider::Devin => "devin", + SessionProvider::MistralVibe => "mistralVibe", } } @@ -72,6 +80,8 @@ impl SessionProvider { SessionProvider::Antigravity => "AntiGravity", SessionProvider::GrokBot => "Grok Bot", SessionProvider::Muse => "Muse Code", + SessionProvider::Devin => "Devin", + SessionProvider::MistralVibe => "Mistral Vibe", } } @@ -88,3 +98,44 @@ impl SessionProvider { ) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn raw_values_round_trip_and_match_serde() { + for provider in SessionProvider::ALL { + assert_eq!( + SessionProvider::from_raw(provider.raw_value()), + Some(provider) + ); + assert_eq!( + serde_json::to_string(&provider).unwrap(), + format!("\"{}\"", provider.raw_value()) + ); + } + assert_eq!(SessionProvider::Devin.raw_value(), "devin"); + assert_eq!(SessionProvider::MistralVibe.raw_value(), "mistralVibe"); + assert_eq!(SessionProvider::Devin.default_harness(), "Devin"); + assert_eq!( + SessionProvider::MistralVibe.default_harness(), + "Mistral Vibe" + ); + } + + #[test] + fn read_only_providers_refuse_deletion() { + for provider in [ + SessionProvider::ClaudeCowork, + SessionProvider::Cursor, + SessionProvider::Antigravity, + SessionProvider::GrokBot, + SessionProvider::Muse, + SessionProvider::Devin, + SessionProvider::MistralVibe, + ] { + assert!(!provider.supports_deletion(), "{provider:?}"); + } + } +} diff --git a/implementations/rust/crates/agent-session-core/src/resume.rs b/implementations/rust/crates/agent-session-core/src/resume.rs index 0fb70f0..7015cd6 100644 --- a/implementations/rust/crates/agent-session-core/src/resume.rs +++ b/implementations/rust/crates/agent-session-core/src/resume.rs @@ -32,6 +32,8 @@ pub fn command( Ok(format!("agy --conversation {id}")) } SessionProvider::Muse => Ok(format!("muse resume {id}")), + SessionProvider::Devin => Ok(format!("devin --resume {id}")), + SessionProvider::MistralVibe => Ok(format!("vibe --resume {id}")), SessionProvider::ClaudeCowork | SessionProvider::Cursor | SessionProvider::GrokBot => { // Cowork runs inside Claude.app and Cursor's agents inside Cursor; // neither publishes a "reopen this conversation" command. Grok Bot @@ -81,8 +83,10 @@ fn is_valid(id: &str, provider: SessionProvider) -> bool { | SessionProvider::Cursor | SessionProvider::Antigravity | SessionProvider::GrokBot - | SessionProvider::Muse => id.chars().all(|c| c.is_ascii_hexdigit() || c == '-'), - SessionProvider::Grok | SessionProvider::Gemini => id + | SessionProvider::Muse + | SessionProvider::MistralVibe => id.chars().all(|c| c.is_ascii_hexdigit() || c == '-'), + // Devin names a session with a word pair (`quiet-harbor`), not a UUID. + SessionProvider::Grok | SessionProvider::Gemini | SessionProvider::Devin => id .chars() .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-')), } @@ -125,6 +129,19 @@ mod tests { .unwrap(), "muse resume 01a0ac0d-5355-7c41-bc77-3b55f0e77ea1" ); + assert_eq!( + command(SessionProvider::Devin, "quiet-harbor", None).unwrap(), + "devin --resume quiet-harbor" + ); + assert_eq!( + command( + SessionProvider::MistralVibe, + "5c0ffee1-7a1b-4c2d-8e3f-0123456789ab", + None + ) + .unwrap(), + "vibe --resume 5c0ffee1-7a1b-4c2d-8e3f-0123456789ab" + ); } #[test] @@ -145,6 +162,14 @@ mod tests { command(SessionProvider::Codex, "", None), Err(SessionCoreError::InvalidSessionId) )); + assert!(matches!( + command(SessionProvider::MistralVibe, "quiet-harbor", None), + Err(SessionCoreError::InvalidSessionId) + )); + assert!(matches!( + command(SessionProvider::Devin, "quiet harbor; rm -rf /", None), + Err(SessionCoreError::InvalidSessionId) + )); } #[test] diff --git a/implementations/swift/Sources/AgentSessionKit/Harness/Harness.swift b/implementations/swift/Sources/AgentSessionKit/Harness/Harness.swift index 69299ef..331a09e 100644 --- a/implementations/swift/Sources/AgentSessionKit/Harness/Harness.swift +++ b/implementations/swift/Sources/AgentSessionKit/Harness/Harness.swift @@ -19,6 +19,8 @@ import Foundation /// | Cursor | Anysphere | `~/.cursor/chats/**/store.db` | /// | Grok Bot | xAI | `~/Library/Application Support/Grok Bot/sand-client-persistence` — a cloud cache: conversations only, no model | /// | Muse Code | Meta AI | `~/.local/share/muse/sessions/YYYY/MM/DD//session.jsonl` | +/// | Devin | Cognition | `~/.local/share/devin/cli/sessions.db` — one database, every session a row | +/// | Mistral Vibe | Mistral AI | `~/.vibe/logs/session/session_*/{meta.json,messages.jsonl}` | /// /// Renaming a harness is one edit here, not a hunt across a UI. public enum HarnessCatalog { @@ -39,6 +41,12 @@ public enum HarnessCatalog { public static let grokBot = "Grok Bot" /// Meta's terminal coding agent, the `muse` CLI. public static let museCode = "Muse Code" + /// Cognition's local agent: the `devin` CLI and the Devin desktop app, + /// whose "Devin Local" agent drives that CLI over ACP. Both write the one + /// session database, so they are one harness. + public static let devin = "Devin" + /// Mistral AI's terminal coding agent, the `vibe` CLI. + public static let mistralVibe = "Mistral Vibe" } /// The local harness a session or usage event came from — the unit every @@ -61,6 +69,8 @@ public enum Harness: String, CaseIterable, Codable, Sendable, Hashable { case cursor case grokBot case museCode + case devin + case mistralVibe public var displayName: String { switch self { @@ -74,6 +84,8 @@ public enum Harness: String, CaseIterable, Codable, Sendable, Hashable { case .cursor: HarnessCatalog.cursor case .grokBot: HarnessCatalog.grokBot case .museCode: HarnessCatalog.museCode + case .devin: HarnessCatalog.devin + case .mistralVibe: HarnessCatalog.mistralVibe } } } diff --git a/implementations/swift/Sources/AgentSessionKit/Sessions/DevinSessionAdapter.swift b/implementations/swift/Sources/AgentSessionKit/Sessions/DevinSessionAdapter.swift new file mode 100644 index 0000000..061ec03 --- /dev/null +++ b/implementations/swift/Sources/AgentSessionKit/Sessions/DevinSessionAdapter.swift @@ -0,0 +1,611 @@ +import Foundation +import SQLite3 + +/// Devin sessions: every conversation the `devin` CLI and the Devin desktop +/// app run lives in **one** SQLite database, +/// `~/.local/share/devin/cli/sessions.db`, which the CLI keeps open in WAL +/// mode. The desktop app's "Devin Local" agent drives that same CLI over ACP, +/// so both surfaces land in the same rows and are one harness. +/// +/// Two tables matter: +/// +/// - `sessions(id, working_directory, model, created_at, last_activity_at, +/// title, main_chain_id, hidden, …)` — one row per session, timestamps in +/// unix seconds. `hidden = 1` marks sessions Devin's own helper agents (the +/// summarizer, for one) persist; Devin keeps them out of every user-facing +/// list, and so does this adapter. +/// - `message_nodes(session_id, node_id, parent_node_id, chat_message, +/// created_at, …)` — a *forest* per session. Compaction re-roots the system +/// prefix, a retry adds a sibling, and every sub-agent's chain is a tree of +/// its own. `sessions.main_chain_id` is the node id of the conversation's +/// current tip; walking `parent_node_id` up from it to a root and reversing +/// is the conversation as the model last saw it, and the only branch this +/// adapter reads. +/// +/// **One store, many sessions.** The adapter protocol is per file, so a +/// session is addressed by a locator *inside* the database — +/// `<…>/sessions.db/` — that is never a real path. +/// `discoverSessionFiles` lists one locator per visible row, the index keys +/// each one separately, and `changeFingerprint` answers per session (its +/// activity stamp, node count, highest node row, tip, title), so a turn in +/// one conversation re-reads that conversation and not every other one. +/// +/// Every read opens the database read-only through `LiveSQLiteReader` and +/// closes it before returning; nothing holds it open between calls, and +/// nothing is ever written. **Read-only, permanently**: removing a session +/// means deleting rows from five tables of a database another process has +/// open, which is `devin rm`'s job, not this package's. +public struct DevinSessionAdapter: SessionProviderAdapter { + public let provider: SessionProvider = .devin + + public init() {} + + static let storeDirectory = ".local/share/devin/cli" + static let databaseFileName = "sessions.db" + + public func roots(homeDirectory: String) -> [URL] { + [URL(fileURLWithPath: homeDirectory).appendingPathComponent(Self.storeDirectory, isDirectory: true)] + } + + /// The one database every session lives in. + public static func databaseURL(homeDirectory: String) -> URL { + URL(fileURLWithPath: homeDirectory) + .appendingPathComponent(storeDirectory, isDirectory: true) + .appendingPathComponent(databaseFileName) + } + + /// The locator `discoverSessionFiles` hands out for `sessionID`: + /// `/`. It names a row, not a file. + public static func sessionURL(database: URL, sessionID: String) -> URL { + database.appendingPathComponent(sessionID, isDirectory: false) + } + + // MARK: - Limits + + /// Sessions listed from one database, most recently active first. + static let maxSessions = 10_000 + /// Nodes followed up one main chain. A chain past this keeps its newest + /// nodes and the transcript says it was truncated. + static let maxChainNodes = 20_000 + /// Message bytes decoded for one transcript, oldest first. + static let maxTranscriptBytes = 32 * 1024 * 1024 + /// Nodes read from the root while looking for the first prompt, and from + /// the tip while looking for the last reply. System-prefix nodes come + /// first on every chain, so the head window has to clear them. + static let headScanNodes = 64 + static let tailScanNodes = 32 + + // MARK: - Discovery + + public func discoverSessionFiles(homeDirectory: String) -> [URL] { + let database = Self.databaseURL(homeDirectory: homeDirectory) + guard Self.isRegularFile(database) else { return [] } + let ids = LiveSQLiteReader.read(at: database) { handle in + try Self.visibleSessionIDs(handle) + } ?? [] + return ids.filter(Self.isUsableSessionID).map { + Self.sessionURL(database: database, sessionID: $0) + } + } + + /// Not a symlink, and a regular file. A link here could resolve anywhere. + static func isRegularFile(_ url: URL) -> Bool { + let values = try? url.resourceValues(forKeys: [.isSymbolicLinkKey, .isRegularFileKey]) + return values?.isSymbolicLink == false && values?.isRegularFile == true + } + + /// A session id that can be one path component of a locator. + static func isUsableSessionID(_ id: String) -> Bool { + !id.isEmpty && id.count <= 200 && id != "." && id != ".." && !id.contains("/") + && !id.unicodeScalars.contains { $0.value < 0x20 } + } + + /// `(database, session id)` for a locator, or `nil` for anything else. + static func locate(_ url: URL) -> (database: URL, sessionID: String)? { + let database = url.deletingLastPathComponent() + let sessionID = url.lastPathComponent + guard database.lastPathComponent == databaseFileName, isUsableSessionID(sessionID) else { + return nil + } + return (database, sessionID) + } + + // MARK: - Change fingerprint + + /// Per session, not per database: the session's `last_activity_at` + /// (seconds, stored as nanoseconds) and a stable hash of what else moves + /// when a conversation changes — its node count, its highest node row + /// (rows are `AUTOINCREMENT`, so a replaced node always gets a new one), + /// its tip, its title, and its `hidden` flag. + /// + /// No snapshot fallback: this runs once per session per refresh against + /// a database every session shares, so a store that cannot be read right + /// now is skipped (`nil`) and asked again on the next pass rather than + /// copied once per session. + public func changeFingerprint(fileURL: URL) -> SessionChangeFingerprint? { + guard let (database, sessionID) = Self.locate(fileURL), Self.isRegularFile(database) else { + return nil + } + let probe = LiveSQLiteReader.read(at: database, snapshotFallback: false) { handle in + try Self.fingerprint(handle, sessionID: sessionID) + } + return probe ?? nil + } + + static func fingerprint(_ handle: OpaquePointer, sessionID: String) throws -> SessionChangeFingerprint? { + guard let row = try sessionRow(handle, sessionID: sessionID) else { return nil } + let statement = try LiveSQLiteReader.prepare( + handle, + "SELECT COUNT(*), COALESCE(MAX(row_id), 0) FROM message_nodes WHERE session_id = ?1" + ) + defer { sqlite3_finalize(statement) } + bind(statement, 1, sessionID) + guard sqlite3_step(statement) == SQLITE_ROW else { throw LiveSQLiteReader.ReadError.statement } + let marker = [ + String(sqlite3_column_int64(statement, 0)), + String(sqlite3_column_int64(statement, 1)), + row.mainChainID.map(String.init) ?? "-", + String(row.hidden), + row.title ?? "" + ].joined(separator: "\u{1F}") + let seconds = min(max(row.lastActivityAt ?? 0, 0), Int64.max / 1_000_000_000) + return SessionChangeFingerprint( + mtimeNanos: seconds * 1_000_000_000, + size: Int64(bitPattern: fnv1a64(marker)) + ) + } + + /// FNV-1a, 64-bit. Deterministic across launches, which `Hasher` is not, + /// and the fingerprint is persisted. + static func fnv1a64(_ text: String) -> UInt64 { + var hash: UInt64 = 0xcbf2_9ce4_8422_2325 + for byte in text.utf8 { + hash ^= UInt64(byte) + hash = hash &* 0x0000_0100_0000_01b3 + } + return hash + } + + // MARK: - Metadata + + public func extractMetadata(fileURL: URL) throws -> SessionSummary { + guard let (database, sessionID) = Self.locate(fileURL) else { + throw SessionParseError.invalidFormat("\(fileURL.lastPathComponent): not a Devin session locator") + } + guard Self.isRegularFile(database) else { + throw SessionParseError.unreadable(database.lastPathComponent) + } + let read = LiveSQLiteReader.read(at: database) { handle -> Head? in + guard let row = try Self.sessionRow(handle, sessionID: sessionID) else { return nil } + guard !row.isHidden else { + return Head(row: row, firstPrompt: nil, lastReply: nil, lastModel: nil, messageBytes: 0) + } + return try Self.head(handle, row: row) + } + guard let read else { throw SessionParseError.unreadable(database.lastPathComponent) } + guard let head = read else { + throw SessionParseError.invalidFormat("\(database.lastPathComponent): no such session") + } + guard !head.row.isHidden else { + // Devin's own helper agents persist these; Devin lists none of them. + throw SessionParseError.invalidFormat("\(database.lastPathComponent): hidden session") + } + + return SessionSummary( + provider: .devin, + sessionID: sessionID, + harness: .devin, + model: head.lastModel ?? SessionParsing.string(head.row.model), + title: SessionParsing.display(head.row.title ?? head.firstPrompt, limit: SessionParsing.titleLimit), + summary: SessionParsing.display(head.lastReply, limit: SessionParsing.summaryLimit), + projectDir: SessionParsing.string(head.row.workingDirectory), + createdAt: head.row.createdAt.flatMap { SessionParsing.date(NSNumber(value: $0)) }, + lastActiveAt: head.row.lastActivityAt.flatMap { SessionParsing.date(NSNumber(value: $0)) }, + sourcePath: fileURL.path, + sizeBytes: head.messageBytes + ) + } + + struct Head { + let row: SessionRow + let firstPrompt: String? + let lastReply: String? + let lastModel: String? + let messageBytes: Int64 + } + + /// The list-row facts, reading only a bounded window at each end of the + /// main chain. + static func head(_ handle: OpaquePointer, row: SessionRow) throws -> Head { + let chain = try mainChain(handle, sessionID: row.id, tip: row.mainChainID) + let lookup = try NodeLookup(handle, sessionID: row.id) + + var firstPrompt: String? + if row.title == nil { + for nodeID in chain.nodes.prefix(headScanNodes) { + guard let node = try lookup.node(nodeID), node.role == "user", !node.isInjected else { continue } + if let text = SessionParsing.string(SessionParsing.extractText(node.message["content"])) { + firstPrompt = text + break + } + } + } + + var lastReply: String? + var lastModel: String? + var sawNewestAssistant = false + for nodeID in chain.nodes.suffix(tailScanNodes).reversed() { + guard let node = try lookup.node(nodeID), node.role == "assistant" else { continue } + // The model is the newest response's own. One without a + // `generation_model` leaves the session row's model to answer, + // rather than an older response that may predate a model switch. + if !sawNewestAssistant { + sawNewestAssistant = true + lastModel = node.generationModel + } + if lastReply == nil { + lastReply = SessionParsing.string(SessionParsing.extractText(node.message["content"])) + } + if lastReply != nil { break } + } + + return Head( + row: row, + firstPrompt: firstPrompt, + lastReply: lastReply, + lastModel: lastModel, + messageBytes: messageBytes(handle, sessionID: row.id) + ) + } + + /// Bytes this session's messages occupy in the shared store — the + /// closest honest answer to "how big is this session". `octet_length` + /// reads the record header rather than the text; a SQLite too old to have + /// it answers 0 instead of failing the row. + static func messageBytes(_ handle: OpaquePointer, sessionID: String) -> Int64 { + guard let statement = try? LiveSQLiteReader.prepare( + handle, + "SELECT COALESCE(SUM(octet_length(chat_message)), 0) FROM message_nodes WHERE session_id = ?1" + ) else { return 0 } + defer { sqlite3_finalize(statement) } + bind(statement, 1, sessionID) + guard sqlite3_step(statement) == SQLITE_ROW else { return 0 } + return max(0, sqlite3_column_int64(statement, 0)) + } + + // MARK: - Transcript + + public func parseTranscript(fileURL: URL, range: Range?) throws -> TranscriptDocument { + guard let (database, sessionID) = Self.locate(fileURL) else { + throw SessionParseError.invalidFormat("\(fileURL.lastPathComponent): not a Devin session locator") + } + guard Self.isRegularFile(database) else { + throw SessionParseError.unreadable(database.lastPathComponent) + } + let read = LiveSQLiteReader.read(at: database) { handle -> (messages: [SessionMessage], cut: Bool)? in + guard let row = try Self.sessionRow(handle, sessionID: sessionID) else { return nil } + return try Self.transcript(handle, row: row) + } + guard let read else { throw SessionParseError.unreadable(database.lastPathComponent) } + guard let (messages, cut) = read else { + throw SessionParseError.invalidFormat("\(database.lastPathComponent): no such session") + } + let document = SessionTranscriptSlicing.document(messages: messages, range: range) + guard cut else { return document } + return TranscriptDocument( + messages: document.messages, + totalMessageCount: document.totalMessageCount, + truncated: true + ) + } + + /// The main chain, root first, as transcript messages. + /// + /// - `system` nodes (the system prompt, rules, skill lists, environment + /// blocks) are the harness talking to the model and are dropped; + /// - a `user` node whose `metadata.is_user_input` is explicitly `false` + /// was injected by Devin rather than typed, and is dropped; + /// - an `assistant` node yields its prose and then one `[Tool: name]` + /// message per tool call; `thinking` is dropped, as every adapter drops + /// a model's private reasoning; + /// - a `tool` node yields its result text. + static func transcript(_ handle: OpaquePointer, row: SessionRow) throws -> (messages: [SessionMessage], cut: Bool) { + let chain = try mainChain(handle, sessionID: row.id, tip: row.mainChainID) + let lookup = try NodeLookup(handle, sessionID: row.id) + + var messages: [SessionMessage] = [] + func append(_ role: SessionRole, _ text: String, _ timestamp: Date?) { + guard !text.isEmpty else { return } + messages.append(SessionMessage(seq: messages.count, role: role, text: text, timestamp: timestamp)) + } + + var budget = maxTranscriptBytes + var cut = chain.truncated + for nodeID in chain.nodes { + guard budget > 0 else { + cut = true + break + } + guard let node = try lookup.node(nodeID) else { continue } + budget -= node.byteCount + let timestamp = node.timestamp + switch node.role { + case "user": + guard !node.isInjected else { continue } + append(.user, SessionParsing.extractText(node.message["content"]), timestamp) + case "assistant": + append(.assistant, SessionParsing.extractText(node.message["content"]), timestamp) + for call in (node.message["tool_calls"] as? [[String: Any]]) ?? [] { + append(.tool, toolCallText(call), timestamp) + } + case "tool": + append(.tool, SessionParsing.extractText(node.message["content"]), timestamp) + default: + continue + } + } + return (messages, cut) + } + + /// `[Tool: name]` plus its arguments. The call's own `name` / `arguments` + /// are read first, then an OpenAI-style `function` object, so either + /// spelling renders. + static func toolCallText(_ call: [String: Any]) -> String { + let function = call["function"] as? [String: Any] + let name = SessionParsing.firstString( + call["name"], function?["name"], call["inference_tool_name"], call["tool_name"] + ) ?? "tool" + let rawArguments = call["arguments"] ?? function?["arguments"] ?? call["input"] + let arguments: String? + if let text = rawArguments as? String { + arguments = SessionParsing.string(text) + } else if let rawArguments, + JSONSerialization.isValidJSONObject(rawArguments), + let data = try? JSONSerialization.data(withJSONObject: rawArguments, options: [.sortedKeys]) { + arguments = SessionParsing.truncate(String(decoding: data, as: UTF8.self), limit: 4_000) + } else { + arguments = nil + } + return arguments.map { "[Tool: \(name)]\n\($0)" } ?? "[Tool: \(name)]" + } + + // MARK: - Deletion + + public func deletionPlan(for summary: SessionSummary, homeDirectory: String) throws -> SessionDeletionPlan { + throw SessionDeleteError.providerIsReadOnly(.devin) + } + + // MARK: - Rows + + struct SessionRow { + let id: String + let workingDirectory: String? + let model: String? + let createdAt: Int64? + let lastActivityAt: Int64? + let title: String? + let mainChainID: Int64? + let hidden: Int64 + + var isHidden: Bool { hidden != 0 } + } + + /// Visible sessions, most recently active first. A database from before + /// Devin added the `hidden` column has nothing hidden in it. + static func visibleSessionIDs(_ handle: OpaquePointer) throws -> [String] { + let statement: OpaquePointer + if let filtered = try? LiveSQLiteReader.prepare( + handle, + "SELECT id FROM sessions WHERE hidden = 0 ORDER BY last_activity_at DESC, id LIMIT ?1" + ) { + statement = filtered + } else { + statement = try LiveSQLiteReader.prepare( + handle, + "SELECT id FROM sessions ORDER BY last_activity_at DESC, id LIMIT ?1" + ) + } + defer { sqlite3_finalize(statement) } + sqlite3_bind_int64(statement, 1, Int64(maxSessions)) + var ids: [String] = [] + while true { + switch sqlite3_step(statement) { + case SQLITE_ROW: + if let id = LiveSQLiteReader.text(statement, 0) { ids.append(id) } + case SQLITE_DONE: + return ids + default: + throw LiveSQLiteReader.ReadError.statement + } + } + } + + static func sessionRow(_ handle: OpaquePointer, sessionID: String) throws -> SessionRow? { + let columns = "id, working_directory, model, created_at, last_activity_at, title, main_chain_id" + let statement: OpaquePointer + let hasHidden: Bool + if let withHidden = try? LiveSQLiteReader.prepare( + handle, "SELECT \(columns), hidden FROM sessions WHERE id = ?1" + ) { + statement = withHidden + hasHidden = true + } else { + statement = try LiveSQLiteReader.prepare(handle, "SELECT \(columns) FROM sessions WHERE id = ?1") + hasHidden = false + } + defer { sqlite3_finalize(statement) } + bind(statement, 1, sessionID) + switch sqlite3_step(statement) { + case SQLITE_ROW: + return SessionRow( + id: LiveSQLiteReader.text(statement, 0) ?? sessionID, + workingDirectory: LiveSQLiteReader.text(statement, 1), + model: LiveSQLiteReader.text(statement, 2), + createdAt: int64(statement, 3), + lastActivityAt: int64(statement, 4), + title: SessionParsing.string(LiveSQLiteReader.text(statement, 5)), + mainChainID: int64(statement, 6), + hidden: hasHidden ? (int64(statement, 7) ?? 0) : 0 + ) + case SQLITE_DONE: + return nil + default: + throw LiveSQLiteReader.ReadError.statement + } + } + + // MARK: - The main chain + + struct Chain { + /// Root first. + let nodes: [Int64] + /// The walk stopped at `maxChainNodes` before reaching a root. + let truncated: Bool + } + + /// Node ids from the chain's root to `tip`, following `parent_node_id`. + /// + /// `tip` is `sessions.main_chain_id`. When that is missing or names a node + /// the session does not hold, the newest node stands in — the same + /// `MAX(node_id)` Devin itself reads. A parent the session does not hold + /// ends the walk as a root would, and a node seen twice (a cycle, which a + /// well-formed forest never has) ends it too. + static func mainChain(_ handle: OpaquePointer, sessionID: String, tip: Int64?) throws -> Chain { + let statement = try LiveSQLiteReader.prepare( + handle, + "SELECT parent_node_id FROM message_nodes WHERE session_id = ?1 AND node_id = ?2" + ) + defer { sqlite3_finalize(statement) } + + func walk(from start: Int64) throws -> Chain { + var ids: [Int64] = [] + var seen: Set = [] + var current: Int64? = start + while let id = current { + guard ids.count < maxChainNodes else { + return Chain(nodes: ids.reversed(), truncated: true) + } + guard seen.insert(id).inserted else { break } + sqlite3_reset(statement) + sqlite3_clear_bindings(statement) + bind(statement, 1, sessionID) + sqlite3_bind_int64(statement, 2, id) + switch sqlite3_step(statement) { + case SQLITE_ROW: + ids.append(id) + current = int64(statement, 0) + case SQLITE_DONE: + current = nil + default: + throw LiveSQLiteReader.ReadError.statement + } + } + return Chain(nodes: ids.reversed(), truncated: false) + } + + if let tip { + let chain = try walk(from: tip) + if !chain.nodes.isEmpty { return chain } + } + guard let newest = try newestNodeID(handle, sessionID: sessionID) else { + return Chain(nodes: [], truncated: false) + } + return try walk(from: newest) + } + + static func newestNodeID(_ handle: OpaquePointer, sessionID: String) throws -> Int64? { + let statement = try LiveSQLiteReader.prepare( + handle, "SELECT MAX(node_id) FROM message_nodes WHERE session_id = ?1" + ) + defer { sqlite3_finalize(statement) } + bind(statement, 1, sessionID) + guard sqlite3_step(statement) == SQLITE_ROW else { throw LiveSQLiteReader.ReadError.statement } + return int64(statement, 0) + } + + // MARK: - Nodes + + struct Node { + let message: [String: Any] + let byteCount: Int + let createdAt: Int64? + + var role: String? { message["role"] as? String } + + var metadata: [String: Any]? { message["metadata"] as? [String: Any] } + + /// Devin marks a prompt a person typed `is_user_input: true`; a user + /// turn that says `false` was put there by the harness. + var isInjected: Bool { + guard let flag = metadata?["is_user_input"] as? NSNumber else { return false } + return !flag.boolValue + } + + var generationModel: String? { SessionParsing.string(metadata?["generation_model"]) } + + /// The message's own ISO stamp, which survives compaction; the row's + /// `created_at` is when the node was (re)written. + var timestamp: Date? { + SessionParsing.date(metadata?["created_at"]) + ?? createdAt.flatMap { SessionParsing.date(NSNumber(value: $0)) } + } + } + + /// One prepared point lookup, reused for every node of a walk. + final class NodeLookup { + private let statement: OpaquePointer + private let sessionID: String + + init(_ handle: OpaquePointer, sessionID: String) throws { + self.statement = try LiveSQLiteReader.prepare( + handle, + "SELECT chat_message, created_at FROM message_nodes WHERE session_id = ?1 AND node_id = ?2" + ) + self.sessionID = sessionID + } + + deinit { sqlite3_finalize(statement) } + + /// `nil` for a node that is gone or whose message is not a JSON + /// object; a step error throws so the read is retried whole. + func node(_ nodeID: Int64) throws -> Node? { + sqlite3_reset(statement) + sqlite3_clear_bindings(statement) + DevinSessionAdapter.bind(statement, 1, sessionID) + sqlite3_bind_int64(statement, 2, nodeID) + switch sqlite3_step(statement) { + case SQLITE_ROW: + guard let raw = sqlite3_column_text(statement, 0) else { return nil } + let length = Int(sqlite3_column_bytes(statement, 0)) + guard length > 0, length <= LiveSQLiteReader.maxBlobBytes else { return nil } + let data = Data(bytes: raw, count: length) + guard let message = SessionParsing.json(data) else { return nil } + return Node( + message: message, + byteCount: length, + createdAt: DevinSessionAdapter.int64(statement, 1) + ) + case SQLITE_DONE: + return nil + default: + throw LiveSQLiteReader.ReadError.statement + } + } + } + + // MARK: - Column helpers + + private static let transient = unsafeBitCast(-1, to: sqlite3_destructor_type.self) + + static func bind(_ statement: OpaquePointer, _ index: Int32, _ text: String) { + sqlite3_bind_text(statement, index, text, -1, transient) + } + + static func int64(_ statement: OpaquePointer, _ column: Int32) -> Int64? { + switch sqlite3_column_type(statement, column) { + case SQLITE_INTEGER, SQLITE_FLOAT: return sqlite3_column_int64(statement, column) + case SQLITE_TEXT: return LiveSQLiteReader.text(statement, column).flatMap { Int64($0) } + default: return nil + } + } +} diff --git a/implementations/swift/Sources/AgentSessionKit/Sessions/LiveSQLiteReader.swift b/implementations/swift/Sources/AgentSessionKit/Sessions/LiveSQLiteReader.swift index a578779..b991363 100644 --- a/implementations/swift/Sources/AgentSessionKit/Sessions/LiveSQLiteReader.swift +++ b/implementations/swift/Sources/AgentSessionKit/Sessions/LiveSQLiteReader.swift @@ -3,11 +3,12 @@ import SQLite3 /// Read helpers for another app's SQLite store while that app is running. /// -/// AntiGravity's conversation databases and Cursor's agent stores are both -/// written by processes that may be running right now, so they routinely -/// carry a live `-wal` / `-shm` pair. That rules out `immutable=1` — the flag -/// tells SQLite there is no journal to replay, which on a live database means -/// reading a stale or torn snapshot. +/// AntiGravity's conversation databases, Cursor's agent stores, and Devin's +/// shared session database are all written by processes that may be running +/// right now, so they routinely carry a live `-wal` / `-shm` pair. That rules +/// out `immutable=1` while a journal exists — the flag tells SQLite there is +/// no journal to replay, which on a live database means reading a stale or +/// torn snapshot. /// /// The strategy is therefore: open the real file read-only with a short busy /// timeout and, only if that fails to produce a complete read, snapshot the @@ -38,12 +39,32 @@ public enum LiveSQLiteReader { /// Run `body` against a read-only handle on `url`, falling back to a /// snapshot copy. Returns `nil` when neither route produced a result; /// `body` must build its output from scratch so a retry is clean. - public static func read(at url: URL, _ body: (OpaquePointer) throws -> T) -> T? { + /// + /// `snapshotFallback: false` gives up after the direct read instead. That + /// is for a probe run once per session against a store many sessions + /// share (Devin's), where one locked moment must not turn into a copy of + /// the whole database per session: the caller skips and asks again later. + public static func read( + at url: URL, + snapshotFallback: Bool = true, + _ body: (OpaquePointer) throws -> T + ) -> T? { if let handle = open(path: url.path) { defer { sqlite3_close_v2(handle) } if let value = try? body(handle) { return value } } - guard let snapshot = snapshot(of: url) else { return nil } + // A WAL database whose last writer closed cleanly has no `-wal` left, + // and a read-only connection cannot create one, so the plain open + // above fails with SQLITE_CANTOPEN. Nothing is waiting to be replayed + // in that state, and a writer that arrives mid-read appends to a new + // journal rather than touching the main file, so the file is read in + // place rather than copied. + if isCheckpointedWALDatabase(url), + let handle = open(path: immutableURI(url.path), uri: true) { + defer { sqlite3_close_v2(handle) } + if let value = try? body(handle) { return value } + } + guard snapshotFallback, let snapshot = snapshot(of: url) else { return nil } defer { try? FileManager.default.removeItem(at: snapshot.deletingLastPathComponent()) } // The copy is ours, so a read-write open is allowed to replay the @@ -53,13 +74,33 @@ public enum LiveSQLiteReader { defer { sqlite3_close_v2(handle) } if let value = try? body(handle) { return value } } - guard let handle = open(path: "file:\(snapshot.path)?immutable=1", uri: true) else { + guard let handle = open(path: immutableURI(snapshot.path), uri: true) else { return nil } defer { sqlite3_close_v2(handle) } return try? body(handle) } + /// The file's header says WAL (format bytes 18 and 19 are both 2) and no + /// `-wal` sibling exists. + static func isCheckpointedWALDatabase(_ url: URL) -> Bool { + guard !FileManager.default.fileExists(atPath: url.path + "-wal"), + let handle = try? FileHandle(forReadingFrom: url) + else { return false } + defer { try? handle.close() } + guard let header = try? handle.read(upToCount: 20), header.count == 20 else { return false } + return header[header.startIndex + 18] == 2 && header[header.startIndex + 19] == 2 + } + + /// `file:?immutable=1`, with the path percent-encoded the way a + /// SQLite URI requires (`?`, `#`, and `%` would otherwise end or escape it). + static func immutableURI(_ path: String) -> String { + var allowed = CharacterSet.urlPathAllowed + allowed.remove(charactersIn: "?#%") + let encoded = path.addingPercentEncoding(withAllowedCharacters: allowed) ?? path + return "file:\(encoded)?immutable=1" + } + private static func open(path: String, readOnly: Bool = true, uri: Bool = false) -> OpaquePointer? { var handle: OpaquePointer? var flags = (readOnly ? SQLITE_OPEN_READONLY : SQLITE_OPEN_READWRITE) | SQLITE_OPEN_NOMUTEX diff --git a/implementations/swift/Sources/AgentSessionKit/Sessions/MistralVibeSessionAdapter.swift b/implementations/swift/Sources/AgentSessionKit/Sessions/MistralVibeSessionAdapter.swift new file mode 100644 index 0000000..8678dac --- /dev/null +++ b/implementations/swift/Sources/AgentSessionKit/Sessions/MistralVibeSessionAdapter.swift @@ -0,0 +1,249 @@ +import Foundation + +/// Mistral Vibe sessions: a directory per conversation at +/// `~/.vibe/logs/session/session__/`, +/// holding two files the `vibe` CLI writes on every save: +/// +/// - `meta.json` — rewritten atomically: `session_id`, `start_time` / +/// `end_time` (ISO-8601, µs, `+00:00`), `environment.working_directory`, +/// `title` (often `null`: titling is opt-in), and a `config` snapshot whose +/// `models[active_model].name` is the model id the session calls. +/// - `messages.jsonl` — one message per line, never the system prompt: +/// `role` (`user` / `assistant` / `tool`), `content` (a string or a chunk +/// list), `injected`, `tool_calls[].function.{name, arguments}`. Appended, +/// or rewritten whole on a rewind. No line carries a timestamp. +/// +/// A session's file is its `messages.jsonl`, so head / tail reads and host +/// byte bounds work as they do for every other JSONL provider; its +/// `changeFingerprint` folds `meta.json` in, because a rename or a model +/// switch rewrites only that file. +/// +/// Discovery lists the top-level `session_*` directories and nothing under +/// them. A sub-agent's session is nested in its parent's directory +/// (`/agents/_…/`) and linked from the parent's +/// `child_sessions`; like Muse Code's reminder children it belongs to the +/// parent's conversation, and Vibe's own resume list skips it too. `active/` +/// holds lease files and `.session_index.json` is Vibe's listing cache — +/// neither is a session. +/// +/// Read-only: Vibe caches its listing in `.session_index.json`, leases a +/// running session under `active/`, nests sub-agent sessions inside the +/// parent's directory, and sweeps worktrees that no listed session resumes +/// into — a session removed underneath it can cost the user a worktree. +public struct MistralVibeSessionAdapter: SessionProviderAdapter { + public let provider: SessionProvider = .mistralVibe + + public init() {} + + static let rootDirectory = ".vibe/logs/session" + static let sessionDirectoryPrefix = "session_" + static let metadataFileName = "meta.json" + static let messagesFileName = "messages.jsonl" + + /// `meta.json` carries the system prompt and every tool schema, but does + /// not grow with the conversation; anything past this is not one. + static let maxMetadataBytes: Int64 = 16 * 1024 * 1024 + static let headLineCount = 40 + /// A pasted prompt can make the first record large; a title never needs + /// more than this much of the log. + static let headByteBudget = 1 << 20 + static let tailLineCount = 40 + + public func roots(homeDirectory: String) -> [URL] { + [URL(fileURLWithPath: homeDirectory).appendingPathComponent(Self.rootDirectory, isDirectory: true)] + } + + // MARK: - Discovery + + public func discoverSessionFiles(homeDirectory: String) -> [URL] { + roots(homeDirectory: homeDirectory).flatMap { root -> [URL] in + guard let children = try? FileManager.default.contentsOfDirectory( + at: root, + includingPropertiesForKeys: [.isDirectoryKey, .isSymbolicLinkKey], + options: [.skipsHiddenFiles] + ) else { return [] } + return children.compactMap { directory -> URL? in + guard directory.lastPathComponent.hasPrefix(Self.sessionDirectoryPrefix) else { return nil } + let values = try? directory.resourceValues(forKeys: [.isDirectoryKey, .isSymbolicLinkKey]) + guard values?.isSymbolicLink == false, values?.isDirectory == true else { return nil } + let messages = directory.appendingPathComponent(Self.messagesFileName) + guard Self.isRegularFile(messages), + Self.isRegularFile(directory.appendingPathComponent(Self.metadataFileName)) + else { return nil } + return messages + } + .sorted { $0.path < $1.path } + } + } + + /// Not a symlink, and a regular file. + static func isRegularFile(_ url: URL) -> Bool { + let values = try? url.resourceValues(forKeys: [.isSymbolicLinkKey, .isRegularFileKey]) + return values?.isSymbolicLink == false && values?.isRegularFile == true + } + + static func metadataURL(forMessages url: URL) -> URL { + url.deletingLastPathComponent().appendingPathComponent(metadataFileName) + } + + // MARK: - Change fingerprint + + /// `messages.jsonl` with `meta.json` folded in: a turn moves the first, a + /// rename, a model switch, or a generated title moves only the second. + public func changeFingerprint(fileURL: URL) -> SessionChangeFingerprint? { + guard let messages = SessionChangeFingerprint.file(at: fileURL) else { return nil } + return messages.folding(SessionChangeFingerprint.file(at: Self.metadataURL(forMessages: fileURL))) + } + + // MARK: - Metadata + + public func extractMetadata(fileURL: URL) throws -> SessionSummary { + guard fileURL.lastPathComponent == Self.messagesFileName else { + throw SessionParseError.invalidFormat("\(fileURL.lastPathComponent): not a messages log") + } + let directory = fileURL.deletingLastPathComponent() + let meta = try Self.metadata(forMessages: fileURL) + + guard let sessionID = SessionParsing.string(meta["session_id"]) else { + throw SessionParseError.invalidFormat("\(Self.metadataFileName): no session id") + } + guard Self.directory(directory.lastPathComponent, matches: sessionID) else { + throw SessionParseError.invalidFormat( + "\(directory.lastPathComponent): directory is not named after the session id" + ) + } + + // Vibe lists an empty log only when the session recorded no messages; + // otherwise it is an interrupted write it refuses to load. + let size = SessionParsing.fileSize(fileURL) + if size == 0, SessionParsing.int(meta["total_messages"]) != 0 { + throw SessionParseError.invalidFormat("\(Self.messagesFileName): empty log") + } + + var firstPrompt: String? + if SessionParsing.string(meta["title"]) == nil { + for line in JSONLHeadTail.headLines(url: fileURL, count: Self.headLineCount, maxBytes: Self.headByteBudget) { + guard let message = Self.message(line), message.role == "user" else { continue } + if let text = SessionParsing.string(message.text) { + firstPrompt = text + break + } + } + } + + var lastReply: String? + for line in JSONLHeadTail.tailLines(url: fileURL, count: Self.tailLineCount).reversed() { + guard let message = Self.message(line), message.role == "assistant" else { continue } + if let text = SessionParsing.string(message.text) { + lastReply = text + break + } + } + + let environment = meta["environment"] as? [String: Any] + return SessionSummary( + provider: .mistralVibe, + sessionID: sessionID, + harness: .mistralVibe, + model: Self.model(in: meta["config"]), + title: SessionParsing.display( + SessionParsing.string(meta["title"]) ?? firstPrompt, + limit: SessionParsing.titleLimit + ), + summary: SessionParsing.display(lastReply, limit: SessionParsing.summaryLimit), + projectDir: SessionParsing.firstString(environment?["working_directory"], meta["origin_directory"]), + createdAt: SessionParsing.date(meta["start_time"]) ?? SessionParsing.creationDate(directory), + lastActiveAt: SessionParsing.date(meta["end_time"]) ?? SessionParsing.modificationDate(fileURL), + sourcePath: fileURL.path, + sizeBytes: max(0, size) + ) + } + + /// `meta.json` beside a messages log, bounded and never through a link. + static func metadata(forMessages url: URL) throws -> [String: Any] { + let metaURL = metadataURL(forMessages: url) + guard isRegularFile(metaURL), SessionParsing.fileSize(metaURL) <= maxMetadataBytes, + let object = SessionParsing.jsonObject(at: metaURL) + else { throw SessionParseError.unreadable(metadataFileName) } + return object + } + + /// `session_20260101_000000_5c0ffee1` belongs to a session whose id starts + /// `5c0ffee1`. + static func directory(_ name: String, matches sessionID: String) -> Bool { + guard name.hasPrefix(sessionDirectoryPrefix), sessionID.count >= 8 else { return false } + return name.hasSuffix("_" + String(sessionID.prefix(8))) + } + + /// `config.models[config.active_model].name` — the model id the alias + /// resolves to (`mistral-medium-3.5` → `mistral-vibe-cli-latest`). A + /// `models` list whose entries carry their own `alias` is read the same + /// way. No entry for the active alias means no model, not the alias. + static func model(in config: Any?) -> String? { + guard let config = config as? [String: Any], + let active = SessionParsing.string(config["active_model"]) + else { return nil } + if let models = config["models"] as? [String: Any] { + return SessionParsing.string((models[active] as? [String: Any])?["name"]) + } + if let models = config["models"] as? [[String: Any]] { + let entry = models.first { SessionParsing.string($0["alias"]) == active } + return SessionParsing.string(entry?["name"]) + } + return nil + } + + // MARK: - Transcript + + public func parseTranscript(fileURL: URL, range: Range?) throws -> TranscriptDocument { + var messages: [SessionMessage] = [] + func append(_ role: SessionRole, _ text: String) { + guard !text.isEmpty else { return } + messages.append(SessionMessage(seq: messages.count, role: role, text: text, timestamp: nil)) + } + + let didRead = JSONLLineScanner.forEachLine(in: fileURL) { line in + guard let message = Self.message(line) else { return } + switch message.role { + case "user": + append(.user, message.text) + case "assistant": + append(.assistant, message.text) + for call in (message.object["tool_calls"] as? [[String: Any]]) ?? [] { + append(.tool, Self.toolCallText(call)) + } + case "tool": + append(.tool, message.text) + default: + break + } + } + guard didRead else { throw SessionParseError.unreadable(fileURL.lastPathComponent) } + return SessionTranscriptSlicing.document(messages: messages, range: range) + } + + /// One log line, or `nil` for a malformed line or one Vibe injected — a + /// compaction summary, a hook's message, a resumed plan — rather than a + /// person or the model wrote. Reasoning is dropped. + static func message(_ line: Data) -> (role: String?, text: String, object: [String: Any])? { + guard let object = SessionParsing.json(line), !SessionParsing.bool(object["injected"]) else { + return nil + } + return (object["role"] as? String, SessionParsing.extractText(object["content"]), object) + } + + /// `[Tool: name]` and the call's JSON arguments string. + static func toolCallText(_ call: [String: Any]) -> String { + let function = call["function"] as? [String: Any] + let name = SessionParsing.firstString(function?["name"], call["name"]) ?? "tool" + let arguments = SessionParsing.firstString(function?["arguments"], call["arguments"]) + .map { SessionParsing.truncate($0, limit: 4_000) } + return arguments.map { "[Tool: \(name)]\n\($0)" } ?? "[Tool: \(name)]" + } + + // MARK: - Deletion + + public func deletionPlan(for summary: SessionSummary, homeDirectory: String) throws -> SessionDeletionPlan { + throw SessionDeleteError.providerIsReadOnly(.mistralVibe) + } +} diff --git a/implementations/swift/Sources/AgentSessionKit/Sessions/SessionIndexService.swift b/implementations/swift/Sources/AgentSessionKit/Sessions/SessionIndexService.swift index 4a3eb8f..e78b7c2 100644 --- a/implementations/swift/Sources/AgentSessionKit/Sessions/SessionIndexService.swift +++ b/implementations/swift/Sources/AgentSessionKit/Sessions/SessionIndexService.swift @@ -4,7 +4,8 @@ import Foundation /// Keeps `SessionIndexStore` in step with what is actually on disk. /// /// One pass walks every adapter's session files, fingerprints each one -/// (modification time in nanoseconds + size), and re-reads only the +/// (`SessionProviderAdapter.changeFingerprint` — for most providers the +/// file's modification time in nanoseconds + size), and re-reads only the /// files whose fingerprint moved. Metadata is always indexed; message /// bodies are indexed only while `bodyIndexing()` says so, and turning /// that off drops the bodies on the next pass. @@ -180,7 +181,7 @@ public actor SessionIndexService { pathHash: String, bodies: Bool ) async -> SessionIndexStore.IndexBatchEntry? { - guard let fingerprint = Self.fingerprint(url) else { return nil } + guard let fingerprint = adapter.changeFingerprint(fileURL: url) else { return nil } do { if let cursor = try await store.fileCursor(pathHash: pathHash), cursor.mtimeNanos == fingerprint.mtimeNanos, @@ -232,30 +233,6 @@ public actor SessionIndexService { PrivacyPreservingHash.fileComponent(prefix: "session-path-v1", rawValue: path) } - /// Nanosecond mtime + size. Second-resolution timestamps are too - /// coarse: a session file appended to twice inside the same second is - /// exactly the case an incremental index has to notice. - /// - /// A live SQLite store in WAL mode commits into `-wal` and leaves - /// the main file untouched until a checkpoint, so the journal sibling is - /// folded in (latest mtime, summed size) or an active Cursor / AntiGravity - /// conversation would look unchanged for as long as it is being written. - static func fingerprint(_ url: URL) -> (mtimeNanos: Int64, size: Int64)? { - guard var result = statFingerprint(url.path) else { return nil } - if let wal = statFingerprint(url.path + "-wal") { - result = (max(result.mtimeNanos, wal.mtimeNanos), result.size + wal.size) - } - return result - } - - private static func statFingerprint(_ path: String) -> (mtimeNanos: Int64, size: Int64)? { - var info = stat() - guard stat(path, &info) == 0 else { return nil } - let seconds = Int64(info.st_mtimespec.tv_sec) - let nanos = Int64(info.st_mtimespec.tv_nsec) - return (seconds * 1_000_000_000 + nanos, Int64(info.st_size)) - } - // MARK: - Excerpt policy /// Per-message cap. Long enough to keep a paragraph of context around diff --git a/implementations/swift/Sources/AgentSessionKit/Sessions/SessionModels.swift b/implementations/swift/Sources/AgentSessionKit/Sessions/SessionModels.swift index 9e1f464..7ab2a84 100644 --- a/implementations/swift/Sources/AgentSessionKit/Sessions/SessionModels.swift +++ b/implementations/swift/Sources/AgentSessionKit/Sessions/SessionModels.swift @@ -8,7 +8,7 @@ import Foundation /// ChatGPT Work depending on its `originator`), which is why every summary /// also carries a `harness`. /// -/// Five providers are listed and readable but never deletable, because +/// Seven providers are listed and readable but never deletable, because /// another running app — or, for Grok Bot, a server — owns the store; see /// `supportsDeletion`. public enum SessionProvider: String, Codable, Sendable, CaseIterable, Hashable { @@ -21,6 +21,8 @@ public enum SessionProvider: String, Codable, Sendable, CaseIterable, Hashable { case antigravity case grokBot case muse + case devin + case mistralVibe public var displayName: String { switch self { @@ -33,6 +35,8 @@ public enum SessionProvider: String, Codable, Sendable, CaseIterable, Hashable { case .antigravity: return HarnessCatalog.antigravity case .grokBot: return HarnessCatalog.grokBot case .muse: return HarnessCatalog.museCode + case .devin: return HarnessCatalog.devin + case .mistralVibe: return HarnessCatalog.mistralVibe } } @@ -50,6 +54,8 @@ public enum SessionProvider: String, Codable, Sendable, CaseIterable, Hashable { case .antigravity: return .antigravity case .grokBot: return .grokBot case .muse: return .museCode + case .devin: return .devin + case .mistralVibe: return .mistralVibe } } @@ -59,16 +65,19 @@ public enum SessionProvider: String, Codable, Sendable, CaseIterable, Hashable { /// underneath it is how a store gets corrupted rather than emptied — /// AntiGravity's live WAL handles, Cursor's open agent database, /// Cowork's transcripts inside Claude.app's own container, and Grok - /// Bot's replica of conversations that actually live in the cloud, and + /// Bot's replica of conversations that actually live in the cloud, /// Muse Code's session directories, which the CLI's own SQLite index - /// references and a running `muse` holds locks and sockets inside. + /// references and a running `muse` holds locks and sockets inside, + /// Devin's sessions, which are rows in one database the CLI holds open, + /// and Mistral Vibe's session directories, which the CLI indexes, leases, + /// and nests sub-agent sessions inside. /// The adapters fail closed with /// `SessionDeleteError.providerIsReadOnly`; this is the same fact /// stated where a UI can ask it before offering the action. public var supportsDeletion: Bool { switch self { case .claude, .codex, .grok, .gemini: return true - case .claudeCowork, .cursor, .antigravity, .grokBot, .muse: return false + case .claudeCowork, .cursor, .antigravity, .grokBot, .muse, .devin, .mistralVibe: return false } } } @@ -317,6 +326,10 @@ public enum SessionDeleteError: Error, Hashable, Codable, Sendable { return "Grok Bot sessions are the app's own cloud cache; manage them in Grok Bot." case .muse: return "Muse Code indexes its own sessions; remove them from the muse CLI." + case .devin: + return "Devin keeps every session in one live database; remove them with devin rm." + case .mistralVibe: + return "Mistral Vibe indexes and leases its own sessions; manage them from the vibe CLI." case .claude, .codex, .grok, .gemini: return "Deleting sessions is not supported for this provider." } diff --git a/implementations/swift/Sources/AgentSessionKit/Sessions/SessionProviderAdapter.swift b/implementations/swift/Sources/AgentSessionKit/Sessions/SessionProviderAdapter.swift index 52c3b94..6e6ee3b 100644 --- a/implementations/swift/Sources/AgentSessionKit/Sessions/SessionProviderAdapter.swift +++ b/implementations/swift/Sources/AgentSessionKit/Sessions/SessionProviderAdapter.swift @@ -1,3 +1,4 @@ +import Darwin import Foundation /// Failure modes shared by every adapter's read paths. @@ -52,6 +53,17 @@ public protocol SessionProviderAdapter: Sendable { /// What removing this session means on disk, plus the inputs the /// deleter re-asserts before it removes anything. func deletionPlan(for summary: SessionSummary, homeDirectory: String) throws -> SessionDeletionPlan + + /// What the incremental index compares to decide whether `fileURL` must be + /// re-read; `nil` skips it for this pass and retries on the next. + /// + /// The default is the file's own fingerprint + /// (`SessionChangeFingerprint.file(at:)`). An adapter overrides it when + /// the file alone is the wrong answer: Mistral Vibe renames a session by + /// rewriting a sibling `meta.json`, and Devin keeps every session as rows + /// in one shared database, where the file's fingerprint would re-read all + /// of them after a turn in any one. + func changeFingerprint(fileURL: URL) -> SessionChangeFingerprint? } public extension SessionProviderAdapter { @@ -59,6 +71,10 @@ public extension SessionProviderAdapter { try parseTranscript(fileURL: fileURL, range: nil) } + func changeFingerprint(fileURL: URL) -> SessionChangeFingerprint? { + SessionChangeFingerprint.file(at: fileURL) + } + /// Discover + describe in one pass, dropping files that do not /// parse. Adapters that reject a file (`invalidFormat`) drop out /// here rather than failing the whole sweep. @@ -69,6 +85,77 @@ public extension SessionProviderAdapter { } } +/// Two numbers the incremental index stores per discovered session and +/// compares for equality on the next pass. They are never interpreted, so an +/// adapter whose session is not one file may fill them with whatever moves +/// exactly when the session does. +public struct SessionChangeFingerprint: Hashable, Sendable { + public let mtimeNanos: Int64 + public let size: Int64 + + public init(mtimeNanos: Int64, size: Int64) { + self.mtimeNanos = mtimeNanos + self.size = size + } + + /// Nanosecond mtime + size. Second-resolution timestamps are too coarse: + /// a session file appended to twice inside the same second is exactly the + /// case an incremental index has to notice. + /// + /// A live SQLite store in WAL mode commits into `-wal` and leaves + /// the main file untouched until a checkpoint, so the journal sibling is + /// folded in (latest mtime, summed size) or an active Cursor / AntiGravity + /// conversation would look unchanged for as long as it is being written. + /// + /// A locator that names a session *inside* a store file — + /// `/`, the shape Devin's session URLs take — answers + /// with the store's own fingerprint. That is coarse (every session in the + /// store moves together), and it is only reached by a wrapper adapter that + /// does not forward `changeFingerprint`; without it such a wrapper would + /// silently index none of those sessions. + public static func file(at url: URL) -> SessionChangeFingerprint? { + switch statFingerprint(url.path) { + case let .success(own): + return own.folding(try? statFingerprint(url.path + "-wal").get()) + case .failure(let failure) where failure.code == ENOTDIR: + let store = url.deletingLastPathComponent().path + var info = stat() + guard lstat(store, &info) == 0, (info.st_mode & S_IFMT) == S_IFREG, + let parent = try? statFingerprint(store).get() + else { return nil } + return parent.folding(try? statFingerprint(store + "-wal").get()) + case .failure: + return nil + } + } + + /// `self` with another file's fingerprint folded in the way a WAL sibling + /// is: the later mtime, the summed size. + public func folding(_ other: SessionChangeFingerprint?) -> SessionChangeFingerprint { + guard let other else { return self } + return SessionChangeFingerprint( + mtimeNanos: max(mtimeNanos, other.mtimeNanos), + size: size &+ other.size + ) + } + + struct StatFailure: Error { + let code: Int32 + } + + /// `stat(2)` with its `errno` captured at the call, not read back later. + static func statFingerprint(_ path: String) -> Result { + var info = stat() + guard stat(path, &info) == 0 else { return .failure(StatFailure(code: errno)) } + let seconds = Int64(info.st_mtimespec.tv_sec) + let nanos = Int64(info.st_mtimespec.tv_nsec) + return .success(SessionChangeFingerprint( + mtimeNanos: seconds * 1_000_000_000 + nanos, + size: Int64(info.st_size) + )) + } +} + /// Provider → adapter lookup, with a stable iteration order. public struct SessionProviderRegistry: Sendable { public let adapters: [any SessionProviderAdapter] @@ -85,9 +172,10 @@ public struct SessionProviderRegistry: Sendable { /// The adapters shipped today, one per `SessionProvider`. /// - /// AntiGravity, Claude Cowork, Cursor, Grok Bot, and Muse Code list and read like the - /// rest but refuse to plan a delete, because another running app owns - /// those stores — see `SessionProvider.supportsDeletion`. + /// AntiGravity, Claude Cowork, Cursor, Grok Bot, Muse Code, Devin, and + /// Mistral Vibe list and read like the rest but refuse to plan a delete, + /// because another running app owns those stores — see + /// `SessionProvider.supportsDeletion`. public static func standard(homeDirectory: String = RealHomeDirectory.path) -> SessionProviderRegistry { SessionProviderRegistry(adapters: [ ClaudeSessionAdapter(), @@ -98,7 +186,9 @@ public struct SessionProviderRegistry: Sendable { GeminiSessionAdapter(), AntigravitySessionAdapter(), GrokBotSessionAdapter(), - MuseSessionAdapter() + MuseSessionAdapter(), + DevinSessionAdapter(), + MistralVibeSessionAdapter() ]) } diff --git a/implementations/swift/Sources/AgentSessionKit/Sessions/SessionResumeCommandBuilder.swift b/implementations/swift/Sources/AgentSessionKit/Sessions/SessionResumeCommandBuilder.swift index 2fcb199..bf62d56 100644 --- a/implementations/swift/Sources/AgentSessionKit/Sessions/SessionResumeCommandBuilder.swift +++ b/implementations/swift/Sources/AgentSessionKit/Sessions/SessionResumeCommandBuilder.swift @@ -49,6 +49,10 @@ public enum SessionResumeCommandBuilder { return "agy --conversation \(id)" case .muse: return "muse resume \(id)" + case .devin: + return "devin --resume \(id)" + case .mistralVibe: + return "vibe --resume \(id)" case .claudeCowork, .cursor, .grokBot: // Cowork runs inside Claude.app and Cursor's agents inside // Cursor; neither publishes a "reopen this conversation" @@ -81,9 +85,10 @@ public enum SessionResumeCommandBuilder { static func isValid(_ id: String, for provider: SessionProvider) -> Bool { guard !id.isEmpty, id.count <= maxSessionIDLength else { return false } switch provider { - case .claude, .claudeCowork, .codex, .cursor, .antigravity, .grokBot, .muse: + case .claude, .claudeCowork, .codex, .cursor, .antigravity, .grokBot, .muse, .mistralVibe: return id.unicodeScalars.allSatisfy { uuidCharacters.contains($0) } - case .grok, .gemini: + case .grok, .gemini, .devin: + // Devin names a session with a word pair (`quiet-harbor`), not a UUID. return id.unicodeScalars.allSatisfy { looseCharacters.contains($0) } } } diff --git a/implementations/swift/Sources/AgentSessionKit/Utilities/JSONLHeadTail.swift b/implementations/swift/Sources/AgentSessionKit/Utilities/JSONLHeadTail.swift index 8adb8e5..c6b292e 100644 --- a/implementations/swift/Sources/AgentSessionKit/Utilities/JSONLHeadTail.swift +++ b/implementations/swift/Sources/AgentSessionKit/Utilities/JSONLHeadTail.swift @@ -19,7 +19,12 @@ public enum JSONLHeadTail { private static let chunkSize = 16 * 1024 /// First `count` non-empty lines, reading only as far as needed. - public static func headLines(url: URL, count: Int) -> [Data] { + /// + /// `maxBytes` caps how much of the file is read at all. A head whose + /// lines run past it ends at the last complete line inside the budget, + /// so one enormous (or newline-free) first record cannot pull a whole + /// transcript into memory to learn a title. + public static func headLines(url: URL, count: Int, maxBytes: Int? = nil) -> [Data] { guard count > 0 else { return [] } guard let handle = try? FileHandle(forReadingFrom: url) else { return [] } defer { try? handle.close() } @@ -27,10 +32,13 @@ public enum JSONLHeadTail { var out: [Data] = [] var buffer: [UInt8] = [] var lineStart = 0 + var bytesRead = 0 do { while out.count < count, - let chunk = try handle.read(upToCount: chunkSize), + maxBytes.map({ bytesRead < $0 }) ?? true, + let chunk = try handle.read(upToCount: maxBytes.map { min(chunkSize, $0 - bytesRead) } ?? chunkSize), !chunk.isEmpty { + bytesRead += chunk.count buffer.append(contentsOf: chunk) let end = buffer.count var i = lineStart @@ -49,7 +57,10 @@ public enum JSONLHeadTail { lineStart = 0 } } - if out.count < count, lineStart < buffer.count { + // An unterminated remainder is a whole last line only when the + // file ended; a spent budget leaves it partial, so it is dropped. + let exhausted = maxBytes.map { bytesRead >= $0 } ?? false + if out.count < count, !exhausted, lineStart < buffer.count { let tail = Data(buffer[lineStart.. Bool { switch harness { case .claudeCowork, .cursor, .grokBot: false - case .codex, .chatgptWork, .claudeCode, .geminiCLI, .antigravity, .grokBuild, .museCode: true + case .codex, .chatgptWork, .claudeCode, .geminiCLI, .antigravity, .grokBuild, .museCode, + .devin, .mistralVibe: true } } @@ -127,7 +128,7 @@ public enum SessionResume: Sendable { return "Grok Bot conversations run on xAI's servers; there is nothing local to resume." case .antigravity: return "Only AntiGravity's CLI sessions take a conversation id; an IDE session has none." - case .codex, .chatgptWork, .claudeCode, .geminiCLI, .grokBuild, .museCode: + case .codex, .chatgptWork, .claudeCode, .geminiCLI, .grokBuild, .museCode, .devin, .mistralVibe: return "This session cannot be resumed from the command line." } } @@ -152,6 +153,8 @@ public extension Harness { case .cursor: .cursor case .grokBot: .grokBot case .museCode: .muse + case .devin: .devin + case .mistralVibe: .mistralVibe } } } diff --git a/implementations/swift/Tests/AgentSessionKitTests/DevinSessionAdapterTests.swift b/implementations/swift/Tests/AgentSessionKitTests/DevinSessionAdapterTests.swift new file mode 100644 index 0000000..27ea1ee --- /dev/null +++ b/implementations/swift/Tests/AgentSessionKitTests/DevinSessionAdapterTests.swift @@ -0,0 +1,579 @@ +import XCTest +import SQLite3 +@testable import AgentSessionKit + +/// Builds `~/.local/share/devin/cli/sessions.db` with the tables and columns +/// the Devin CLI creates. Every row is synthetic. +enum DevinStoreFixture { + struct Session { + var id: String + var workingDirectory = "/Users/example/proj" + var model = "swe-2-high" + var createdAt: Int64 = 1_767_225_600 + var lastActivityAt: Int64 = 1_767_225_630 + var title: String? + var mainChainID: Int64? + var hidden: Int64 = 0 + } + + struct Node { + var sessionID: String + var nodeID: Int64 + var parentNodeID: Int64? + var chatMessage: String + var createdAt: Int64 = 1_767_225_640 + } + + static let schema = """ + CREATE TABLE sessions ( + id TEXT PRIMARY KEY, + working_directory TEXT NOT NULL, + backend_type TEXT NOT NULL, + model TEXT NOT NULL, + agent_mode TEXT NOT NULL, + created_at INTEGER NOT NULL, + last_activity_at INTEGER NOT NULL, title TEXT, main_chain_id INTEGER, + shell_last_seen_index INTEGER DEFAULT 0, cogs_json TEXT, workspace_dirs TEXT, + hidden INTEGER NOT NULL DEFAULT 0, metadata TEXT); + CREATE INDEX idx_sessions_activity ON sessions(last_activity_at DESC); + CREATE TABLE message_nodes ( + row_id INTEGER PRIMARY KEY AUTOINCREMENT, + session_id TEXT NOT NULL, + node_id INTEGER NOT NULL, + parent_node_id INTEGER, + chat_message TEXT NOT NULL, + created_at INTEGER NOT NULL, metadata TEXT, + FOREIGN KEY (session_id) REFERENCES sessions(id), + UNIQUE(session_id, node_id) + ); + CREATE INDEX idx_message_nodes_session ON message_nodes(session_id); + CREATE TABLE subagent_heads ( + session_id TEXT NOT NULL, + agent_id TEXT NOT NULL, + chain_node_id INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + PRIMARY KEY (session_id, agent_id) + ); + """ + + static func databaseURL(home: URL) -> URL { + home.appendingPathComponent(".local/share/devin/cli/sessions.db") + } + + /// Writes the store in WAL mode, as the CLI keeps it. + @discardableResult + static func write(home: URL, sessions: [Session], nodes: [Node]) throws -> URL { + let url = databaseURL(home: home) + try FileManager.default.createDirectory( + at: url.deletingLastPathComponent(), withIntermediateDirectories: true + ) + let database = try open(url) + defer { sqlite3_close_v2(database) } + try exec(database, "PRAGMA journal_mode=WAL") + try exec(database, schema) + for session in sessions { try insert(database, session) } + for node in nodes { try insert(database, node) } + return url + } + + static func open(_ url: URL) throws -> OpaquePointer { + var database: OpaquePointer? + guard sqlite3_open_v2(url.path, &database, SQLITE_OPEN_READWRITE | SQLITE_OPEN_CREATE, nil) == SQLITE_OK, + let database + else { throw CocoaError(.fileWriteUnknown) } + return database + } + + static func exec(_ database: OpaquePointer, _ sql: String) throws { + guard sqlite3_exec(database, sql, nil, nil, nil) == SQLITE_OK else { + throw CocoaError(.fileWriteUnknown) + } + } + + private static let transient = unsafeBitCast(-1, to: sqlite3_destructor_type.self) + + static func insert(_ database: OpaquePointer, _ session: Session) throws { + try run(database, """ + INSERT INTO sessions(id, working_directory, backend_type, model, agent_mode, created_at, + last_activity_at, title, main_chain_id, hidden, metadata) + VALUES(?, ?, 'windsurf', ?, 'smart', ?, ?, ?, ?, ?, '{"total_credit_cost":0,"total_acu_cost":0.0}') + """, [ + .text(session.id), .text(session.workingDirectory), .text(session.model), + .int(session.createdAt), .int(session.lastActivityAt), + session.title.map(Value.text) ?? .null, session.mainChainID.map(Value.int) ?? .null, + .int(session.hidden) + ]) + } + + static func insert(_ database: OpaquePointer, _ node: Node) throws { + try run(database, """ + INSERT INTO message_nodes(session_id, node_id, parent_node_id, chat_message, created_at) + VALUES(?, ?, ?, ?, ?) + """, [ + .text(node.sessionID), .int(node.nodeID), node.parentNodeID.map(Value.int) ?? .null, + .text(node.chatMessage), .int(node.createdAt) + ]) + } + + enum Value { + case text(String) + case int(Int64) + case null + } + + static func run(_ database: OpaquePointer, _ sql: String, _ values: [Value]) throws { + var statement: OpaquePointer? + guard sqlite3_prepare_v2(database, sql, -1, &statement, nil) == SQLITE_OK, let statement else { + throw CocoaError(.fileWriteUnknown) + } + defer { sqlite3_finalize(statement) } + for (offset, value) in values.enumerated() { + let index = Int32(offset + 1) + switch value { + case let .text(text): sqlite3_bind_text(statement, index, text, -1, transient) + case let .int(number): sqlite3_bind_int64(statement, index, number) + case .null: sqlite3_bind_null(statement, index) + } + } + guard sqlite3_step(statement) == SQLITE_DONE else { throw CocoaError(.fileWriteUnknown) } + } + + // MARK: - chat_message JSON + + static func json(_ object: [String: Any]) -> String { + let data = try! JSONSerialization.data(withJSONObject: object, options: [.sortedKeys]) + return String(decoding: data, as: UTF8.self) + } + + static func system(_ text: String) -> String { + json(["message_id": UUID().uuidString.lowercased(), "role": "system", "content": text, + "metadata": ["num_tokens": 12, "request_id": ""]]) + } + + static func user(_ text: String, typed: Bool = true, at iso: String = "2026-01-01T00:00:10.123456Z") -> String { + json(["message_id": UUID().uuidString.lowercased(), "role": "user", "content": text, + "metadata": ["is_user_input": typed, "created_at": iso]]) + } + + static func assistant( + _ text: String, + model: String? = "swe-2-high-20260101", + toolCalls: [[String: Any]] = [], + at iso: String = "2026-01-01T00:00:20.654321Z" + ) -> String { + var metadata: [String: Any] = [ + "created_at": iso, + "finish_reason": "stop", + "metrics": ["input_tokens": 1_200, "output_tokens": 80, "cache_read_tokens": 900] + ] + if let model { metadata["generation_model"] = model } + return json([ + "message_id": UUID().uuidString.lowercased(), + "role": "assistant", + "content": text, + "thinking": ["thinking": "private reasoning", "signature": "sealed.v1.example", "signature_type": "x"], + "tool_calls": toolCalls, + "metadata": metadata + ]) + } + + static func tool(_ text: String, callID: String) -> String { + json(["role": "tool", "content": text, "tool_call_id": callID, "metadata": [:]]) + } +} + +final class DevinSessionAdapterTests: XCTestCase { + private var home: URL! + private let adapter = DevinSessionAdapter() + private typealias Fixture = DevinStoreFixture + + private let sessionID = "quiet-harbor" + private let olderID = "amber-lantern" + private let hiddenID = "silent-summary" + + override func setUpWithError() throws { + home = FileManager.default.temporaryDirectory + .appendingPathComponent("ASKDevinSessionTests-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: home, withIntermediateDirectories: true) + } + + override func tearDownWithError() throws { + try? FileManager.default.removeItem(at: home) + } + + // MARK: - Fixtures + + /// A compacted conversation: an abandoned pre-compaction prefix, the + /// re-rooted main chain with system blocks and an injected reminder, a + /// discarded sibling reply, and a sub-agent's tree of its own. + private var forest: [Fixture.Node] { + let call: [String: Any] = ["id": "call_1", "name": "read", "arguments": ["path": "NOTES.md"]] + return [ + .init(sessionID: sessionID, nodeID: 0, parentNodeID: nil, chatMessage: Fixture.system("Old prefix")), + .init(sessionID: sessionID, nodeID: 1, parentNodeID: 0, chatMessage: Fixture.user("Count the lines in NOTES.md")), + .init(sessionID: sessionID, nodeID: 2, parentNodeID: nil, chatMessage: Fixture.system("You are a coding agent.")), + .init(sessionID: sessionID, nodeID: 3, parentNodeID: 2, chatMessage: Fixture.system("example")), + .init(sessionID: sessionID, nodeID: 4, parentNodeID: 3, chatMessage: Fixture.user("Count the lines in NOTES.md")), + .init(sessionID: sessionID, nodeID: 5, parentNodeID: 4, chatMessage: Fixture.system("")), + .init(sessionID: sessionID, nodeID: 6, parentNodeID: 5, + chatMessage: Fixture.user("stay in the workspace", typed: false)), + .init(sessionID: sessionID, nodeID: 7, parentNodeID: 6, + chatMessage: Fixture.assistant("I'll read it.", toolCalls: [call])), + .init(sessionID: sessionID, nodeID: 8, parentNodeID: 7, chatMessage: Fixture.tool("1|# Notes", callID: "call_1")), + .init(sessionID: sessionID, nodeID: 9, parentNodeID: 8, chatMessage: Fixture.assistant("A discarded draft")), + .init(sessionID: sessionID, nodeID: 10, parentNodeID: 8, + chatMessage: Fixture.assistant("NOTES.md has one line.", model: "swe-2-high-20260102")), + .init(sessionID: sessionID, nodeID: 11, parentNodeID: nil, chatMessage: Fixture.system("Sub-agent prompt")), + .init(sessionID: sessionID, nodeID: 12, parentNodeID: 11, chatMessage: Fixture.user("Sub-agent task")) + ] + } + + private var olderNodes: [Fixture.Node] { + [ + .init(sessionID: olderID, nodeID: 0, parentNodeID: nil, chatMessage: Fixture.system("You are a coding agent.")), + .init(sessionID: olderID, nodeID: 1, parentNodeID: 0, chatMessage: Fixture.user("Rename the target")), + .init(sessionID: olderID, nodeID: 2, parentNodeID: 1, chatMessage: Fixture.assistant("Renamed.", model: nil)) + ] + } + + private var sessions: [Fixture.Session] { + [ + .init(id: sessionID, title: "Notes line count", mainChainID: 10), + .init(id: olderID, workingDirectory: "/Users/example/other", model: "swe-2", + createdAt: 1_767_000_000, lastActivityAt: 1_767_000_060, title: nil, mainChainID: nil), + .init(id: hiddenID, lastActivityAt: 1_767_225_900, title: "Summarize", mainChainID: nil, hidden: 1) + ] + } + + @discardableResult + private func writeStore( + sessions: [Fixture.Session]? = nil, + nodes: [Fixture.Node]? = nil + ) throws -> URL { + try Fixture.write(home: home, sessions: sessions ?? self.sessions, nodes: nodes ?? forest + olderNodes) + } + + private func locator(_ id: String) -> URL { + DevinSessionAdapter.sessionURL(database: Fixture.databaseURL(home: home), sessionID: id) + } + + // MARK: - Discovery + + func testDiscoveryListsVisibleSessionsMostRecentFirst() throws { + let database = try writeStore() + let files = adapter.discoverSessionFiles(homeDirectory: home.path) + XCTAssertEqual(files.map(\.lastPathComponent), [sessionID, olderID]) + XCTAssertEqual(Set(files.map { $0.deletingLastPathComponent().path }), [database.path]) + XCTAssertEqual(adapter.discoverSessions(homeDirectory: home.path).map(\.sessionID), [sessionID, olderID]) + } + + func testDiscoveryToleratesAMissingStore() { + XCTAssertEqual(adapter.discoverSessionFiles(homeDirectory: home.path), []) + } + + func testDiscoveryIgnoresASymlinkedDatabase() throws { + let elsewhere = home.appendingPathComponent("elsewhere", isDirectory: true) + try Fixture.write(home: elsewhere, sessions: sessions, nodes: forest) + let link = Fixture.databaseURL(home: home) + try FileManager.default.createDirectory(at: link.deletingLastPathComponent(), withIntermediateDirectories: true) + try FileManager.default.createSymbolicLink(at: link, withDestinationURL: Fixture.databaseURL(home: elsewhere)) + + XCTAssertEqual(adapter.discoverSessionFiles(homeDirectory: home.path), []) + XCTAssertThrowsError(try adapter.extractMetadata(fileURL: locator(sessionID))) + } + + func testRootIsTheDevinCLIDirectory() { + XCTAssertEqual(adapter.roots(homeDirectory: "/Users/example").map(\.path), + ["/Users/example/.local/share/devin/cli"]) + XCTAssertEqual(DevinSessionAdapter.databaseURL(homeDirectory: "/Users/example").path, + "/Users/example/.local/share/devin/cli/sessions.db") + } + + // MARK: - Metadata + + func testMetadataReadsTheSessionRowAndTheMainChain() throws { + try writeStore() + let summary = try adapter.extractMetadata(fileURL: locator(sessionID)) + + XCTAssertEqual(summary.provider, .devin) + XCTAssertEqual(summary.harness, .devin) + XCTAssertEqual(summary.sessionID, sessionID) + XCTAssertEqual(summary.title, "Notes line count") + XCTAssertEqual(summary.summary, "NOTES.md has one line.") + // The model that generated the latest reply wins over the session's setting. + XCTAssertEqual(summary.model, "swe-2-high-20260102") + XCTAssertEqual(summary.projectDir, "/Users/example/proj") + XCTAssertEqual(summary.createdAt, Date(timeIntervalSince1970: 1_767_225_600)) + XCTAssertEqual(summary.lastActiveAt, Date(timeIntervalSince1970: 1_767_225_630)) + XCTAssertEqual(summary.sourcePath, locator(sessionID).path) + XCTAssertGreaterThan(summary.sizeBytes, 0) + XCTAssertFalse(summary.hasKnownMessageCount) + } + + func testTitleFallsBackToTheFirstTypedPromptAndModelToTheSessionRow() throws { + try writeStore() + let summary = try adapter.extractMetadata(fileURL: locator(olderID)) + XCTAssertEqual(summary.title, "Rename the target") + XCTAssertEqual(summary.summary, "Renamed.") + XCTAssertEqual(summary.model, "swe-2") + XCTAssertEqual(summary.projectDir, "/Users/example/other") + } + + /// A newest reply without a `generation_model` does not borrow an older + /// response's model, which may predate a model switch. + func testTheNewestReplyWithoutAModelFallsBackToTheSessionRow() throws { + let nodes: [Fixture.Node] = [ + .init(sessionID: olderID, nodeID: 0, parentNodeID: nil, chatMessage: Fixture.user("Rename the target")), + .init(sessionID: olderID, nodeID: 1, parentNodeID: 0, + chatMessage: Fixture.assistant("Looking.", model: "swe-1.6")), + .init(sessionID: olderID, nodeID: 2, parentNodeID: 1, chatMessage: Fixture.assistant("Renamed.", model: nil)) + ] + try writeStore(nodes: nodes) + let summary = try adapter.extractMetadata(fileURL: locator(olderID)) + XCTAssertEqual(summary.summary, "Renamed.") + XCTAssertEqual(summary.model, "swe-2") + } + + /// An injected user turn is not the session's first prompt. + func testTitleSkipsInjectedAndSystemTurns() throws { + let nodes: [Fixture.Node] = [ + .init(sessionID: olderID, nodeID: 0, parentNodeID: nil, chatMessage: Fixture.system("You are a coding agent.")), + .init(sessionID: olderID, nodeID: 1, parentNodeID: 0, chatMessage: Fixture.user("context", typed: false)), + .init(sessionID: olderID, nodeID: 2, parentNodeID: 1, chatMessage: Fixture.user("Fix the flaky test")) + ] + try writeStore(sessions: [.init(id: olderID, title: nil, mainChainID: 2)], nodes: nodes) + XCTAssertEqual(try adapter.extractMetadata(fileURL: locator(olderID)).title, "Fix the flaky test") + } + + func testHiddenSessionsAreNotListedAndTheirMetadataIsRefused() throws { + try writeStore() + XCTAssertFalse(adapter.discoverSessionFiles(homeDirectory: home.path).map(\.lastPathComponent).contains(hiddenID)) + XCTAssertThrowsError(try adapter.extractMetadata(fileURL: locator(hiddenID))) { error in + guard case SessionParseError.invalidFormat = error else { return XCTFail("got \(error)") } + } + } + + func testUnknownSessionsAndForeignURLsAreInvalidFormat() throws { + try writeStore() + XCTAssertThrowsError(try adapter.extractMetadata(fileURL: locator("no-such-session"))) { error in + guard case SessionParseError.invalidFormat = error else { return XCTFail("got \(error)") } + } + XCTAssertThrowsError(try adapter.extractMetadata(fileURL: Fixture.databaseURL(home: home))) { error in + guard case SessionParseError.invalidFormat = error else { return XCTFail("got \(error)") } + } + XCTAssertThrowsError(try adapter.parseTranscript(fileURL: locator("no-such-session"))) + } + + func testAMissingDatabaseIsUnreadable() { + XCTAssertThrowsError(try adapter.extractMetadata(fileURL: locator(sessionID))) { error in + guard case SessionParseError.unreadable = error else { return XCTFail("got \(error)") } + } + } + + // MARK: - Transcript + + func testTranscriptFollowsTheMainChainOnly() throws { + try writeStore() + let document = try adapter.parseTranscript(fileURL: locator(sessionID)) + + XCTAssertEqual(document.messages.map(\.role), [.user, .assistant, .tool, .tool, .assistant]) + XCTAssertEqual(document.messages.map(\.text), [ + "Count the lines in NOTES.md", + "I'll read it.", + "[Tool: read]\n{\"path\":\"NOTES.md\"}", + "1|# Notes", + "NOTES.md has one line." + ]) + XCTAssertEqual(document.messages.map(\.seq), [0, 1, 2, 3, 4]) + XCTAssertFalse(document.truncated) + // The message's own stamp, not the row's (re)write time. + let userStamp = try XCTUnwrap(document.messages.first?.timestamp) + XCTAssertEqual(userStamp.timeIntervalSince1970, 1_767_225_610.123, accuracy: 0.001) + XCTAssertFalse(document.messages.contains { $0.text.contains("private reasoning") }) + } + + func testTranscriptRangesSlice() throws { + try writeStore() + let document = try adapter.parseTranscript(fileURL: locator(sessionID), range: 1..<3) + XCTAssertEqual(document.messages.map(\.text), ["I'll read it.", "[Tool: read]\n{\"path\":\"NOTES.md\"}"]) + XCTAssertEqual(document.totalMessageCount, 5) + } + + /// With no `main_chain_id`, the newest node is the tip — Devin's own fallback. + func testMissingMainChainFallsBackToTheNewestNode() throws { + try writeStore() + XCTAssertEqual( + try adapter.parseTranscript(fileURL: locator(olderID)).messages.map(\.text), + ["Rename the target", "Renamed."] + ) + } + + func testAMainChainNamingAMissingNodeFallsBackToo() throws { + try writeStore(sessions: [.init(id: olderID, title: nil, mainChainID: 99)], nodes: olderNodes) + XCTAssertEqual(try adapter.parseTranscript(fileURL: locator(olderID)).messages.count, 2) + } + + func testDanglingParentsAndCyclesEndTheWalk() throws { + let nodes: [Fixture.Node] = [ + .init(sessionID: olderID, nodeID: 1, parentNodeID: 2, chatMessage: Fixture.user("First")), + .init(sessionID: olderID, nodeID: 2, parentNodeID: 1, chatMessage: Fixture.assistant("Second")), + .init(sessionID: olderID, nodeID: 5, parentNodeID: 40, chatMessage: Fixture.user("Orphan")), + .init(sessionID: olderID, nodeID: 6, parentNodeID: 5, chatMessage: Fixture.assistant("Reply")) + ] + try writeStore(sessions: [.init(id: olderID, title: nil, mainChainID: 2)], nodes: nodes) + XCTAssertEqual(try adapter.parseTranscript(fileURL: locator(olderID)).messages.map(\.text), ["First", "Second"]) + + try FileManager.default.removeItem(at: home.appendingPathComponent(".local")) + try writeStore(sessions: [.init(id: olderID, title: nil, mainChainID: 6)], nodes: nodes) + XCTAssertEqual(try adapter.parseTranscript(fileURL: locator(olderID)).messages.map(\.text), ["Orphan", "Reply"]) + } + + func testMalformedMessagesAreSkipped() throws { + let nodes: [Fixture.Node] = [ + .init(sessionID: olderID, nodeID: 0, parentNodeID: nil, chatMessage: Fixture.user("Keep this")), + .init(sessionID: olderID, nodeID: 1, parentNodeID: 0, chatMessage: "{not json"), + .init(sessionID: olderID, nodeID: 2, parentNodeID: 1, chatMessage: "[]"), + .init(sessionID: olderID, nodeID: 3, parentNodeID: 2, chatMessage: Fixture.json(["role": "narrator", "content": "?"])), + .init(sessionID: olderID, nodeID: 4, parentNodeID: 3, chatMessage: Fixture.assistant("And this")) + ] + try writeStore(sessions: [.init(id: olderID, title: nil, mainChainID: 4)], nodes: nodes) + XCTAssertEqual(try adapter.parseTranscript(fileURL: locator(olderID)).messages.map(\.text), ["Keep this", "And this"]) + XCTAssertEqual(try adapter.extractMetadata(fileURL: locator(olderID)).title, "Keep this") + } + + func testToolCallsInEitherSpellingRender() { + XCTAssertEqual( + DevinSessionAdapter.toolCallText(["name": "exec", "arguments": "{\"cmd\":\"ls\"}"]), + "[Tool: exec]\n{\"cmd\":\"ls\"}" + ) + XCTAssertEqual( + DevinSessionAdapter.toolCallText(["function": ["name": "grep", "arguments": ["q": "x"]]]), + "[Tool: grep]\n{\"q\":\"x\"}" + ) + XCTAssertEqual(DevinSessionAdapter.toolCallText([:]), "[Tool: tool]") + } + + // MARK: - Change fingerprint + + func testChangeFingerprintMovesPerSession() throws { + let database = try writeStore() + let first = try XCTUnwrap(adapter.changeFingerprint(fileURL: locator(sessionID))) + let older = try XCTUnwrap(adapter.changeFingerprint(fileURL: locator(olderID))) + XCTAssertEqual(adapter.changeFingerprint(fileURL: locator(sessionID)), first, "stable across reads") + XCTAssertNotEqual(first, older) + + let writer = try Fixture.open(database) + defer { sqlite3_close_v2(writer) } + try Fixture.insert(writer, .init(sessionID: olderID, nodeID: 3, parentNodeID: 2, chatMessage: Fixture.user("More"))) + try Fixture.exec(writer, "UPDATE sessions SET main_chain_id = 3 WHERE id = '\(olderID)'") + + XCTAssertEqual(adapter.changeFingerprint(fileURL: locator(sessionID)), first, "an untouched session does not move") + XCTAssertNotEqual(adapter.changeFingerprint(fileURL: locator(olderID)), older) + + // A title generated after the fact moves it too, with no new node. + try Fixture.exec(writer, "UPDATE sessions SET title = 'Renamed' WHERE id = '\(sessionID)'") + XCTAssertNotEqual(adapter.changeFingerprint(fileURL: locator(sessionID)), first) + XCTAssertNil(adapter.changeFingerprint(fileURL: locator("no-such-session"))) + } + + /// A wrapper that does not forward `changeFingerprint` still indexes Devin + /// sessions, through the store's own fingerprint. + func testTheDefaultFingerprintFallsBackToTheStoreFile() throws { + let database = try writeStore() + let store = try XCTUnwrap(SessionChangeFingerprint.file(at: database)) + XCTAssertEqual(SessionChangeFingerprint.file(at: locator(sessionID)), store) + XCTAssertNil(SessionChangeFingerprint.file(at: home.appendingPathComponent("missing/file.jsonl"))) + } + + // MARK: - Never writing + + /// The CLI's last connection removes `-wal` and `-shm` when it closes + /// cleanly. A read-only connection cannot recreate them, and must not + /// need to: every read works and leaves the directory as it found it. + func testReadsOfAClosedStoreLeaveNoJournalBehind() throws { + let database = try writeStore() + let writer = try Fixture.open(database) + try Fixture.exec(writer, "PRAGMA wal_checkpoint(TRUNCATE)") + sqlite3_close_v2(writer) + for suffix in ["-wal", "-shm"] { + try? FileManager.default.removeItem(atPath: database.path + suffix) + } + let before = try FileManager.default.contentsOfDirectory(atPath: database.deletingLastPathComponent().path) + + XCTAssertEqual(adapter.discoverSessionFiles(homeDirectory: home.path).count, 2) + XCTAssertNotNil(adapter.changeFingerprint(fileURL: locator(sessionID))) + XCTAssertEqual(try adapter.extractMetadata(fileURL: locator(sessionID)).title, "Notes line count") + XCTAssertEqual(try adapter.parseTranscript(fileURL: locator(sessionID)).messages.count, 5) + + let after = try FileManager.default.contentsOfDirectory(atPath: database.deletingLastPathComponent().path) + XCTAssertEqual(after.sorted(), before.sorted()) + XCTAssertEqual(before, ["sessions.db"]) + } + + /// While the CLI holds the store open, committed rows may live only in + /// `-wal`; they are read through it. + func testRowsStillInTheWALAreRead() throws { + let database = try writeStore() + let writer = try Fixture.open(database) + defer { sqlite3_close_v2(writer) } + try Fixture.exec(writer, "PRAGMA wal_autocheckpoint=0") + try Fixture.insert(writer, Fixture.Session(id: "fresh-meadow", lastActivityAt: 1_767_300_000, title: "Brand new")) + + XCTAssertEqual(adapter.discoverSessionFiles(homeDirectory: home.path).first?.lastPathComponent, "fresh-meadow") + XCTAssertEqual(try adapter.extractMetadata(fileURL: locator("fresh-meadow")).title, "Brand new") + XCTAssertEqual(try adapter.parseTranscript(fileURL: locator("fresh-meadow")).messages, []) + } + + // MARK: - The index + + func testTheIndexKeysEverySessionInTheStoreSeparately() async throws { + let database = try writeStore() + let store = try SessionIndexStore(url: home.appendingPathComponent("index/session_index.sqlite3")) + let service = SessionIndexService( + homeDirectory: home.path, + store: store, + registry: SessionProviderRegistry(adapters: [adapter]), + bodyIndexing: { true } + ) + + await service.refreshIndex() + var summaries = try await store.allSummaries() + XCTAssertEqual(Set(summaries.map(\.sessionID)), [sessionID, olderID]) + XCTAssertEqual(summaries.first { $0.sessionID == sessionID }?.harness, .devin) + let hits = try await service.search("one line", providers: [.devin]) + XCTAssertEqual(hits.map(\.summary.sessionID), [sessionID]) + + let writer = try Fixture.open(database) + try Fixture.exec(writer, "UPDATE sessions SET title = 'Renamed later' WHERE id = '\(olderID)'") + sqlite3_close_v2(writer) + + await service.refreshIndex() + summaries = try await store.allSummaries() + XCTAssertEqual(summaries.first { $0.sessionID == olderID }?.title, "Renamed later") + XCTAssertEqual(summaries.first { $0.sessionID == sessionID }?.title, "Notes line count") + } + + // MARK: - Deletion, registry, resume + + func testDeletionIsRefused() throws { + try writeStore() + let summary = try adapter.extractMetadata(fileURL: locator(sessionID)) + XCTAssertFalse(SessionProvider.devin.supportsDeletion) + XCTAssertThrowsError(try adapter.deletionPlan(for: summary, homeDirectory: home.path)) { error in + XCTAssertEqual(error as? SessionDeleteError, .providerIsReadOnly(.devin)) + } + } + + func testStandardRegistryCarriesTheAdapter() { + let registry = SessionProviderRegistry.standard(homeDirectory: "/Users/example") + XCTAssertTrue(registry.adapter(for: .devin) is DevinSessionAdapter) + XCTAssertEqual(SessionProvider.devin.defaultHarness, .devin) + } + + func testResumeCommand() throws { + XCTAssertEqual( + try SessionResumeCommandBuilder.command(provider: .devin, sessionID: sessionID), + "devin --resume \(sessionID)" + ) + } +} diff --git a/implementations/swift/Tests/AgentSessionKitTests/HarnessNamingTests.swift b/implementations/swift/Tests/AgentSessionKitTests/HarnessNamingTests.swift index 42ffc59..2b49eaf 100644 --- a/implementations/swift/Tests/AgentSessionKitTests/HarnessNamingTests.swift +++ b/implementations/swift/Tests/AgentSessionKitTests/HarnessNamingTests.swift @@ -17,7 +17,9 @@ final class HarnessNamingTests: XCTestCase { "Grok Build", "Cursor", "Grok Bot", - "Muse Code" + "Muse Code", + "Devin", + "Mistral Vibe" ] ) } @@ -38,7 +40,8 @@ final class HarnessNamingTests: XCTestCase { Harness.allCases.map(\.rawValue), [ "codex", "chatgptWork", "claudeCode", "claudeCowork", - "geminiCLI", "antigravity", "grokBuild", "cursor", "grokBot", "museCode" + "geminiCLI", "antigravity", "grokBuild", "cursor", "grokBot", "museCode", + "devin", "mistralVibe" ] ) } @@ -49,7 +52,7 @@ final class HarnessNamingTests: XCTestCase { XCTAssertEqual( SessionProvider.allCases.map(\.defaultHarness), [.claudeCode, .claudeCowork, .codex, .grokBuild, .cursor, .geminiCLI, - .antigravity, .grokBot, .museCode] + .antigravity, .grokBot, .museCode, .devin, .mistralVibe] ) XCTAssertEqual( Set(Harness.allCases).subtracting(SessionProvider.allCases.map(\.defaultHarness)), @@ -66,6 +69,16 @@ final class HarnessNamingTests: XCTestCase { XCTAssertEqual(SessionProvider.grokBot.displayName, HarnessCatalog.grokBot) XCTAssertEqual(SessionProvider.muse.displayName, HarnessCatalog.museCode) XCTAssertEqual(Harness.museCode.displayName, "Muse Code") + XCTAssertEqual(SessionProvider.devin.displayName, HarnessCatalog.devin) + XCTAssertEqual(Harness.devin.displayName, "Devin") + XCTAssertEqual(SessionProvider.mistralVibe.displayName, HarnessCatalog.mistralVibe) + XCTAssertEqual(Harness.mistralVibe.displayName, "Mistral Vibe") + } + + /// The provider raw values are storage keys too. + func testNewProviderRawValuesAreStable() { + XCTAssertEqual(SessionProvider.devin.rawValue, "devin") + XCTAssertEqual(SessionProvider.mistralVibe.rawValue, "mistralVibe") } /// Grok Build and Grok Bot share a company and nothing else: one is a diff --git a/implementations/swift/Tests/AgentSessionKitTests/JSONLHeadTailTests.swift b/implementations/swift/Tests/AgentSessionKitTests/JSONLHeadTailTests.swift index de50844..fd9fc35 100644 --- a/implementations/swift/Tests/AgentSessionKitTests/JSONLHeadTailTests.swift +++ b/implementations/swift/Tests/AgentSessionKitTests/JSONLHeadTailTests.swift @@ -46,6 +46,22 @@ final class JSONLHeadTailTests: XCTestCase { XCTAssertEqual(text(JSONLHeadTail.headLines(url: url, count: 5)).count, 2) } + /// A byte budget stops the head read at the last whole line inside it, + /// however long the next record runs. + func testHeadByteBudgetBoundsAnOversizedRecord() throws { + let huge = "{\"blob\":\"" + String(repeating: "x", count: 200_000) + "\"}" + let url = try write(["{\"i\":0}", huge, "{\"i\":2}"]) + XCTAssertEqual(text(JSONLHeadTail.headLines(url: url, count: 3, maxBytes: 32 * 1024)), ["{\"i\":0}"]) + + let unterminated = directory.appendingPathComponent("unterminated.jsonl") + try String(repeating: "y", count: 100_000).write(to: unterminated, atomically: true, encoding: .utf8) + XCTAssertEqual(JSONLHeadTail.headLines(url: unterminated, count: 1, maxBytes: 20_000), []) + XCTAssertEqual(JSONLHeadTail.headLines(url: unterminated, count: 1).first?.count, 100_000) + + let small = try write(["{\"i\":0}", "{\"i\":1}"], name: "small.jsonl") + XCTAssertEqual(text(JSONLHeadTail.headLines(url: small, count: 5, maxBytes: 1 << 20)).count, 2) + } + func testLargeFileTailSeeksAndDropsThePartialFirstLine() throws { // ~100 KB: well past the 16 KB whole-read threshold, so the tail // window opens in the middle of a line. diff --git a/implementations/swift/Tests/AgentSessionKitTests/MistralVibeSessionAdapterTests.swift b/implementations/swift/Tests/AgentSessionKitTests/MistralVibeSessionAdapterTests.swift new file mode 100644 index 0000000..ffa94e5 --- /dev/null +++ b/implementations/swift/Tests/AgentSessionKitTests/MistralVibeSessionAdapterTests.swift @@ -0,0 +1,289 @@ +import XCTest +@testable import AgentSessionKit + +final class MistralVibeSessionAdapterTests: XCTestCase { + private var home: URL! + private let adapter = MistralVibeSessionAdapter() + private let sessionID = "5c0ffee1-7a1b-4c2d-8e3f-0123456789ab" + private var directoryName: String { "session_20260101_000000_5c0ffee1" } + + override func setUpWithError() throws { + home = FileManager.default.temporaryDirectory + .appendingPathComponent("ASKMistralVibeSessionTests-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: home, withIntermediateDirectories: true) + } + + override func tearDownWithError() throws { + try? FileManager.default.removeItem(at: home) + } + + // MARK: - Fixtures + + private var root: URL { home.appendingPathComponent(".vibe/logs/session", isDirectory: true) } + + /// `meta.json` shaped like `vibe` 2.x writes it, minus the bulk. + private func meta( + id: String? = nil, + title: Any = NSNull(), + activeModel: String = "mistral-medium-3.5", + totalMessages: Int = 5 + ) -> [String: Any] { + [ + "session_id": id ?? sessionID, + "parent_session_id": NSNull(), + "start_time": "2026-01-01T00:00:00.250000+00:00", + "end_time": "2026-01-01T00:03:30.500000+00:00", + "git_commit": NSNull(), + "git_branch": NSNull(), + "environment": ["working_directory": "/Users/example/proj"], + "origin_directory": "/Users/example/proj", + "username": "example", + "child_sessions": [], + "loops": [], + "title": title, + "title_source": "auto", + "config": [ + "active_model": activeModel, + "models": [ + "mistral-medium-3.5": [ + "name": "mistral-vibe-cli-latest", "provider": "mistral", "alias": "mistral-medium-3.5", + "input_price": 1.5, "output_price": 7.5 + ], + "local": ["name": "devstral", "provider": "llamacpp", "alias": "local"] + ] + ], + "stats": ["session_prompt_tokens": 1_200, "session_completion_tokens": 80, "session_cost": 0.0024], + "total_messages": totalMessages, + "system_prompt": ["role": "system", "content": "You are Vibe."] + ] + } + + private var conversation: [[String: Any]] { + [ + ["role": "user", "content": "Count the lines in NOTES.md", "injected": false, "message_id": "m-1"], + [ + "role": "assistant", "content": "I'll read it.", "injected": false, "message_id": "m-2", + "reasoning_content": "private reasoning", + "tool_calls": [[ + "id": "call_1", "index": 0, "type": "function", + "function": ["name": "read_file", "arguments": "{\"path\":\"NOTES.md\"}"] + ]] + ], + [ + "role": "tool", "content": "1|# Notes", "injected": false, "name": "read_file", + "tool_call_id": "call_1", "tool_result": ["output": ["content": "1|# Notes"], "cancelled": false] + ], + [ + "role": "user", "content": "Summary of the conversation so far", "injected": true, + "context_boundary": "compaction" + ], + [ + "role": "assistant", + "content": [["type": "text", "text": "NOTES.md has one line."]], + "injected": false, "message_id": "m-3" + ] + ] + } + + @discardableResult + private func writeSession( + meta: [String: Any]? = nil, + messages: [[String: Any]]? = nil, + rawLines: [String] = [], + directoryName: String? = nil, + under parent: URL? = nil + ) throws -> URL { + let directory = (parent ?? root).appendingPathComponent(directoryName ?? self.directoryName, isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + let metaData = try JSONSerialization.data(withJSONObject: meta ?? self.meta(), options: [.prettyPrinted]) + try metaData.write(to: directory.appendingPathComponent("meta.json")) + var lines = try (messages ?? conversation).map { + String(decoding: try JSONSerialization.data(withJSONObject: $0, options: [.sortedKeys]), as: UTF8.self) + } + lines.insert(contentsOf: rawLines, at: min(1, lines.count)) + let url = directory.appendingPathComponent("messages.jsonl") + let text = lines.isEmpty ? "" : lines.joined(separator: "\n") + "\n" + try text.write(to: url, atomically: true, encoding: .utf8) + return url + } + + // MARK: - Discovery + + /// A sub-agent's session nests under its parent's `agents/`; lease files + /// and Vibe's own listing cache sit beside the sessions. None is a row. + func testDiscoveryListsTopLevelSessionsOnly() throws { + let parent = try writeSession() + try writeSession( + meta: meta(id: "a8234fe2-23ee-4cc5-8e81-8908fe89b18d"), + directoryName: "explore_20260101_000100_a8234fe2", + under: parent.deletingLastPathComponent().appendingPathComponent("agents", isDirectory: true) + ) + try FileManager.default.createDirectory(at: root.appendingPathComponent("active"), withIntermediateDirectories: true) + try Data().write(to: root.appendingPathComponent("active/\(sessionID).lock")) + try Data("{}".utf8).write(to: root.appendingPathComponent(".session_index.json")) + // A directory with no meta.json is an interrupted first save, not a session. + let orphan = root.appendingPathComponent("session_20260101_000200_ffffffff", isDirectory: true) + try FileManager.default.createDirectory(at: orphan, withIntermediateDirectories: true) + try Data().write(to: orphan.appendingPathComponent("messages.jsonl")) + + let files = adapter.discoverSessionFiles(homeDirectory: home.path) + XCTAssertEqual(files.map { $0.resolvingSymlinksInPath().path }, [parent.resolvingSymlinksInPath().path]) + XCTAssertEqual(adapter.discoverSessions(homeDirectory: home.path).map(\.sessionID), [sessionID]) + } + + func testDiscoveryToleratesAMissingRootAndSkipsSymlinks() throws { + XCTAssertEqual(adapter.discoverSessionFiles(homeDirectory: home.path), []) + + let elsewhere = home.appendingPathComponent("elsewhere", isDirectory: true) + let real = try writeSession(under: elsewhere) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + try FileManager.default.createSymbolicLink( + at: root.appendingPathComponent(directoryName), + withDestinationURL: real.deletingLastPathComponent() + ) + XCTAssertEqual(adapter.discoverSessionFiles(homeDirectory: home.path), []) + } + + func testRootIsTheVibeSessionLogDirectory() { + XCTAssertEqual(adapter.roots(homeDirectory: "/Users/example").map(\.path), + ["/Users/example/.vibe/logs/session"]) + } + + // MARK: - Metadata + + func testMetadataReadsMetaAndTheLog() throws { + let url = try writeSession() + let summary = try adapter.extractMetadata(fileURL: url) + + XCTAssertEqual(summary.provider, .mistralVibe) + XCTAssertEqual(summary.harness, .mistralVibe) + XCTAssertEqual(summary.sessionID, sessionID) + // The alias resolves to the model id the session actually calls. + XCTAssertEqual(summary.model, "mistral-vibe-cli-latest") + // No title recorded: the first prompt a person typed. + XCTAssertEqual(summary.title, "Count the lines in NOTES.md") + XCTAssertEqual(summary.summary, "NOTES.md has one line.") + XCTAssertEqual(summary.projectDir, "/Users/example/proj") + let created = try XCTUnwrap(summary.createdAt) + XCTAssertEqual(created.timeIntervalSince1970, 1_767_225_600.25, accuracy: 0.001) + let active = try XCTUnwrap(summary.lastActiveAt) + XCTAssertEqual(active.timeIntervalSince1970, 1_767_225_810.5, accuracy: 0.001) + XCTAssertEqual(summary.sourcePath, url.path) + XCTAssertEqual(summary.sizeBytes, SessionParsing.fileSize(url)) + XCTAssertFalse(summary.hasKnownMessageCount) + } + + func testARecordedTitleWins() throws { + let url = try writeSession(meta: meta(title: "Notes line count")) + XCTAssertEqual(try adapter.extractMetadata(fileURL: url).title, "Notes line count") + } + + func testAnInjectedFirstLineIsNotTheTitle() throws { + var messages = conversation + messages.insert(["role": "user", "content": "resumed", "injected": true], at: 0) + let url = try writeSession(messages: messages) + XCTAssertEqual(try adapter.extractMetadata(fileURL: url).title, "Count the lines in NOTES.md") + } + + /// An alias with no entry in `models` is not guessed at. + func testAnUnknownAliasYieldsNoModel() throws { + XCTAssertNil(try adapter.extractMetadata(fileURL: try writeSession(meta: meta(activeModel: "retired"))).model) + XCTAssertEqual( + MistralVibeSessionAdapter.model(in: [ + "active_model": "fast", + "models": [["alias": "fast", "name": "mistral-small-latest"]] + ]), + "mistral-small-latest" + ) + } + + func testADirectoryNamedAfterAnotherSessionIsRejected() throws { + let url = try writeSession(directoryName: "session_20260101_000000_ffffffff") + XCTAssertThrowsError(try adapter.extractMetadata(fileURL: url)) { error in + guard case SessionParseError.invalidFormat = error else { return XCTFail("got \(error)") } + } + } + + func testMissingOrMalformedMetaIsUnreadable() throws { + let url = try writeSession() + try Data("{not json".utf8).write(to: url.deletingLastPathComponent().appendingPathComponent("meta.json")) + XCTAssertThrowsError(try adapter.extractMetadata(fileURL: url)) { error in + guard case SessionParseError.unreadable = error else { return XCTFail("got \(error)") } + } + try FileManager.default.removeItem(at: url.deletingLastPathComponent().appendingPathComponent("meta.json")) + XCTAssertThrowsError(try adapter.extractMetadata(fileURL: url)) + } + + /// Vibe refuses an empty log unless the session recorded no messages. + func testAnEmptyLogListsOnlyWhenMetaSaysItIsEmpty() throws { + let interrupted = try writeSession(messages: []) + XCTAssertThrowsError(try adapter.extractMetadata(fileURL: interrupted)) + + let rewound = try writeSession(meta: meta(totalMessages: 0), messages: []) + XCTAssertEqual(try adapter.extractMetadata(fileURL: rewound).sessionID, sessionID) + XCTAssertEqual(try adapter.parseTranscript(fileURL: rewound).messages, []) + } + + // MARK: - Transcript + + func testTranscriptKeepsTurnsAndToolRoundTripsAndDropsInjectedLines() throws { + let document = try adapter.parseTranscript(fileURL: try writeSession()) + + XCTAssertEqual(document.messages.map(\.role), [.user, .assistant, .tool, .tool, .assistant]) + XCTAssertEqual(document.messages.map(\.text), [ + "Count the lines in NOTES.md", + "I'll read it.", + "[Tool: read_file]\n{\"path\":\"NOTES.md\"}", + "1|# Notes", + "NOTES.md has one line." + ]) + XCTAssertEqual(document.messages.map(\.seq), [0, 1, 2, 3, 4]) + XCTAssertTrue(document.messages.allSatisfy { $0.timestamp == nil }) + XCTAssertFalse(document.messages.contains { $0.text.contains("private reasoning") }) + } + + func testMalformedLinesAreSkipped() throws { + let url = try writeSession(rawLines: ["{not json", "[]", "\"text\""]) + XCTAssertEqual(try adapter.parseTranscript(fileURL: url).messages.count, 5) + XCTAssertEqual(try adapter.extractMetadata(fileURL: url).sessionID, sessionID) + } + + // MARK: - Change fingerprint + + /// A rename rewrites only `meta.json`; the row still has to refresh. + func testChangeFingerprintFoldsInMeta() throws { + let url = try writeSession() + let before = try XCTUnwrap(adapter.changeFingerprint(fileURL: url)) + XCTAssertEqual(before.size, + SessionParsing.fileSize(url) + SessionParsing.fileSize(url.deletingLastPathComponent() + .appendingPathComponent("meta.json"))) + + let metaData = try JSONSerialization.data(withJSONObject: meta(title: "A much longer manual title"), options: []) + try metaData.write(to: url.deletingLastPathComponent().appendingPathComponent("meta.json")) + XCTAssertNotEqual(adapter.changeFingerprint(fileURL: url), before) + XCTAssertNil(adapter.changeFingerprint(fileURL: root.appendingPathComponent("gone/messages.jsonl"))) + } + + // MARK: - Deletion, registry, resume + + func testDeletionIsRefused() throws { + let summary = try adapter.extractMetadata(fileURL: try writeSession()) + XCTAssertFalse(SessionProvider.mistralVibe.supportsDeletion) + XCTAssertThrowsError(try adapter.deletionPlan(for: summary, homeDirectory: home.path)) { error in + XCTAssertEqual(error as? SessionDeleteError, .providerIsReadOnly(.mistralVibe)) + } + } + + func testStandardRegistryCarriesTheAdapter() { + let registry = SessionProviderRegistry.standard(homeDirectory: "/Users/example") + XCTAssertTrue(registry.adapter(for: .mistralVibe) is MistralVibeSessionAdapter) + XCTAssertEqual(SessionProvider.mistralVibe.defaultHarness, .mistralVibe) + } + + func testResumeCommand() throws { + XCTAssertEqual( + try SessionResumeCommandBuilder.command(provider: .mistralVibe, sessionID: sessionID), + "vibe --resume \(sessionID)" + ) + } +} diff --git a/implementations/swift/Tests/AgentSessionKitTests/SessionDeleterTests.swift b/implementations/swift/Tests/AgentSessionKitTests/SessionDeleterTests.swift index 83fd3fb..1819bd3 100644 --- a/implementations/swift/Tests/AgentSessionKitTests/SessionDeleterTests.swift +++ b/implementations/swift/Tests/AgentSessionKitTests/SessionDeleterTests.swift @@ -166,7 +166,7 @@ final class SessionDeleterTests: XCTestCase { XCTAssertTrue(exists(url)) } - /// The four providers whose stores belong to another running app — or, for + /// The providers whose stores belong to another running app — or, for /// Grok Bot, to a server — never reach the containment fence at all: their /// adapters refuse first, and each says which app to delete from instead. func testProvidersWhoseStoreAnotherAppOwnsAreRefused() { @@ -176,7 +176,9 @@ final class SessionDeleterTests: XCTestCase { .claudeCowork: "Library/Application Support/Claude/local-agent-mode-sessions/" + "space/x/local_\(sessionID)/.claude/projects/-Users-example-proj/\(sessionID).jsonl", .grokBot: "Library/Application Support/Grok Bot/sand-client-persistence/\(sessionID).blob", - .muse: ".local/share/muse/sessions/2026/01/01/\(sessionID)/session.jsonl" + .muse: ".local/share/muse/sessions/2026/01/01/\(sessionID)/session.jsonl", + .devin: ".local/share/devin/cli/sessions.db/\(sessionID)", + .mistralVibe: ".vibe/logs/session/session_20260101_000000_\(sessionID.prefix(8))/messages.jsonl" ] for (provider, path) in paths { XCTAssertFalse(provider.supportsDeletion, "\(provider) must not be deletable") @@ -200,7 +202,7 @@ final class SessionDeleterTests: XCTestCase { } XCTAssertEqual( Set(SessionProvider.allCases.filter { !$0.supportsDeletion }), - [.antigravity, .cursor, .claudeCowork, .grokBot, .muse] + [.antigravity, .cursor, .claudeCowork, .grokBot, .muse, .devin, .mistralVibe] ) } diff --git a/implementations/swift/Tests/AgentSessionKitTests/SessionResumeCommandBuilderTests.swift b/implementations/swift/Tests/AgentSessionKitTests/SessionResumeCommandBuilderTests.swift index 75d349f..6dc3822 100644 --- a/implementations/swift/Tests/AgentSessionKitTests/SessionResumeCommandBuilderTests.swift +++ b/implementations/swift/Tests/AgentSessionKitTests/SessionResumeCommandBuilderTests.swift @@ -17,6 +17,21 @@ final class SessionResumeCommandBuilderTests: XCTestCase { "gemini --resume session-2026-01-01T00-00-abc") XCTAssertEqual(try SessionResumeCommandBuilder.command(provider: .muse, sessionID: uuid), "muse resume \(uuid)") + XCTAssertEqual(try SessionResumeCommandBuilder.command(provider: .devin, sessionID: "quiet-harbor"), + "devin --resume quiet-harbor") + XCTAssertEqual(try SessionResumeCommandBuilder.command(provider: .mistralVibe, sessionID: uuid), + "vibe --resume \(uuid)") + } + + /// Devin names sessions with a word pair, not a UUID; Mistral Vibe's ids + /// are UUID-shaped hex. Each is held to its own charset. + func testDevinAndMistralVibeIDCharsets() { + assertThrows(.invalidSessionID) { + try SessionResumeCommandBuilder.command(provider: .mistralVibe, sessionID: "quiet-harbor") + } + assertThrows(.invalidSessionID) { + try SessionResumeCommandBuilder.command(provider: .devin, sessionID: "quiet harbor; rm -rf /") + } } func testAntigravityNeedsTheCLIVariant() throws { @@ -54,7 +69,9 @@ final class SessionResumeCommandBuilderTests: XCTestCase { /// Every provider either builds a command or refuses for a stated /// reason — a new case must not fall through to a wrong CLI. func testEveryProviderIsAccountedFor() { - let resumable: Set = [.claude, .codex, .grok, .gemini, .antigravity, .muse] + let resumable: Set = [ + .claude, .codex, .grok, .gemini, .antigravity, .muse, .devin, .mistralVibe + ] for provider in SessionProvider.allCases { let command = try? SessionResumeCommandBuilder.command( provider: provider, sessionID: uuid, variant: "cli" diff --git a/implementations/swift/Tests/AgentSessionLiveTests/SessionResumeTests.swift b/implementations/swift/Tests/AgentSessionLiveTests/SessionResumeTests.swift index 243bbd0..b075a6a 100644 --- a/implementations/swift/Tests/AgentSessionLiveTests/SessionResumeTests.swift +++ b/implementations/swift/Tests/AgentSessionLiveTests/SessionResumeTests.swift @@ -27,6 +27,9 @@ struct SessionResumeTests { (.chatgptWork, "codex resume 11111111-2222-3333-4444-555555555555"), (.grokBuild, "grok --resume 11111111-2222-3333-4444-555555555555"), (.geminiCLI, "gemini --resume 11111111-2222-3333-4444-555555555555"), + (.museCode, "muse resume 11111111-2222-3333-4444-555555555555"), + (.devin, "devin --resume 11111111-2222-3333-4444-555555555555"), + (.mistralVibe, "vibe --resume 11111111-2222-3333-4444-555555555555"), ]) func commandPerHarness(_ harness: Harness, _ expected: String) { #expect(SessionResume.availability(for: Self.identity(harness)).command == expected) @@ -114,6 +117,8 @@ struct SessionResumeTests { #expect(Harness.cursor.sessionProvider == .cursor) #expect(Harness.grokBot.sessionProvider == .grokBot) #expect(Harness.museCode.sessionProvider == .muse) + #expect(Harness.devin.sessionProvider == .devin) + #expect(Harness.mistralVibe.sessionProvider == .mistralVibe) } @Test("the mapping is the inverse of the provider's default harness")