diff --git a/Sources/AgentSyncApp/AppModel.swift b/Sources/AgentSyncApp/AppModel.swift index c079640..901614e 100644 --- a/Sources/AgentSyncApp/AppModel.swift +++ b/Sources/AgentSyncApp/AppModel.swift @@ -39,11 +39,20 @@ enum Prefs { } static var codexTargetModel: String { - UserDefaults.standard.string(forKey: codexTargetModelKey) ?? production.defaultCodexModel + nonBlank(UserDefaults.standard.string(forKey: codexTargetModelKey)) ?? production.defaultCodexModel } static var claudeTargetModel: String { - UserDefaults.standard.string(forKey: claudeTargetModelKey) ?? production.defaultClaudeModel + nonBlank(UserDefaults.standard.string(forKey: claudeTargetModelKey)) ?? production.defaultClaudeModel + } + + /// A cleared Settings field stores "", which must fall back to the default + /// rather than become the target model. + private static func nonBlank(_ value: String?) -> String? { + guard let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines), !trimmed.isEmpty else { + return nil + } + return trimmed } static var preferredTerminal: TerminalApp { @@ -331,6 +340,18 @@ final class AppModel: ObservableObject { private var activationObserver: NSObjectProtocol? private var titleCache: [String: String] = [:] private var titleGenerationInFlight: Set = [] + /// Sessions the on-device model declined to title this launch (too short, + /// guardrails, timeout). Skipped so they don't block the ones behind them. + private var titleGenerationFailed: Set = [] + /// A refresh requested while a scan or conversion is running (e.g. after a + /// Settings path change) runs once that work lands. + private var refreshQueued = false + /// Bumped whenever the continue flow is abandoned, so a conversion that + /// finishes afterwards doesn't resurrect a stale review page. + private var conversionGeneration = 0 + /// The picker whose continue flow started the current conversion. Each + /// hotkey panel is a fresh picker, so only its own close abandons work. + private var conversionOwner: UUID? private let isDemo: Bool /// Demo instance for README screenshots: no scanning, timers, or hotkey, so @@ -402,9 +423,11 @@ final class AppModel: ObservableObject { guard !isDemo else { return } - guard !isRefreshing, launchingID == nil else { + guard launchingID == nil, !isRefreshing else { + refreshQueued = true return } + refreshQueued = false isRefreshing = true let configuration = Prefs.configuration() @@ -433,9 +456,12 @@ final class AppModel: ObservableObject { self.pruneTitleCache(keeping: decorated) self.generateMissingTitles() case .failure(let error): - self.status = .error(String(describing: error)) + if self.launchingID == nil { + self.setStatus(.error(String(describing: error))) + } } self.isRefreshing = false + self.runQueuedRefresh() } } } @@ -447,7 +473,11 @@ final class AppModel: ObservableObject { return } let candidates = sessions - .filter { $0.wantsRefinedTitle && !titleGenerationInFlight.contains($0.id) } + .filter { + $0.wantsRefinedTitle + && !titleGenerationInFlight.contains($0.id) + && !titleGenerationFailed.contains($0.id) + } .prefix(3) for item in candidates { titleGenerationInFlight.insert(item.id) @@ -457,6 +487,8 @@ final class AppModel: ObservableObject { await MainActor.run { self.titleGenerationInFlight.remove(item.id) guard let title else { + self.titleGenerationFailed.insert(item.id) + self.generateMissingTitles() return } guard let index = self.sessions.firstIndex(where: { $0.id == item.id }), @@ -502,15 +534,18 @@ final class AppModel: ObservableObject { } } - func prepareResume(_ item: SessionItem, target: AgentKind, mode: ResumeMode) { + func prepareResume(_ item: SessionItem, target: AgentKind, mode: ResumeMode, owner: UUID) { guard launchingID == nil, target != item.preview.provider else { return } + conversionOwner = owner let terminal = Prefs.preferredTerminal let codexDestination = Prefs.codexLaunchDestination let claudeDestination = Prefs.claudeLaunchDestination let opencodeDestination = Prefs.opencodeLaunchDestination launchingID = item.id + conversionGeneration += 1 + let generation = conversionGeneration setStatus(.working("Converting for \(target.displayName)…")) Prefs.setPrimaryTarget(target, for: item.preview.provider) let configuration = Prefs.configuration() @@ -557,7 +592,9 @@ final class AppModel: ObservableObject { await MainActor.run { switch result { case .success(let prepared): - self.preparedConversion = prepared + if generation == self.conversionGeneration { + self.preparedConversion = prepared + } self.setStatus(.idle) case .failure(let error): self.setStatus(.error(shortErrorText(error))) @@ -567,6 +604,7 @@ final class AppModel: ObservableObject { } } self.launchingID = nil + self.runQueuedRefresh() } } } @@ -611,9 +649,26 @@ final class AppModel: ObservableObject { } func clearPreparedConversion() { + conversionGeneration += 1 preparedConversion = nil } + /// Drops the prepared (or in-flight) conversion only if `owner`'s picker + /// started it, so closing one picker never discards another's work. + func abandonConversion(ownedBy owner: UUID) { + guard conversionOwner == owner else { + return + } + conversionOwner = nil + clearPreparedConversion() + } + + private func runQueuedRefresh() { + if refreshQueued { + refresh() + } + } + func clearStatus() { setStatus(.idle) } diff --git a/Sources/AgentSyncApp/ContinueView.swift b/Sources/AgentSyncApp/ContinueView.swift index 5a23b63..c2914e9 100644 --- a/Sources/AgentSyncApp/ContinueView.swift +++ b/Sources/AgentSyncApp/ContinueView.swift @@ -6,14 +6,16 @@ import SwiftUI struct ContinueView: View { @ObservedObject var model: AppModel var item: SessionItem + var ownerID: UUID var onDismiss: () -> Void @State private var target: AgentKind @State private var mode: ResumeMode - init(model: AppModel, item: SessionItem, onDismiss: @escaping () -> Void) { + init(model: AppModel, item: SessionItem, ownerID: UUID = UUID(), onDismiss: @escaping () -> Void) { self.model = model self.item = item + self.ownerID = ownerID self.onDismiss = onDismiss let initialTarget = item.primaryTarget _target = State(initialValue: initialTarget) @@ -104,7 +106,7 @@ struct ContinueView: View { Spacer() Button { - model.prepareResume(item, target: target, mode: mode) + model.prepareResume(item, target: target, mode: mode, owner: ownerID) } label: { HStack(spacing: 5) { if model.launchingID == item.id { diff --git a/Sources/AgentSyncApp/QuickPicker.swift b/Sources/AgentSyncApp/QuickPicker.swift index 64fa3fd..a59627f 100644 --- a/Sources/AgentSyncApp/QuickPicker.swift +++ b/Sources/AgentSyncApp/QuickPicker.swift @@ -67,7 +67,8 @@ enum QuickPicker { panel = nil return } - let hosting = NSHostingController(rootView: SessionPickerView(model: model)) + let ownerID = UUID() + let hosting = NSHostingController(rootView: SessionPickerView(model: model, ownerID: ownerID)) let newPanel = KeyablePanel( contentRect: NSRect(x: 0, y: 0, width: 480, height: 200), styleMask: [.nonactivatingPanel, .titled, .fullSizeContentView], @@ -86,6 +87,9 @@ enum QuickPicker { newPanel.isReleasedWhenClosed = false newPanel.onDismiss = { panel = nil + // This panel's picker is gone for good; its review page must not + // reappear in the next one. + model.abandonConversion(ownedBy: ownerID) } // Spotlight placement: horizontally centered, upper third. diff --git a/Sources/AgentSyncApp/SessionPickerView.swift b/Sources/AgentSyncApp/SessionPickerView.swift index 788ba38..56d4243 100644 --- a/Sources/AgentSyncApp/SessionPickerView.swift +++ b/Sources/AgentSyncApp/SessionPickerView.swift @@ -3,6 +3,8 @@ import SwiftUI struct SessionPickerView: View { @ObservedObject var model: AppModel + /// Identifies this picker's continue flow to the shared model. + var ownerID = UUID() @State private var query = "" @State private var selectedItem: SessionItem? @@ -31,11 +33,11 @@ struct SessionPickerView: View { var body: some View { VStack(spacing: 0) { if let item = selectedItem { - ContinueView(model: model, item: item) { + ContinueView(model: model, item: item, ownerID: ownerID) { // Leaving the continue flow abandons any prepared // conversion; otherwise reopening the same session would // restore (and offer to launch) stale choices. - model.clearPreparedConversion() + model.abandonConversion(ownedBy: ownerID) selectedItem = nil } } else { diff --git a/Sources/AgentSyncCLI/CLI.swift b/Sources/AgentSyncCLI/CLI.swift index 4b35c4a..a2390ae 100644 --- a/Sources/AgentSyncCLI/CLI.swift +++ b/Sources/AgentSyncCLI/CLI.swift @@ -33,8 +33,8 @@ struct AgentSyncCLI { let path = value(after: "--path", in: args) else { throw AgentSyncError.invalidArguments("prepare-resume requires --provider claude|codex and --path FILE plus the home/state flags") } - let mode = value(after: "--mode", in: args).flatMap(ResumeMode.init(rawValue:)) ?? .auto - let target = value(after: "--target", in: args).flatMap(AgentKind.init(rawValue:)) + let mode = try parsed(after: "--mode", in: args, as: ResumeMode.init(rawValue:)) ?? .auto + let target = try parsed(after: "--target", in: args, as: AgentKind.init(rawValue:)) let ticket = try SyncEngine(configuration: config).prepareResume( provider: provider, sourcePath: provider == .opencode ? path : NSString(string: path).expandingTildeInPath, @@ -151,8 +151,8 @@ struct AgentSyncCLI { stateDirectory: URL(fileURLWithPath: NSString(string: stateDir).expandingTildeInPath, isDirectory: true), defaultCodexModel: value(after: "--codex-model", in: args) ?? "gpt-5.5", defaultClaudeModel: value(after: "--claude-model", in: args) ?? "claude-sonnet-5", - sessionLookbackDays: intValue(after: "--lookback-days", in: args) ?? 14, - maximumImportedSessions: intValue(after: "--max-sessions", in: args) ?? 3 + sessionLookbackDays: try intValue(after: "--lookback-days", in: args) ?? 14, + maximumImportedSessions: try intValue(after: "--max-sessions", in: args) ?? 3 ) } @@ -163,8 +163,23 @@ struct AgentSyncCLI { return args[index + 1] } - private static func intValue(after flag: String, in args: [String]) -> Int? { - value(after: flag, in: args).flatMap(Int.init) + private static func intValue(after flag: String, in args: [String]) throws -> Int? { + try parsed(after: flag, in: args, as: Int.init) + } + + /// Nil when the flag is absent; throws when it is present but unparsable, + /// so a typo like `--mode ful` fails instead of silently using the default. + private static func parsed(after flag: String, in args: [String], as parse: (String) -> T?) throws -> T? { + guard args.contains(flag) else { + return nil + } + guard let raw = value(after: flag, in: args) else { + throw AgentSyncError.invalidArguments("missing value for \(flag)") + } + guard let parsedValue = parse(raw) else { + throw AgentSyncError.invalidArguments("invalid value for \(flag): \(raw)") + } + return parsedValue } private static func printUsage() { diff --git a/Sources/AgentSyncCore/CodexThreadIndex.swift b/Sources/AgentSyncCore/CodexThreadIndex.swift index bf3be07..2775011 100644 --- a/Sources/AgentSyncCore/CodexThreadIndex.swift +++ b/Sources/AgentSyncCore/CodexThreadIndex.swift @@ -102,19 +102,23 @@ struct CodexThreadIndex { private func runSQLiteCapturing(_ sql: String) throws -> String { let process = Process() process.executableURL = URL(fileURLWithPath: "/usr/bin/sqlite3") - process.arguments = [databaseURL.path, sql] + // Codex keeps this database open; wait out its write locks instead of + // failing immediately with "database is locked". + process.arguments = ["-cmd", ".timeout 3000", databaseURL.path, sql] let stdout = Pipe() let stderr = Pipe() process.standardOutput = stdout process.standardError = stderr try process.run() + // Drain stdout before waiting so a large result can't fill the pipe + // and deadlock the child. + let data = stdout.fileHandleForReading.readDataToEndOfFile() process.waitUntilExit() if process.terminationStatus != 0 { - let data = stderr.fileHandleForReading.readDataToEndOfFile() - let message = String(data: data, encoding: .utf8) ?? "sqlite3 failed" + let errorData = stderr.fileHandleForReading.readDataToEndOfFile() + let message = String(data: errorData, encoding: .utf8) ?? "sqlite3 failed" throw AgentSyncError.commandFailed(message) } - let data = stdout.fileHandleForReading.readDataToEndOfFile() return String(data: data, encoding: .utf8) ?? "" } } diff --git a/Sources/AgentSyncCore/ModelCatalog.swift b/Sources/AgentSyncCore/ModelCatalog.swift index 27d6e57..624ad1b 100644 --- a/Sources/AgentSyncCore/ModelCatalog.swift +++ b/Sources/AgentSyncCore/ModelCatalog.swift @@ -64,15 +64,19 @@ public enum ModelCatalog { /// Blocking GET of the models.dev catalog; nil on any non-200 or timeout. private static func fetchCatalogData(timeout: TimeInterval) -> Data? { let semaphore = DispatchSemaphore(value: 0) - var fetched: Data? - URLSession.shared.dataTask(with: sourceURL) { data, response, _ in + let fetched = FetchResult() + let task = URLSession.shared.dataTask(with: sourceURL) { data, response, _ in if let http = response as? HTTPURLResponse, http.statusCode == 200 { - fetched = data + fetched.set(data) } semaphore.signal() - }.resume() - _ = semaphore.wait(timeout: .now() + timeout) - return fetched + } + task.resume() + guard semaphore.wait(timeout: .now() + timeout) == .success else { + task.cancel() + return nil + } + return fetched.get() } /// Usable input tokens for a model, matched as "provider/model" or bare @@ -132,3 +136,22 @@ public enum ModelCatalog { lock.unlock() } } + +/// The completion handler can still be running when a timed-out fetch +/// returns, so its result crosses threads behind a lock. +private final class FetchResult: @unchecked Sendable { + private let lock = NSLock() + private var data: Data? + + func set(_ data: Data?) { + lock.lock() + defer { lock.unlock() } + self.data = data + } + + func get() -> Data? { + lock.lock() + defer { lock.unlock() } + return data + } +} diff --git a/Sources/AgentSyncCore/NativeFileWriter.swift b/Sources/AgentSyncCore/NativeFileWriter.swift index a4b68b3..f63a157 100644 --- a/Sources/AgentSyncCore/NativeFileWriter.swift +++ b/Sources/AgentSyncCore/NativeFileWriter.swift @@ -27,10 +27,13 @@ enum NativeFileWriter { } try text.write(to: tmp, atomically: true, encoding: .utf8) - if fm.fileExists(atPath: destination.path) { - try fm.removeItem(at: destination) + // rename(2) swaps the file in one step, so a concurrent reader (a + // resuming CLI) never sees the mirror missing mid-replace. + guard rename(tmp.path, destination.path) == 0 else { + let code = errno + try? fm.removeItem(at: tmp) + throw AgentSyncError.unsafeWrite("Could not move mirror into place at \(destination.path): \(String(cString: strerror(code)))") } - try fm.moveItem(at: tmp, to: destination) } /// Canonicalizes a path whether or not it exists yet, by resolving the diff --git a/Sources/AgentSyncCore/OpenCodeAdapter.swift b/Sources/AgentSyncCore/OpenCodeAdapter.swift index d398eea..a799b7e 100644 --- a/Sources/AgentSyncCore/OpenCodeAdapter.swift +++ b/Sources/AgentSyncCore/OpenCodeAdapter.swift @@ -154,8 +154,10 @@ public struct OpenCodeAdapter: Sendable { text: "OpenCode tool call: \(toolName)\n\(input)", metadata: metadata )) + // A failed call carries its message in `error`, not `output`. + let rawOutput = state.string("output") ?? state["output"]?.prettyString() ?? "" let output = boundedTranscriptText( - state.string("output") ?? state["output"]?.prettyString() ?? "", + rawOutput.isEmpty ? (state.string("error") ?? "") : rawOutput, limit: 20_000 ) if !output.isEmpty { diff --git a/Sources/AgentSyncCore/OpenCodeRenderer.swift b/Sources/AgentSyncCore/OpenCodeRenderer.swift index 96c4580..dffed3d 100644 --- a/Sources/AgentSyncCore/OpenCodeRenderer.swift +++ b/Sources/AgentSyncCore/OpenCodeRenderer.swift @@ -122,7 +122,8 @@ public extension OpenCodeAdapter { func newMessageID() -> String { messageCounter += 1 let suffix = UUID().uuidString.replacingOccurrences(of: "-", with: "").lowercased().prefix(18) - return "msg_agsync\(String(format: "%03d", messageCounter))\(suffix)" + // Fixed width keeps IDs sorting in order past the 999th message. + return "msg_agsync\(String(format: "%06d", messageCounter))\(suffix)" } func appendMessage(role: String, model: String, timestamp: Date, parts rawParts: [JSONValue]) -> (id: String, index: Int) { @@ -191,7 +192,11 @@ public extension OpenCodeAdapter { continue } switch event.role { - case .user, .developer, .system: + case .developer, .system: + // Harness instructions (Codex sandbox/permission blocks) are + // not things the user typed; the Claude renderer skips them too. + continue + case .user: let message = appendMessage( role: "user", model: defaultModel, diff --git a/Sources/AgentSyncCore/PathEncoding.swift b/Sources/AgentSyncCore/PathEncoding.swift index c2cd01c..bf2e97d 100644 --- a/Sources/AgentSyncCore/PathEncoding.swift +++ b/Sources/AgentSyncCore/PathEncoding.swift @@ -1,15 +1,23 @@ import Foundation public enum PathEncoding { - /// Claude Code encodes a project cwd by replacing "/" AND "." with "-" - /// (verified by letting Claude create a session from a dotted cwd: - /// /Users/x/.codex/… → -Users-x--codex-…). Dotted-looking folders with - /// preserved "." exist on disk from an older Claude version, but current - /// resume resolution only reads the dashed form. + /// Claude Code encodes a project cwd by replacing every character outside + /// [A-Za-z0-9] with "-", one dash per UTF-16 code unit (its JS + /// `replace(/[^a-zA-Z0-9]/g, "-")`): /Users/x/.codex → -Users-x--codex, + /// "Application Support" → Application-Support, my_app → my-app. + /// Dotted-looking folders with preserved "." exist on disk from an older + /// Claude version, but current resume resolution only reads the dashed form. public static func claudeProjectName(for cwd: String) -> String { - let encoded = cwd - .replacingOccurrences(of: "/", with: "-") - .replacingOccurrences(of: ".", with: "-") + let dash = UInt16(UInt8(ascii: "-")) + let units = cwd.utf16.map { unit -> UInt16 in + switch unit { + case 0x30...0x39, 0x41...0x5A, 0x61...0x7A: + return unit + default: + return dash + } + } + let encoded = String(decoding: units, as: UTF16.self) return encoded.isEmpty ? "-unknown" : encoded } diff --git a/Sources/AgentSyncCore/RecentSessionScanner.swift b/Sources/AgentSyncCore/RecentSessionScanner.swift index 3534b2e..245d3d3 100644 --- a/Sources/AgentSyncCore/RecentSessionScanner.swift +++ b/Sources/AgentSyncCore/RecentSessionScanner.swift @@ -529,22 +529,24 @@ public enum RecentSessionScanner { let handle = try FileHandle(forReadingFrom: url) defer { try? handle.close() } let data: Data + var startsMidFile = false if fromEnd { let size = (try? handle.seekToEnd()) ?? 0 let offset = size > UInt64(maximumBytes) ? size - UInt64(maximumBytes) : 0 + startsMidFile = offset > 0 try handle.seek(toOffset: offset) data = try handle.readToEnd() ?? Data() } else { data = try handle.read(upToCount: maximumBytes) ?? Data() } - guard let text = String(data: data, encoding: .utf8) else { - return [] - } + // A byte cut can split a multibyte character at either edge; decode + // leniently so that only the partial edge line is lost, not the chunk. + let text = String(decoding: data, as: UTF8.self) var lines = text.split(separator: "\n", omittingEmptySubsequences: true) if fromEnd { // The first chunk-line may be a partial JSON record; drop it. - if !lines.isEmpty { + if startsMidFile, !lines.isEmpty { lines.removeFirst() } lines = Array(lines.suffix(maximumLines)) diff --git a/Sources/AgentSyncCore/TextBounds.swift b/Sources/AgentSyncCore/TextBounds.swift index e7efb1d..d0ab8a7 100644 --- a/Sources/AgentSyncCore/TextBounds.swift +++ b/Sources/AgentSyncCore/TextBounds.swift @@ -177,7 +177,8 @@ public func looksLikeInjectedContext(_ text: String) -> Bool { return true } let prefixes = [ - "Caveat:", "[Continuo handoff brief]", "Continuo mirrored this conversation" + "Caveat:", "[Continuo handoff brief]", "[Continuo transcript bookends]", + "Continuo mirrored this conversation" ] let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) return prefixes.contains { trimmed.hasPrefix($0) } diff --git a/Tests/AgentSyncCoreTests/RecentSessionScannerTests.swift b/Tests/AgentSyncCoreTests/RecentSessionScannerTests.swift index c5bf95c..10baacf 100644 --- a/Tests/AgentSyncCoreTests/RecentSessionScannerTests.swift +++ b/Tests/AgentSyncCoreTests/RecentSessionScannerTests.swift @@ -206,6 +206,75 @@ struct RecentSessionScannerTests { #expect(Set(previews.map(\.id)).count == previews.count) } + @Test("edge reads survive a byte cut inside a multibyte character") + func edgeReadsSurviveMultibyteCut() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("continuo-multibyte-edge-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + + let claudeHome = root.appendingPathComponent("claude", isDirectory: true) + let project = claudeHome + .appendingPathComponent("projects", isDirectory: true) + .appendingPathComponent("-tmp-project", isDirectory: true) + try FileManager.default.createDirectory(at: project, withIntermediateDirectories: true) + + // "é" is two UTF-8 bytes. Pad the lines around a long run of them so + // both the 256 KB prefix cut and the 256 KB tail cut land mid-character. + let edgeBytes = 256 * 1024 + let sessionID = "33333333-3333-4333-8333-333333333333" + var header = #"{"sessionId":"\#(sessionID)","cwd":"/tmp/project","type":"user","message":{"role":"user","content":"Multibyte prompt"}}"# + "\n" + header += #"{"type":"progress","text":""# + if (edgeBytes - header.utf8.count).isMultiple(of: 2) { + header = header.replacingOccurrences(of: #"{"type":"progress""#, with: #"{ "type":"progress""#) + } + var titleLine = #"{"type":"ai-title","aiTitle":"Title from the tail"}"# + "\n" + if (3 + titleLine.utf8.count).isMultiple(of: 2) { + titleLine = " " + titleLine + } + let transcript = header + String(repeating: "é", count: 300_000) + "\"}\n" + titleLine + try Data(transcript.utf8).write(to: project.appendingPathComponent("\(sessionID).jsonl")) + + let previews = try RecentSessionScanner.scan( + claudeHome: claudeHome, + codexHome: root.appendingPathComponent("codex", isDirectory: true), + opencodeHome: root.appendingPathComponent("opencode", isDirectory: true), + lookbackDays: nil, + maximumPerProvider: 10 + ) + let preview = try #require(previews.first) + + #expect(preview.snippet == "Multibyte prompt") + #expect(preview.title == "Title from the tail") + #expect(preview.cwd == "/tmp/project") + } + + @Test("a small transcript keeps its first record in the tail read") + func smallTranscriptTailKeepsFirstRecord() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("continuo-small-tail-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + + let claudeHome = root.appendingPathComponent("claude", isDirectory: true) + let project = claudeHome + .appendingPathComponent("projects", isDirectory: true) + .appendingPathComponent("-tmp-project", isDirectory: true) + try FileManager.default.createDirectory(at: project, withIntermediateDirectories: true) + + let sessionID = "44444444-4444-4444-8444-444444444444" + let transcript = #"{"type":"assistant","sessionId":"\#(sessionID)","cwd":"/tmp/project","message":{"role":"assistant","content":"hi","usage":{"input_tokens":1234}}}"# + "\n" + try Data(transcript.utf8).write(to: project.appendingPathComponent("\(sessionID).jsonl")) + + let previews = try RecentSessionScanner.scan( + claudeHome: claudeHome, + codexHome: root.appendingPathComponent("codex", isDirectory: true), + opencodeHome: root.appendingPathComponent("opencode", isDirectory: true), + lookbackDays: nil, + maximumPerProvider: 10 + ) + + #expect(try #require(previews.first).estimatedTokens == 1234) + } + @Test("provider title metadata is distinguished from a prompt fallback") func sourceTitleProvenance() throws { let root = FileManager.default.temporaryDirectory diff --git a/Tests/AgentSyncCoreTests/SyncEngineTests.swift b/Tests/AgentSyncCoreTests/SyncEngineTests.swift index 3bc082e..7221afb 100644 --- a/Tests/AgentSyncCoreTests/SyncEngineTests.swift +++ b/Tests/AgentSyncCoreTests/SyncEngineTests.swift @@ -1701,3 +1701,14 @@ private func createOpenCodeFixtureDatabase(_ databaseURL: URL) throws { try process.run() process.waitUntilExit() } + +@Test func claudeProjectNameDashesEveryNonAlphanumericCharacter() { + #expect(PathEncoding.claudeProjectName(for: "/Users/x/.codex/repo") == "-Users-x--codex-repo") + #expect(PathEncoding.claudeProjectName(for: "/Users/x/Library/Application Support/my_app") == "-Users-x-Library-Application-Support-my-app") + #expect(PathEncoding.claudeProjectName(for: "/Users/x/com~apple~CloudDocs/a+b@c") == "-Users-x-com-apple-CloudDocs-a-b-c") + // One dash per UTF-16 unit, like Claude Code's JS regex: "é" is one unit, + // an astral emoji is two. + #expect(PathEncoding.claudeProjectName(for: "/tmp/café") == "-tmp-caf-") + #expect(PathEncoding.claudeProjectName(for: "/tmp/🚀") == "-tmp---") + #expect(PathEncoding.claudeProjectName(for: "") == "-unknown") +}