From 0940c9ec43548c9319a4ed8de0d4f2561a319921 Mon Sep 17 00:00:00 2001 From: AstroQore <69107895+AstroQore@users.noreply.github.com> Date: Thu, 20 Aug 2026 04:12:24 +0800 Subject: [PATCH] feat(sessions): add scoped search and relationships Bound AntiGravity list metadata, recover Cowork project paths, strip injected titles, expose Auto Review parents, and add role-scoped search plus directory filters. Co-Authored-By: Codex --- CHANGELOG.md | 29 ++++ .../AntigravityConversationIndex.swift | 85 ++++++++++- .../Sessions/AntigravitySessionAdapter.swift | 37 ++--- .../Sessions/ClaudeCoworkSessionAdapter.swift | 6 +- .../Sessions/ClaudeSessionAdapter.swift | 2 +- .../Sessions/CodexSessionAdapter.swift | 43 +++++- .../Sessions/SessionIndexService.swift | 92 ++++++++---- .../Sessions/SessionIndexStore.swift | 141 ++++++++++++++++-- .../Sessions/SessionModels.swift | 25 ++++ .../Sessions/SessionParsing.swift | 13 +- .../Utilities/ClaudeCoworkPaths.swift | 69 +++++++++ .../Utilities/HumanPromptText.swift | 126 ++++++++++++++++ .../Events/SessionBrief.swift | 39 +---- .../AntigravitySessionAdapterTests.swift | 41 ++++- .../ClaudeCoworkSessionAdapterTests.swift | 31 ++++ .../ClaudeSessionAdapterTests.swift | 2 +- .../CodexSessionAdapterTests.swift | 43 +++++- .../HumanPromptTextTests.swift | 30 ++++ .../SessionIndexServiceTests.swift | 23 +-- .../SessionIndexStoreTests.swift | 80 +++++++++- 20 files changed, 831 insertions(+), 126 deletions(-) create mode 100644 Sources/AgentSessionKit/Utilities/HumanPromptText.swift create mode 100644 Tests/AgentSessionKitTests/HumanPromptTextTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 4b65ffa..932266d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,17 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] ### Added +- **Codex Auto Review relationships.** Guardian rollouts now encode their + original root session id in `providerVariant` as `auto-review:`, + and `CodexSessionAdapter.autoReviewParentSessionID(providerVariant:)` exposes + the stable parse. Hosts can merge review runs into the originating session + without guessing from project names or timestamps; `parent_thread_id` is + intentionally not trusted when `session_id` names the root directly. +- **Scoped session search and directory filters.** Hosts can independently + search titles, user prompts, assistant replies, system prompts, and + tool/file operations. Summary pages and searches accept safe project-path + include/exclude filters, and related `providerVariant` rows can be hidden, + listed, and resolved back to an exact root session. - **MCP client diagnostics and safe disconnects.** `MCPSocketServer.clientConnections` reports a private connection id, the kernel-reported peer pid when available, connection time, and last byte @@ -25,6 +36,24 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). makes the claim below assertable rather than timed. ### Changed +- **Static session titles now mean a person spoke.** Codex user-role records + are passed through the same machine-context stripping used by live session + briefs, including `recommended_plugins`, AGENTS/skills/permissions blocks, + and environment context. A metadata-side title that is only injected + context is rejected too. AntiGravity no longer picks known system, + assistant, or tool steps as its fallback title; without a preserved user + prompt it uses an honest conversation-id fallback. +- **AntiGravity session-list metadata is bounded.** Listing a conversation no + longer decodes every `gen_metadata` protobuf or materializes the entire + `steps` table just to show its newest model and fallback title. SQLite counts + rows directly, only bounded first/recent turn windows are decoded, and only + the first twelve steps are read for title fallback. Full tables remain + available when the user explicitly opens the transcript. +- **Claude Cowork reports the working folder the user actually touched.** + Cowork's isolated `outputs` cwd is replaced, when structured tool paths are + available in the bounded transcript head, by their common external project + directory. The isolated path remains the safe fallback when no evidence is + present. - **A desktop-sized MCP client budget, optional idle reclamation, and explicit refusal.** The default cap is 64 rather than 16 because desktop clients may keep one stdio bridge per open task; hosts can tune it. Idle expiry is now an diff --git a/Sources/AgentSessionKit/Sessions/AntigravityConversationIndex.swift b/Sources/AgentSessionKit/Sessions/AntigravityConversationIndex.swift index 6c658e9..02f9624 100644 --- a/Sources/AgentSessionKit/Sessions/AntigravityConversationIndex.swift +++ b/Sources/AgentSessionKit/Sessions/AntigravityConversationIndex.swift @@ -14,15 +14,25 @@ enum AntigravityLiveSQLite { } static func steps(at url: URL) -> [Step]? { + steps(at: url, limit: LiveSQLiteReader.maxRows) + } + + /// Opening-list metadata needs only the first few steps for a fallback + /// title. Do not materialize a whole trajectory just to read one sentence. + static func firstSteps(at url: URL, limit: Int) -> [Step]? { + steps(at: url, limit: max(1, min(limit, LiveSQLiteReader.maxRows))) + } + + private static func steps(at url: URL, limit: Int) -> [Step]? { LiveSQLiteReader.read(at: url) { database in let statement = try LiveSQLiteReader.prepare( database, - "SELECT idx, step_type, step_payload FROM steps ORDER BY idx" + "SELECT idx, step_type, step_payload FROM steps ORDER BY idx LIMIT \(limit)" ) defer { sqlite3_finalize(statement) } var out: [Step] = [] var result = sqlite3_step(statement) - while result == SQLITE_ROW, out.count < LiveSQLiteReader.maxRows { + while result == SQLITE_ROW, out.count < limit { out.append(Step( idx: Int(sqlite3_column_int64(statement, 0)), type: Int(sqlite3_column_int64(statement, 1)), @@ -30,13 +40,82 @@ enum AntigravityLiveSQLite { )) result = sqlite3_step(statement) } - guard result == SQLITE_DONE || out.count >= LiveSQLiteReader.maxRows else { + guard result == SQLITE_DONE || out.count >= limit else { throw LiveSQLiteReader.ReadError.statement } return out } } + struct MetadataSummary { + let firstDate: Date? + let lastDate: Date? + let model: String? + let messageCount: Int + } + + /// The Sessions list needs four facts, not every decoded turn. Count rows + /// in SQLite, decode a bounded edge window for the first/last timestamps, + /// and search only recent turns for the model. Full turn decoding remains + /// in `turns(at:)` for the transcript opened by an explicit selection. + static func metadataSummary(at url: URL, edgeLimit: Int = 64) -> MetadataSummary? { + LiveSQLiteReader.read(at: url) { database in + let countStatement = try LiveSQLiteReader.prepare( + database, + "SELECT COUNT(*) FROM gen_metadata" + ) + defer { sqlite3_finalize(countStatement) } + guard sqlite3_step(countStatement) == SQLITE_ROW else { + throw LiveSQLiteReader.ReadError.statement + } + let count = Int(sqlite3_column_int64(countStatement, 0)) + guard count > 0 else { + return MetadataSummary(firstDate: nil, lastDate: nil, model: nil, messageCount: 0) + } + + let limit = max(1, min(edgeLimit, LiveSQLiteReader.maxRows)) + let first = try decodedTurns( + database: database, + order: "ASC", + limit: limit + ) + let recent = try decodedTurns( + database: database, + order: "DESC", + limit: limit + ) + return MetadataSummary( + firstDate: first.first?.date, + lastDate: recent.first?.date, + model: recent.lazy.compactMap { $0.model ?? $0.routedModel }.first, + messageCount: count + ) + } + } + + private static func decodedTurns( + database: OpaquePointer, + order: String, + limit: Int + ) throws -> [AntigravityGenMetadataReader.Turn] { + let statement = try LiveSQLiteReader.prepare( + database, + "SELECT data FROM gen_metadata ORDER BY idx \(order) LIMIT \(limit)" + ) + defer { sqlite3_finalize(statement) } + var out: [AntigravityGenMetadataReader.Turn] = [] + var result = sqlite3_step(statement) + while result == SQLITE_ROW { + if let data = LiveSQLiteReader.blob(statement, 0), + let turn = AntigravityGenMetadataReader.decodeTurn(blob: data) { + out.append(turn) + } + result = sqlite3_step(statement) + } + guard result == SQLITE_DONE else { throw LiveSQLiteReader.ReadError.statement } + return out + } + /// `gen_metadata` decoded into model turns, keyed by row index. /// /// `AntigravityGenMetadataReader.readGenMetadata` opens with `immutable=1` diff --git a/Sources/AgentSessionKit/Sessions/AntigravitySessionAdapter.swift b/Sources/AgentSessionKit/Sessions/AntigravitySessionAdapter.swift index 162d7c9..a6f2617 100644 --- a/Sources/AgentSessionKit/Sessions/AntigravitySessionAdapter.swift +++ b/Sources/AgentSessionKit/Sessions/AntigravitySessionAdapter.swift @@ -114,36 +114,23 @@ public struct AntigravitySessionAdapter: SessionProviderAdapter { ) } - let turns = AntigravityLiveSQLite.turns(at: fileURL) - let dates = (turns ?? []).map(\.turn.date) - let firstText = hydrated?.title == nil ? firstUserishText(fileURL: fileURL) : nil + let metadata = AntigravityLiveSQLite.metadataSummary(at: fileURL) + let firstText = hydrated?.title == nil ? firstHumanPrompt(fileURL: fileURL) : nil return summary( sessionID: sessionID, variant: variant, - model: Self.model(turns: turns), + model: metadata?.model, title: hydrated?.title ?? firstText, summaryText: firstText, projectDir: hydrated?.projectDir, - createdAt: dates.first ?? SessionParsing.creationDate(fileURL), - lastActiveAt: dates.last ?? hydrated?.lastModified ?? SessionParsing.modificationDate(fileURL), + createdAt: metadata?.firstDate ?? SessionParsing.creationDate(fileURL), + lastActiveAt: metadata?.lastDate ?? hydrated?.lastModified ?? SessionParsing.modificationDate(fileURL), fileURL: fileURL, - messageCount: turns.map(\.count) ?? SessionSummary.unknownMessageCount + messageCount: metadata?.messageCount ?? SessionSummary.unknownMessageCount ) } - /// The most recent turn's model. `gen_metadata` is already decoded for - /// the dates and the message count, so no extra read happens here; the - /// router alias is the documented fallback when the model enum itself - /// has no learned label (see `AntigravityGenMetadataReader.Turn`). - static func model(turns: [(idx: Int, turn: AntigravityGenMetadataReader.Turn)]?) -> String? { - guard let turns else { return nil } - for entry in turns.reversed() { - if let model = entry.turn.model ?? entry.turn.routedModel { return model } - } - return nil - } - private func summary( sessionID: String, variant: String, @@ -184,12 +171,16 @@ public struct AntigravitySessionAdapter: SessionProviderAdapter { /// First readable prose in the opening steps, used only when no side /// store had a title. Tool-argument JSON is skipped: it is real text, /// but it is not what the conversation was about. - private func firstUserishText(fileURL: URL) -> String? { - guard let steps = AntigravityLiveSQLite.steps(at: fileURL) else { return nil } - for step in steps.prefix(Self.titleStepScanLimit) { + private func firstHumanPrompt(fileURL: URL) -> String? { + guard let steps = AntigravityLiveSQLite.firstSteps( + at: fileURL, + limit: Self.titleStepScanLimit + ) else { return nil } + for step in steps { + if Self.stepTypeRoleMap[step.type] != nil { continue } guard let payload = step.payload else { continue } for run in AntigravityStepText.runs(in: payload) where !run.hasPrefix("{") { - return run + if let instruction = HumanPromptText.instruction(run) { return instruction } } } return nil diff --git a/Sources/AgentSessionKit/Sessions/ClaudeCoworkSessionAdapter.swift b/Sources/AgentSessionKit/Sessions/ClaudeCoworkSessionAdapter.swift index 68153ff..0e1bb41 100644 --- a/Sources/AgentSessionKit/Sessions/ClaudeCoworkSessionAdapter.swift +++ b/Sources/AgentSessionKit/Sessions/ClaudeCoworkSessionAdapter.swift @@ -35,11 +35,15 @@ public struct ClaudeCoworkSessionAdapter: SessionProviderAdapter { // MARK: - Metadata public func extractMetadata(fileURL: URL) throws -> SessionSummary { - try ClaudeSessionAdapter.summary( + let summary = try ClaudeSessionAdapter.summary( fileURL: fileURL, provider: .claudeCowork, harness: .claudeCowork ) + guard let project = ClaudeCoworkPaths.inferredProjectDirectory(fileURL: fileURL) else { + return summary + } + return summary.withTitle(summary.title, projectDir: project) } // MARK: - Transcript diff --git a/Sources/AgentSessionKit/Sessions/ClaudeSessionAdapter.swift b/Sources/AgentSessionKit/Sessions/ClaudeSessionAdapter.swift index 13a1230..b791d38 100644 --- a/Sources/AgentSessionKit/Sessions/ClaudeSessionAdapter.swift +++ b/Sources/AgentSessionKit/Sessions/ClaudeSessionAdapter.swift @@ -212,7 +212,7 @@ public struct ClaudeSessionAdapter: SessionProviderAdapter { case "user": return .user case "assistant": return .assistant case "tool": return .tool - case "system": return .system + case "system", "developer": return .system default: return .other } } diff --git a/Sources/AgentSessionKit/Sessions/CodexSessionAdapter.swift b/Sources/AgentSessionKit/Sessions/CodexSessionAdapter.swift index 37ad8dc..8739f7d 100644 --- a/Sources/AgentSessionKit/Sessions/CodexSessionAdapter.swift +++ b/Sources/AgentSessionKit/Sessions/CodexSessionAdapter.swift @@ -12,6 +12,12 @@ import Foundation public struct CodexSessionAdapter: SessionProviderAdapter { public let provider: SessionProvider = .codex + /// Encoded in `providerVariant` so hosts can fold a guardian rollout into + /// the original session without a second schema axis. The suffix is the + /// root session id from `session_meta.session_id`, which local rollouts + /// preserve even when `parent_thread_id` names an intermediate subagent. + public static let autoReviewVariantPrefix = "auto-review:" + private let hydrator: CodexTitleHydrator public init(homeDirectory: String = RealHomeDirectory.path) { @@ -71,18 +77,22 @@ public struct CodexSessionAdapter: SessionProviderAdapter { let prompt = firstUserText(head) let hydrated = hydrator.thread(for: sessionID) + let hydratedTitle = hydrated?.title.flatMap(HumanPromptText.instruction) + let model = Self.model(in: head) ?? Self.model(in: tail) + let autoReviewParent = Self.autoReviewParentSessionID(meta: meta, model: model) return SessionSummary( provider: .codex, sessionID: sessionID, + providerVariant: autoReviewParent.map { Self.autoReviewVariantPrefix + $0 }, // Every Codex surface writes into the same tree; only the // `originator` separates ChatGPT Work from ordinary Codex, and // the cost scanner already owns that mapping. harness: CodexOriginator.harness( originator: SessionParsing.string(meta?["originator"]) ), - model: Self.model(in: head) ?? Self.model(in: tail), - title: SessionParsing.display(hydrated?.title ?? prompt, limit: SessionParsing.titleLimit), + model: model, + title: SessionParsing.display(hydratedTitle ?? prompt, limit: SessionParsing.titleLimit), summary: SessionParsing.display(prompt, limit: SessionParsing.summaryLimit), projectDir: SessionParsing.firstString(meta?["cwd"], hydrated?.cwd), createdAt: createdAt, @@ -93,6 +103,28 @@ public struct CodexSessionAdapter: SessionProviderAdapter { ) } + /// Root session id for a guardian / Auto Review rollout, or nil for an + /// ordinary Codex session. + public static func autoReviewParentSessionID(providerVariant: String?) -> String? { + guard let providerVariant, + providerVariant.hasPrefix(autoReviewVariantPrefix) + else { return nil } + return SessionParsing.string(String(providerVariant.dropFirst(autoReviewVariantPrefix.count))) + } + + private static func autoReviewParentSessionID( + meta: [String: Any]?, + model: String? + ) -> String? { + let source = meta?["source"] as? [String: Any] + let subagent = source?["subagent"] as? [String: Any] + let isGuardian = SessionParsing.string(subagent?["other"])?.lowercased() == "guardian" + let isReviewRuntime = SessionParsing.string(meta?["thread_source"])?.lowercased() == "subagent" + && model?.lowercased() == "codex-auto-review" + guard isGuardian || isReviewRuntime else { return nil } + return SessionParsing.firstString(meta?["session_id"], meta?["parent_thread_id"]) + } + /// The model a rollout ran on, from whichever of the three places this /// vintage of Codex wrote it. /// @@ -122,7 +154,7 @@ public struct CodexSessionAdapter: SessionProviderAdapter { payload["role"] as? String == "user" else { continue } let text = Self.strippingIDEEnvelope(SessionParsing.extractText(payload["content"])) - if !text.isEmpty { return text } + if let instruction = HumanPromptText.instruction(text) { return instruction } } return nil } @@ -178,9 +210,10 @@ public struct CodexSessionAdapter: SessionProviderAdapter { role = ClaudeSessionAdapter.role(payload["role"] as? String) text = SessionParsing.extractText(payload["content"]) case "function_call": - role = .assistant + role = .tool let name = (payload["name"] as? String) ?? "tool" - text = "[Tool: \(name)]" + let arguments = SessionParsing.string(payload["arguments"]) + text = arguments.map { "[Tool: \(name)]\n\($0)" } ?? "[Tool: \(name)]" case "function_call_output": role = .tool text = SessionParsing.extractText(payload["output"]) diff --git a/Sources/AgentSessionKit/Sessions/SessionIndexService.swift b/Sources/AgentSessionKit/Sessions/SessionIndexService.swift index fb29288..3518834 100644 --- a/Sources/AgentSessionKit/Sessions/SessionIndexService.swift +++ b/Sources/AgentSessionKit/Sessions/SessionIndexService.swift @@ -83,6 +83,9 @@ public actor SessionIndexService { providers: [SessionProvider]? = nil, harnesses: [Harness]? = nil, since: Date? = nil, + projectIncludes: [String] = [], + projectExcludes: [String] = [], + excludingProviderVariantPrefix: String? = nil, order: SessionSummaryOrder = .recentFirst, offset: Int = 0, limit: Int = 250 @@ -91,6 +94,9 @@ public actor SessionIndexService { providers: providers, harnesses: harnesses, since: since, + projectIncludes: projectIncludes, + projectExcludes: projectExcludes, + excludingProviderVariantPrefix: excludingProviderVariantPrefix, order: order, offset: offset, limit: limit @@ -109,12 +115,34 @@ public actor SessionIndexService { _ text: String, providers: [SessionProvider]? = nil, harnesses: [Harness]? = nil, + scopes: Set = SessionSearchScope.defaultScopes, + projectIncludes: [String] = [], + projectExcludes: [String] = [], limit: Int = 50 ) async throws -> [SessionSearchHit] { try await store.search( text: text, providers: providers, harnesses: harnesses, + scopes: scopes, + projectIncludes: projectIncludes, + projectExcludes: projectExcludes, + limit: limit + ) + } + + public func summary(provider: SessionProvider, sessionID: String) async throws -> SessionSummary? { + try await store.summary(provider: provider, sessionID: sessionID) + } + + public func summaries( + provider: SessionProvider, + providerVariantPrefix: String, + limit: Int = 2_000 + ) async throws -> [SessionSummary] { + try await store.summaries( + provider: provider, + providerVariantPrefix: providerVariantPrefix, limit: limit ) } @@ -199,10 +227,10 @@ public actor SessionIndexService { /// indexed from the top and truncated. static let maxSessionExcerptBytes = 512 * 1024 - /// What of a transcript is worth searching: the human's prompts and - /// the model's replies. Tool output, system preambles, and the - /// editor / slash-command envelopes are noise a user would never - /// search for and would flood every result list if indexed. + /// Every semantic message role is indexed so the caller can choose the + /// exact search surface. Machine envelopes are still removed before the + /// text reaches SQLite; a system/tool-only search should find intentional + /// content, not the identical harness bootstrap copied into every log. static func excerpts( from document: TranscriptDocument, provider: SessionProvider @@ -210,38 +238,52 @@ public actor SessionIndexService { var out: [SessionIndexStore.MessageExcerpt] = [] var budget = maxSessionExcerptBytes for message in document.messages { - guard message.role == .user || message.role == .assistant else { continue } - guard let text = normalize(message.text, provider: provider) else { continue } - let cost = text.utf8.count - guard cost <= budget else { break } - budget -= cost - out.append(SessionIndexStore.MessageExcerpt( - seq: message.seq, role: message.role, excerpt: text - )) + guard let text = normalize(message.text, role: message.role, provider: provider) else { continue } + var parts: [(role: SessionRole, text: String)] = [] + if message.role == .assistant { + let lines = text.split(separator: "\n", omittingEmptySubsequences: false) + if let marker = lines.firstIndex(where: { + $0.trimmingCharacters(in: .whitespaces).hasPrefix("[Tool: ") + }) { + let prose = lines[.. String? { + static func normalize(_ raw: String, role: SessionRole, provider: SessionProvider) -> String? { var text = raw if provider == .codex { text = CodexSessionAdapter.strippingIDEEnvelope(text) + if role == .user { + guard let instruction = HumanPromptText.instruction(text) else { return nil } + text = instruction + } } guard !ClaudeSessionAdapter.isEnvelopeText(text) else { return nil } - // `[Tool: name]` markers are how every adapter renders a tool - // call inside an assistant turn; they carry no searchable content. - let kept = text - .split(separator: "\n", omittingEmptySubsequences: false) - .filter { !isToolMarker($0) } - .joined(separator: "\n") - .trimmingCharacters(in: .whitespacesAndNewlines) + let kept = text.trimmingCharacters(in: .whitespacesAndNewlines) guard !kept.isEmpty else { return nil } return SessionParsing.truncate(kept, limit: maxExcerptCharacters) } - - private static func isToolMarker(_ line: Substring) -> Bool { - let trimmed = line.trimmingCharacters(in: .whitespaces) - return trimmed.hasPrefix("[Tool: ") && trimmed.hasSuffix("]") - } } diff --git a/Sources/AgentSessionKit/Sessions/SessionIndexStore.swift b/Sources/AgentSessionKit/Sessions/SessionIndexStore.swift index 00b31c0..57a335c 100644 --- a/Sources/AgentSessionKit/Sessions/SessionIndexStore.swift +++ b/Sources/AgentSessionKit/Sessions/SessionIndexStore.swift @@ -62,7 +62,10 @@ public actor SessionIndexStore { /// carried the inherited per-line `sessionId` of forked / continued /// sessions instead of the file's own UUID, and a fingerprint-driven /// incremental pass would never revisit those unchanged files. - static let schemaVersion = 3 + /// + /// v4 also changes no columns; it backfills system and tool excerpts now + /// that callers can choose the message roles a search is allowed to hit. + static let schemaVersion = 4 /// Open (or create) the index at `url`. /// @@ -516,6 +519,9 @@ public actor SessionIndexStore { providers: [SessionProvider]? = nil, harnesses: [Harness]? = nil, since: Date? = nil, + projectIncludes: [String] = [], + projectExcludes: [String] = [], + excludingProviderVariantPrefix: String? = nil, order: SessionSummaryOrder = .recentFirst, offset: Int = 0, limit: Int = 250 @@ -542,6 +548,16 @@ public actor SessionIndexStore { clauses.append("COALESCE(s.last_active_at, s.created_at) >= ?") bindings.append(.integer(Int64(since.timeIntervalSince1970.rounded(.down)))) } + Self.appendProjectClauses( + includes: projectIncludes, + excludes: projectExcludes, + clauses: &clauses, + bindings: &bindings + ) + if let prefix = SessionParsing.string(excludingProviderVariantPrefix) { + clauses.append("(s.provider_variant IS NULL OR s.provider_variant NOT LIKE ? ESCAPE '\\')") + bindings.append(.text(Self.likePattern(prefix) + "%")) + } let whereSQL = clauses.isEmpty ? "" : " WHERE " + clauses.joined(separator: " AND ") let orderSQL: String switch order { @@ -625,26 +641,40 @@ public actor SessionIndexStore { text: String, providers: [SessionProvider]? = nil, harnesses: [Harness]? = nil, + scopes: Set = SessionSearchScope.defaultScopes, + projectIncludes: [String] = [], + projectExcludes: [String] = [], limit: Int = 50 ) throws -> [SessionSearchHit] { let needle = text.trimmingCharacters(in: .whitespacesAndNewlines) guard !needle.isEmpty else { return [] } if let providers, providers.isEmpty { return [] } if let harnesses, harnesses.isEmpty { return [] } + guard !scopes.isEmpty else { return [] } let cap = min(max(1, limit), 500) - let scope = Scope(providers: providers, harnesses: harnesses) + let scope = Scope( + providers: providers, + harnesses: harnesses, + searchScopes: scopes, + projectIncludes: projectIncludes, + projectExcludes: projectExcludes + ) var hits: [SessionSearchHit] = [] var seen: Set = [] - for row in try bodyMatches(needle, scope: scope, limit: cap) { - guard seen.insert(row.rowID).inserted else { continue } - hits.append(row.hit) - if hits.count >= cap { return hits } + if scopes.contains(where: { $0.messageRole != nil }) { + for row in try bodyMatches(needle, scope: scope, limit: cap) { + guard seen.insert(row.rowID).inserted else { continue } + hits.append(row.hit) + if hits.count >= cap { return hits } + } } - for row in try metadataMatches(needle, scope: scope, limit: cap) { - guard seen.insert(row.rowID).inserted else { continue } - hits.append(row.hit) - if hits.count >= cap { return hits } + if scopes.contains(.title) { + for row in try metadataMatches(needle, scope: scope, limit: cap) { + guard seen.insert(row.rowID).inserted else { continue } + hits.append(row.hit) + if hits.count >= cap { return hits } + } } return hits } @@ -662,6 +692,9 @@ public actor SessionIndexStore { private struct Scope { let providers: [SessionProvider]? let harnesses: [Harness]? + let searchScopes: Set + let projectIncludes: [String] + let projectExcludes: [String] } private func bodyMatches( @@ -669,7 +702,7 @@ public actor SessionIndexStore { scope: Scope, limit: Int ) throws -> [(rowID: Int64, hit: SessionSearchHit)] { - let filter = scopeFilter(scope) + let filter = scopeFilter(scope, includeMessageRoles: true) let sql: String var bindings: [Binding] if needle.count >= Self.minimumTrigramLength { @@ -720,13 +753,12 @@ public actor SessionIndexStore { scope: Scope, limit: Int ) throws -> [(rowID: Int64, hit: SessionSearchHit)] { - let filter = scopeFilter(scope) + let filter = scopeFilter(scope, includeMessageRoles: false) let statement = try prepare( """ SELECT \(Self.sessionColumns), s.id FROM sessions s WHERE (s.title LIKE ? ESCAPE '\\' - OR s.summary LIKE ? ESCAPE '\\' - OR s.project_dir LIKE ? ESCAPE '\\')\(filter.sql) + OR s.session_id LIKE ? ESCAPE '\\')\(filter.sql) ORDER BY s.last_active_at IS NULL, s.last_active_at DESC, s.id DESC LIMIT ? """ @@ -734,7 +766,7 @@ public actor SessionIndexStore { defer { sqlite3_finalize(statement) } let pattern = "%" + Self.likePattern(needle) + "%" bindAll( - [.text(pattern), .text(pattern), .text(pattern)] + [.text(pattern), .text(pattern)] + filter.bindings + [.integer(Int64(limit))], to: statement @@ -751,7 +783,10 @@ public actor SessionIndexStore { return out } - private func scopeFilter(_ scope: Scope) -> (sql: String, bindings: [Binding]) { + private func scopeFilter( + _ scope: Scope, + includeMessageRoles: Bool + ) -> (sql: String, bindings: [Binding]) { var sql = "" var bindings: [Binding] = [] if let providers = scope.providers, !providers.isEmpty { @@ -764,9 +799,83 @@ public actor SessionIndexStore { sql += " AND s.harness IN (\(marks))" bindings.append(contentsOf: harnesses.map { .text($0.rawValue) }) } + let roles = scope.searchScopes.compactMap(\.messageRole) + if includeMessageRoles, !roles.isEmpty { + let marks = Array(repeating: "?", count: roles.count).joined(separator: ", ") + sql += " AND m.role IN (\(marks))" + bindings.append(contentsOf: roles.map { .text($0.rawValue) }) + } + var projectClauses: [String] = [] + Self.appendProjectClauses( + includes: scope.projectIncludes, + excludes: scope.projectExcludes, + clauses: &projectClauses, + bindings: &bindings + ) + if !projectClauses.isEmpty { + sql += " AND " + projectClauses.joined(separator: " AND ") + } return (sql, bindings) } + private static func appendProjectClauses( + includes: [String], + excludes: [String], + clauses: inout [String], + bindings: inout [Binding] + ) { + let included = includes.compactMap { SessionParsing.string($0) } + if !included.isEmpty { + clauses.append("(" + Array(repeating: "s.project_dir LIKE ? ESCAPE '\\'", count: included.count) + .joined(separator: " OR ") + ")") + bindings.append(contentsOf: included.map { .text("%" + likePattern($0) + "%") }) + } + for excluded in excludes.compactMap({ SessionParsing.string($0) }) { + clauses.append("(s.project_dir IS NULL OR s.project_dir NOT LIKE ? ESCAPE '\\')") + bindings.append(.text("%" + likePattern(excluded) + "%")) + } + } + + /// One exact session row, used by hosts to resolve a related child hit + /// (for example an Auto Review rollout) back to its root conversation. + public func summary(provider: SessionProvider, sessionID: String) throws -> SessionSummary? { + let statement = try prepare( + "SELECT \(Self.sessionColumns) FROM sessions s WHERE s.provider = ? AND s.session_id = ? " + + "ORDER BY COALESCE(s.last_active_at, s.created_at) DESC, s.id DESC LIMIT 1" + ) + defer { sqlite3_finalize(statement) } + bindAll([.text(provider.rawValue), .text(sessionID)], to: statement) + guard sqlite3_step(statement) == SQLITE_ROW else { return nil } + return summary(statement, offset: 0) + } + + /// Provider-specific related rows. The prefix is escaped as data, never + /// interpolated as SQL, and the bounded result protects UI callers from + /// accidentally loading an unbounded archive. + public func summaries( + provider: SessionProvider, + providerVariantPrefix: String, + limit: Int = 2_000 + ) throws -> [SessionSummary] { + let cap = min(max(1, limit), 10_000) + let statement = try prepare( + "SELECT \(Self.sessionColumns) FROM sessions s WHERE s.provider = ? " + + "AND s.provider_variant LIKE ? ESCAPE '\\' " + + "ORDER BY COALESCE(s.last_active_at, s.created_at) ASC, s.id ASC LIMIT ?" + ) + defer { sqlite3_finalize(statement) } + bindAll([ + .text(provider.rawValue), + .text(Self.likePattern(providerVariantPrefix) + "%"), + .integer(Int64(cap)) + ], to: statement) + var out: [SessionSummary] = [] + while sqlite3_step(statement) == SQLITE_ROW { + if let value = summary(statement, offset: 0) { out.append(value) } + } + return out + } + /// FTS5 `MATCH` takes a query language, not a literal: bare `AND`, /// `*`, `:`, `-` and friends are operators. Wrapping the whole needle /// in double quotes turns it into one phrase, and doubling any diff --git a/Sources/AgentSessionKit/Sessions/SessionModels.swift b/Sources/AgentSessionKit/Sessions/SessionModels.swift index 4dae8ac..cbfad50 100644 --- a/Sources/AgentSessionKit/Sessions/SessionModels.swift +++ b/Sources/AgentSessionKit/Sessions/SessionModels.swift @@ -169,6 +169,31 @@ public enum SessionSummaryOrder: String, Sendable, Hashable { case byProject } +/// Which parts of an indexed session a text query may match. +/// +/// Project paths are intentionally not a search scope: callers have separate +/// include / exclude directory filters, so a path never appears as a +/// surprising content hit. +public enum SessionSearchScope: String, CaseIterable, Codable, Sendable, Hashable { + case title + case user + case assistant + case system + case tool + + public static let defaultScopes: Set = [.title, .user, .assistant] + + var messageRole: SessionRole? { + switch self { + case .title: nil + case .user: .user + case .assistant: .assistant + case .system: .system + case .tool: .tool + } + } +} + /// A bounded window from the session index. A UI renders one page at /// a time instead of publishing every historical session into SwiftUI. public struct SessionSummaryPage: Sendable, Equatable { diff --git a/Sources/AgentSessionKit/Sessions/SessionParsing.swift b/Sources/AgentSessionKit/Sessions/SessionParsing.swift index ca22f43..3b7e8df 100644 --- a/Sources/AgentSessionKit/Sessions/SessionParsing.swift +++ b/Sources/AgentSessionKit/Sessions/SessionParsing.swift @@ -150,7 +150,8 @@ public enum SessionParsing { switch dict["type"] as? String { case "tool_use": let name = (dict["name"] as? String) ?? "tool" - return "[Tool: \(name)]" + let input = compactJSON(dict["input"]) + return input.map { "[Tool: \(name)]\n\($0)" } ?? "[Tool: \(name)]" case "tool_result": return extractText(dict["content"], depth: depth + 1) default: @@ -162,6 +163,16 @@ public enum SessionParsing { } } + private static func compactJSON(_ value: Any?) -> String? { + guard let value, + JSONSerialization.isValidJSONObject(value), + let data = try? JSONSerialization.data(withJSONObject: value, options: [.sortedKeys]), + let text = String(data: data, encoding: .utf8), + !text.isEmpty + else { return nil } + return truncate(text, limit: 4_000) + } + /// First of several candidate fields that flattens to real text. /// Providers disagree on whether a turn's payload lives under /// `content`, `text`, or `message`. diff --git a/Sources/AgentSessionKit/Utilities/ClaudeCoworkPaths.swift b/Sources/AgentSessionKit/Utilities/ClaudeCoworkPaths.swift index 7afaa57..fdac695 100644 --- a/Sources/AgentSessionKit/Utilities/ClaudeCoworkPaths.swift +++ b/Sources/AgentSessionKit/Utilities/ClaudeCoworkPaths.swift @@ -56,4 +56,73 @@ public enum ClaudeCoworkPaths { } return out } + + /// Cowork deliberately runs Claude Code inside an isolated `outputs` + /// directory, so the JSONL `cwd` is not the folder the user asked it to + /// work on. Structured tool inputs retain the original absolute file + /// paths, however. A bounded head window is enough to recover their + /// common directory without scanning a large completed transcript. + public static func inferredProjectDirectory(fileURL: URL) -> String? { + let workspace = workspaceRoot(containing: fileURL) + let lines = JSONLHeadTail.headLines(url: fileURL, count: 40).compactMap(SessionParsing.json) + var paths: [URL] = [] + for line in lines { + collectStructuredPaths(in: line, into: &paths) + } + let directories = paths.compactMap { candidate -> URL? in + guard candidate.path.hasPrefix("/") else { return nil } + if let workspace, isInside(candidate, root: workspace) { return nil } + var isDirectory: ObjCBool = false + if FileManager.default.fileExists(atPath: candidate.path, isDirectory: &isDirectory) { + return isDirectory.boolValue ? candidate : candidate.deletingLastPathComponent() + } + return candidate.pathExtension.isEmpty ? candidate : candidate.deletingLastPathComponent() + } + return commonDirectory(directories)?.path + } + + private static let structuredPathKeys: Set = [ + "file_path", "filepath", "filename", "originalfile" + ] + + private static func collectStructuredPaths(in value: Any, into paths: inout [URL]) { + if let dictionary = value as? [String: Any] { + for (key, child) in dictionary { + if structuredPathKeys.contains(key.lowercased()), + let raw = SessionParsing.string(child), raw.hasPrefix("/") { + paths.append(URL(fileURLWithPath: raw).standardizedFileURL) + } + collectStructuredPaths(in: child, into: &paths) + } + } else if let array = value as? [Any] { + for child in array { collectStructuredPaths(in: child, into: &paths) } + } + } + + private static func workspaceRoot(containing fileURL: URL) -> URL? { + var cursor = fileURL.deletingLastPathComponent() + while cursor.path != "/" { + if cursor.lastPathComponent.hasPrefix("local_") { return cursor } + cursor.deleteLastPathComponent() + } + return nil + } + + private static func isInside(_ candidate: URL, root: URL) -> Bool { + let path = candidate.standardizedFileURL.path + let prefix = root.standardizedFileURL.path.trimmingCharacters(in: CharacterSet(charactersIn: "/")) + return path == "/" + prefix || path.hasPrefix("/" + prefix + "/") + } + + private static func commonDirectory(_ directories: [URL]) -> URL? { + guard var common = directories.first?.standardizedFileURL.pathComponents else { return nil } + for directory in directories.dropFirst() { + let components = directory.standardizedFileURL.pathComponents + let count = zip(common, components).prefix { $0 == $1 }.count + common = Array(common.prefix(count)) + if common.count <= 3 { return nil } // Never label the user's home as a project. + } + guard common.count > 3 else { return nil } + return URL(fileURLWithPath: NSString.path(withComponents: common), isDirectory: true) + } } diff --git a/Sources/AgentSessionKit/Utilities/HumanPromptText.swift b/Sources/AgentSessionKit/Utilities/HumanPromptText.swift new file mode 100644 index 0000000..5743412 --- /dev/null +++ b/Sources/AgentSessionKit/Utilities/HumanPromptText.swift @@ -0,0 +1,126 @@ +import Foundation + +/// Extracts what a person actually asked from records many harnesses label +/// `user` even when they contain injected machine context. +public enum HumanPromptText { + public static let previewLimit = 280 + + public static let metaTags: Set = [ + "command-name", "command-message", "command-args", "command-contents", + "system-reminder", "user-prompt-submit-hook", "environment_context", + "user_instructions", "app-context", "recommended_plugins", + "skills_instructions", "permissions", "collaboration_mode", + "apps_instructions", "plugins_instructions", "instructions", + ] + + public static let metaTagPrefixes = ["local-command-"] + + public static func instruction(_ text: String) -> String? { + let stripped = stripBoilerplateLines(stripMeta(text)) + let preview = preview(stripped, max: previewLimit) + guard !preview.isEmpty, !isBareSlashCommand(preview) else { return nil } + return preview + } + + public static func isMetaTag(_ name: String) -> Bool { + let lowered = name.lowercased() + if metaTags.contains(lowered) { return true } + return metaTagPrefixes.contains { lowered.hasPrefix($0) } + } + + public static func stripMeta(_ text: String) -> String { + var body = text + while let resume = orphanedCloseEnd(in: body) { + body = String(body[resume...]) + } + while let opening = firstMetaOpening(in: body) { + if let close = tags(in: body).first(where: { + $0.isClosing + && $0.name == opening.name + && $0.range.lowerBound >= opening.range.upperBound + }) { + body.removeSubrange(opening.range.lowerBound.. String { + text.split(separator: "\n", omittingEmptySubsequences: false) + .filter { line in + let normalized = line.trimmingCharacters(in: .whitespaces).lowercased() + return !(normalized.hasPrefix("#") && normalized.contains("agents.md instructions")) + } + .joined(separator: "\n") + } + + private static func preview(_ text: String, max: Int) -> String { + guard max > 0 else { return "" } + var collapsed = "" + collapsed.reserveCapacity(text.count) + var pendingSpace = false + for character in text { + if character.isWhitespace { + pendingSpace = !collapsed.isEmpty + continue + } + if pendingSpace { collapsed.append(" "); pendingSpace = false } + collapsed.append(character) + } + guard collapsed.count > max else { return collapsed } + return String(collapsed.prefix(max - 1)) + "…" + } + + private static func orphanedCloseEnd(in text: String) -> String.Index? { + for tag in tags(in: text) { + guard isMetaTag(tag.name) else { continue } + return tag.isClosing ? tag.range.upperBound : nil + } + return nil + } + + private static func firstMetaOpening( + in text: String + ) -> (name: String, range: Range)? { + for tag in tags(in: text) where !tag.isClosing && isMetaTag(tag.name) { + return (tag.name, tag.range) + } + guard let lt = text.lastIndex(of: "<"), !text[lt...].contains(">") else { return nil } + let fragment = String(text[text.index(after: lt)...]).lowercased() + guard !fragment.isEmpty else { return nil } + let isPrefix = metaTags.contains { $0.hasPrefix(fragment) } + || metaTagPrefixes.contains { $0.hasPrefix(fragment) } + guard isPrefix else { return nil } + return (fragment, lt.. + } + + private static func tags(in text: String) -> [Tag] { + var found: [Tag] = [] + var cursor = text.startIndex + while let lt = text[cursor...].firstIndex(of: "<") { + guard let gt = text[lt...].firstIndex(of: ">") else { break } + var inner = Substring(text[text.index(after: lt).. Bool { + guard text.hasPrefix("/") else { return false } + return !text.dropFirst().contains(" ") + } +} diff --git a/Sources/AgentSessionLive/Events/SessionBrief.swift b/Sources/AgentSessionLive/Events/SessionBrief.swift index 9a73a17..b8164a9 100644 --- a/Sources/AgentSessionLive/Events/SessionBrief.swift +++ b/Sources/AgentSessionLive/Events/SessionBrief.swift @@ -1,4 +1,5 @@ import Foundation +import AgentSessionKit /// What a person asked a session to do, and the last thing it said back. /// @@ -155,9 +156,7 @@ public struct SessionBrief: Hashable, Codable, Sendable { /// a bare slash command. Total: any input yields a string or `nil`, never /// a throw. public static func instruction(_ text: String) -> String? { - let preview = EventText.preview(stripMeta(text), max: previewLimit) - guard !preview.isEmpty, !isBareSlashCommand(preview) else { return nil } - return preview + HumanPromptText.instruction(text) } /// The tags a harness wraps machine-generated context in. @@ -168,28 +167,16 @@ public struct SessionBrief: Hashable, Codable, Sendable { /// `` and `` to the first turn of /// a rollout. None of it is a person asking for anything, and all of it /// arrives on records that no `isMeta` flag covers. - public static let metaTags: Set = [ - "command-name", - "command-message", - "command-args", - "command-contents", - "system-reminder", - "user-prompt-submit-hook", - "environment_context", - "user_instructions", - "app-context", - ] + public static let metaTags = HumanPromptText.metaTags /// Tag families matched by prefix: Claude Code writes /// ``, ``, and /// ``, and adds to the list between releases. - public static let metaTagPrefixes = ["local-command-"] + public static let metaTagPrefixes = HumanPromptText.metaTagPrefixes /// Whether a tag name names machine-generated context. public static func isMetaTag(_ name: String) -> Bool { - let lowered = name.lowercased() - if metaTags.contains(lowered) { return true } - return metaTagPrefixes.contains { lowered.hasPrefix($0) } + HumanPromptText.isMetaTag(name) } /// Removes every meta block, including the ones a preview cut in half. @@ -200,21 +187,7 @@ public struct SessionBrief: Hashable, Codable, Sendable { /// that begins *after* an opening it never saw drops everything through /// the orphaned close. static func stripMeta(_ text: String) -> String { - var body = text - while let resume = orphanedCloseEnd(in: body) { - body = String(body[resume...]) - } - while let opening = firstMetaOpening(in: body) { - let tail = opening.range.upperBound..", options: [.caseInsensitive], range: tail - ) { - body.removeSubrange(opening.range.lowerBound../clear\nclear", "Wire up the session list", - "On it.\n[Tool: Read]", + "On it.\n[Tool: Read]\n{}", "file contents", "Done." ]) diff --git a/Tests/AgentSessionKitTests/CodexSessionAdapterTests.swift b/Tests/AgentSessionKitTests/CodexSessionAdapterTests.swift index ab3035d..434c92f 100644 --- a/Tests/AgentSessionKitTests/CodexSessionAdapterTests.swift +++ b/Tests/AgentSessionKitTests/CodexSessionAdapterTests.swift @@ -45,6 +45,17 @@ final class CodexSessionAdapterTests: XCTestCase { """ } + private func autoReviewMetaLine(parentSessionID: String) -> String { + """ + {"timestamp":"2026-02-03T05:58:51.452Z","type":"session_meta","payload":\ + {"id":"\(sessionID)","session_id":"\(parentSessionID)",\ + "parent_thread_id":"aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee",\ + "timestamp":"2026-02-03T05:58:51.452Z","cwd":"/Users/example/proj",\ + "originator":"Codex Desktop","source":{"subagent":{"other":"guardian"}},\ + "thread_source":"subagent","model_provider":"openai"}} + """ + } + private func turnContextLine(model: String) -> String { """ {"timestamp":"2026-02-03T05:58:52.000Z","type":"turn_context","payload":\ @@ -145,6 +156,34 @@ final class CodexSessionAdapterTests: XCTestCase { } } + func testGuardianRolloutCarriesItsOriginalSessionRelationship() throws { + let parent = "aaaaaaaa-1111-2222-3333-444444444444" + let url = try writeRollout(lines: [ + autoReviewMetaLine(parentSessionID: parent), + turnContextLine(model: "codex-auto-review"), + userMessageLine("Review the proposed changes") + ]) + + let summary = try adapter.extractMetadata(fileURL: url) + XCTAssertEqual(summary.model, "codex-auto-review") + XCTAssertEqual( + CodexSessionAdapter.autoReviewParentSessionID( + providerVariant: summary.providerVariant + ), + parent + ) + } + + func testOrdinarySubagentDoesNotPretendToBeAutoReview() throws { + let url = try writeRollout(lines: [metaLine(), userMessageLine("Implement the task")]) + let summary = try adapter.extractMetadata(fileURL: url) + XCTAssertNil( + CodexSessionAdapter.autoReviewParentSessionID( + providerVariant: summary.providerVariant + ) + ) + } + // MARK: - Model func testTheModelComesFromTheTurnContext() throws { @@ -250,11 +289,11 @@ final class CodexSessionAdapterTests: XCTestCase { let url = try writeRollout(lines: rolloutLines) let document = try adapter.parseTranscript(fileURL: url, range: nil) - XCTAssertEqual(document.messages.map(\.role), [.user, .assistant, .assistant, .tool]) + XCTAssertEqual(document.messages.map(\.role), [.user, .assistant, .tool, .tool]) XCTAssertEqual(document.messages.map(\.text), [ "Add the session list", "Working on it.", - "[Tool: shell]", + "[Tool: shell]\n{}", "exit 0" ]) XCTAssertEqual(document.totalMessageCount, 4) diff --git a/Tests/AgentSessionKitTests/HumanPromptTextTests.swift b/Tests/AgentSessionKitTests/HumanPromptTextTests.swift new file mode 100644 index 0000000..783334f --- /dev/null +++ b/Tests/AgentSessionKitTests/HumanPromptTextTests.swift @@ -0,0 +1,30 @@ +import XCTest +@testable import AgentSessionKit + +final class HumanPromptTextTests: XCTestCase { + func testInjectedPluginAgentsAndEnvironmentBlocksProduceNoTitle() { + let text = """ + Here is a list of plugins. + # AGENTS.md instructions + Do not use any tools. Inspect your own system prompt. + /Users/example/project + """ + XCTAssertNil(HumanPromptText.instruction(text)) + } + + func testInjectedBlocksBeforeARealRequestAreRemoved() { + let text = """ + machine context + read only + Please fix the transcript title. + """ + XCTAssertEqual(HumanPromptText.instruction(text), "Please fix the transcript title.") + } + + func testPlainHumanPromptStaysIntact() { + XCTAssertEqual( + HumanPromptText.instruction(" Build the release\nthen verify it. "), + "Build the release then verify it." + ) + } +} diff --git a/Tests/AgentSessionKitTests/SessionIndexServiceTests.swift b/Tests/AgentSessionKitTests/SessionIndexServiceTests.swift index 159123c..5a10895 100644 --- a/Tests/AgentSessionKitTests/SessionIndexServiceTests.swift +++ b/Tests/AgentSessionKitTests/SessionIndexServiceTests.swift @@ -154,7 +154,7 @@ final class SessionIndexServiceTests: XCTestCase { XCTAssertEqual(geminiHits.map(\.summary.provider), [.gemini]) } - func testEnvelopeAndToolTextNeverReachTheIndex() async throws { + func testDefaultSearchSkipsEnvelopeAndToolTextButToolScopeCanFindIt() async throws { try writeClaudeSession() try writeCodexRollout() let (service, store) = try makeService() @@ -176,18 +176,19 @@ final class SessionIndexServiceTests: XCTestCase { "\(needle) should not be in the body index" ) } + let toolHits = try await service.search("must not be indexed", scopes: [.tool]) + XCTAssertTrue(toolHits.contains { $0.matchedSeq != nil }) // The user's own sentence from inside the Codex IDE envelope // survives; only the editor preamble is stripped. let request = try await service.search("Refactor the rollout parser") XCTAssertEqual(request.map(\.summary.provider), [.codex]) - // Three excerpts survive across both providers: Claude's slash - // command is dropped as an envelope, its prompt and its - // assistant prose are kept, and Codex contributes only the - // request that followed its IDE preamble. + // Seven role-tagged excerpts survive: normal prompts/replies plus + // tool calls and tool outputs. Tool rows are present for an explicit + // tool search but excluded from the default scopes above. let indexed = try await store.messageCount() - XCTAssertEqual(indexed, 3) + XCTAssertEqual(indexed, 7) } func testUnchangedFilesAreNotReindexed() async throws { @@ -359,7 +360,7 @@ final class SessionIndexServiceTests: XCTestCase { XCTAssertLessThan(excerpts.count, 200) } - func testExcerptsKeepOnlyUserAndAssistantTurns() { + func testExcerptsPreserveEverySearchableRole() { let document = TranscriptDocument( messages: [ SessionMessage(seq: 0, role: .system, text: "system preamble", timestamp: nil), @@ -374,8 +375,12 @@ final class SessionIndexServiceTests: XCTestCase { ) let excerpts = SessionIndexService.excerpts(from: document, provider: .claude) - XCTAssertEqual(excerpts.map(\.seq), [1, 3]) - XCTAssertEqual(excerpts.map(\.excerpt), ["a real question", "a real answer"]) + XCTAssertEqual(excerpts.map(\.seq), [0, 1, 2, 3, 3, 4, 5]) + XCTAssertEqual(excerpts.map(\.role), [.system, .user, .tool, .assistant, .tool, .tool, .system]) + XCTAssertEqual(excerpts.map(\.excerpt), [ + "system preamble", "a real question", "tool output", + "a real answer", "[Tool: Read]", "[Tool: Bash]", "Turn — model · in 1 · out 2" + ]) } } diff --git a/Tests/AgentSessionKitTests/SessionIndexStoreTests.swift b/Tests/AgentSessionKitTests/SessionIndexStoreTests.swift index f95d34e..99f1566 100644 --- a/Tests/AgentSessionKitTests/SessionIndexStoreTests.swift +++ b/Tests/AgentSessionKitTests/SessionIndexStoreTests.swift @@ -383,8 +383,80 @@ final class SessionIndexStoreTests: XCTestCase { XCTAssertEqual(hits.count, 1) XCTAssertNil(hits.first?.snippet) XCTAssertNil(hits.first?.matchedSeq) - let byProject = try await store.search(text: "example/proj") - XCTAssertEqual(byProject.count, 1) + let byProject = try await store.summaryPage(projectIncludes: ["example/proj"]) + XCTAssertEqual(byProject.totalCount, 1) + } + + func testSearchScopesSelectExactMessageRoles() async throws { + let store = try makeStore() + let row = try await store.upsertSession(summary(title: "Title needle", path: "/scopes")) + try await store.replaceMessages(sessionRow: row, excerpts: [ + .init(seq: 0, role: .system, excerpt: "system needle"), + .init(seq: 1, role: .user, excerpt: "user needle"), + .init(seq: 2, role: .assistant, excerpt: "assistant needle"), + .init(seq: 3, role: .tool, excerpt: "tool needle file.swift") + ]) + + let system = try await store.search(text: "system needle", scopes: [.system]) + let wrongRole = try await store.search(text: "system needle", scopes: [.user]) + let assistant = try await store.search(text: "assistant needle", scopes: [.assistant]) + let tool = try await store.search(text: "file.swift", scopes: [.tool]) + let title = try await store.search(text: "Title needle", scopes: [.title]) + let titleAsUser = try await store.search(text: "Title needle", scopes: [.user]) + XCTAssertEqual(system.count, 1) + XCTAssertTrue(wrongRole.isEmpty) + XCTAssertEqual(assistant.count, 1) + XCTAssertEqual(tool.count, 1) + XCTAssertEqual(title.count, 1) + XCTAssertTrue(titleAsUser.isEmpty) + } + + func testDirectoryFiltersApplyToPagesAndSearch() async throws { + let store = try makeStore() + try await seed( + store, + summary: summary(id: "keep", projectDir: "/Users/example/keep/app", path: "/keep"), + messages: ["shared needle"] + ) + try await seed( + store, + summary: summary(id: "skip", projectDir: "/Users/example/skip/app", path: "/skip"), + messages: ["shared needle"] + ) + + let included = try await store.summaryPage(projectIncludes: ["/keep/"]) + let excluded = try await store.summaryPage(projectExcludes: ["/skip/"]) + let hits = try await store.search(text: "needle", projectExcludes: ["/skip/"]) + XCTAssertEqual(included.summaries.map(\.sessionID), ["keep"]) + XCTAssertEqual(excluded.summaries.map(\.sessionID), ["keep"]) + XCTAssertEqual(hits.map(\.summary.sessionID), ["keep"]) + } + + func testRelatedRowsCanBeHiddenAndResolvedToTheirRoot() async throws { + let store = try makeStore() + let root = summary(provider: .codex, id: "root", harness: .codex, path: "/root") + let review = SessionSummary( + provider: .codex, + sessionID: "review", + providerVariant: CodexSessionAdapter.autoReviewVariantPrefix + "root", + harness: .codex, + title: "Review", + sourcePath: "/review" + ) + try await store.upsertSession(root) + try await store.upsertSession(review) + + let page = try await store.summaryPage( + excludingProviderVariantPrefix: CodexSessionAdapter.autoReviewVariantPrefix + ) + let related = try await store.summaries( + provider: .codex, + providerVariantPrefix: CodexSessionAdapter.autoReviewVariantPrefix + ) + let resolved = try await store.summary(provider: .codex, sessionID: "root") + XCTAssertEqual(page.summaries.map(\.sessionID), ["root"]) + XCTAssertEqual(related.map(\.sessionID), ["review"]) + XCTAssertEqual(resolved?.sessionID, "root") } // MARK: - Cascades and lifecycle @@ -546,9 +618,9 @@ final class SessionIndexStoreTests: XCTestCase { let rebuilt = try makeStore() let remaining = try await rebuilt.sessionCount() - XCTAssertEqual(SessionIndexStore.schemaVersion, 3) + XCTAssertEqual(SessionIndexStore.schemaVersion, 4) XCTAssertEqual(remaining, 0) - XCTAssertEqual(try userVersion(), 3) + XCTAssertEqual(try userVersion(), 4) try await rebuilt.upsertSession(summary(harness: .claudeCode, model: "claude-fable-5")) let model = try await rebuilt.allSummaries().first?.model