diff --git a/Sources/SessionIndexRegisteredAgents.swift b/Sources/SessionIndexRegisteredAgents.swift index 5548a64df404..08e42c634a23 100644 --- a/Sources/SessionIndexRegisteredAgents.swift +++ b/Sources/SessionIndexRegisteredAgents.swift @@ -151,15 +151,17 @@ extension SessionIndexStore { metadata.branch = firstString(in: object, keys: ["gitBranch", "branch"]) } if metadata.title.isEmpty { - metadata.title = firstString(in: object, keys: ["title", "prompt", "text", "content"]) ?? "" + metadata.title = firstTopLevelTitle(in: object) ?? "" } if metadata.title.isEmpty, let message = object["message"] as? [String: Any] { - metadata.title = firstString(in: message, keys: ["content", "text"]) ?? "" + if shouldUseMessageAsTitle(message) { + metadata.title = firstText(in: message, keys: ["content", "text"]) ?? "" + } } if metadata.title.isEmpty, let messages = object["messages"] as? [[String: Any]] { metadata.title = messages.compactMap { message in - firstString(in: message, keys: ["role"]) == "user" - ? firstString(in: message, keys: ["content", "text"]) + shouldUseMessageAsTitle(message) + ? firstText(in: message, keys: ["content", "text"]) : nil }.first ?? "" } @@ -208,6 +210,65 @@ extension SessionIndexStore { return nil } + nonisolated private static func firstText(in object: [String: Any], keys: [String]) -> String? { + for key in keys { + guard let text = firstTextValue(object[key]) else { continue } + return text + } + return nil + } + + nonisolated private static func firstTopLevelTitle(in object: [String: Any]) -> String? { + if let title = firstText(in: object, keys: ["title", "prompt"]) { + return title + } + guard shouldUseMessageAsTitle(object) else { return nil } + return firstText(in: object, keys: ["text", "content"]) + } + + nonisolated private static func firstTextValue(_ value: Any?) -> String? { + if let string = value as? String { + return trimmedNonEmpty(string) + } + if let values = value as? [Any] { + for value in values { + if let text = firstTextBlock(value) { + return text + } + } + } + if let block = value as? [String: Any] { + return firstTextBlock(block) + } + return nil + } + + nonisolated private static func firstTextBlock(_ value: Any) -> String? { + if let string = value as? String { + return trimmedNonEmpty(string) + } + guard let block = value as? [String: Any] else { return nil } + guard let type = firstString(in: block, keys: ["type"]), + type.caseInsensitiveCompare("text") == .orderedSame else { + return nil + } + return firstString(in: block, keys: ["text"]) + } + + nonisolated private static func trimmedNonEmpty(_ value: String) -> String? { + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed + } + + nonisolated private static func shouldUseMessageAsTitle(_ message: [String: Any]) -> Bool { + let role = firstString(in: message, keys: ["role"]) + return role == nil || isUserRole(role) + } + + nonisolated private static func isUserRole(_ role: String?) -> Bool { + role?.caseInsensitiveCompare("user") == .orderedSame + } + nonisolated private static func piCWDInferred(from url: URL) -> String? { let directoryName = url.deletingLastPathComponent().lastPathComponent guard directoryName.hasPrefix("--"), directoryName.hasSuffix("--"), directoryName.count > 4 else { diff --git a/cmuxTests/PiVaultAgentPersistenceTests.swift b/cmuxTests/PiVaultAgentPersistenceTests.swift index 5a3c7b04d46d..988bb92fa5da 100644 --- a/cmuxTests/PiVaultAgentPersistenceTests.swift +++ b/cmuxTests/PiVaultAgentPersistenceTests.swift @@ -263,6 +263,128 @@ final class PiVaultAgentPersistenceTests: XCTestCase { XCTAssertEqual(entry.gitBranch, "issue-3575-vault-pi-agent-support") } + func testPiJSONLTypedContentBlocksUseFirstUserTextAsTitle() async throws { + let tempDir = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-pi-vault-title-blocks-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: tempDir) } + + let cwd = "/tmp/pi typed blocks" + let projectDirectory = try XCTUnwrap(PiSessionLocator.projectDirectoryName(for: cwd)) + let sessionDir = tempDir.appendingPathComponent(projectDirectory, isDirectory: true) + try FileManager.default.createDirectory(at: sessionDir, withIntermediateDirectories: true) + let sessionFile = sessionDir.appendingPathComponent("019e1c86-def0-72c9-90d4-8543db20f981.jsonl") + try """ + {"type":"message","message":{"role":"assistant","content":[{"type":"text","text":"assistant preface"}]}} + {"type":"message","message":{"role":"user","content":[{"type":"text","text":"ping"}]}} + """.write(to: sessionFile, atomically: true, encoding: .utf8) + + var registration = CmuxVaultAgentRegistration.builtInPi + registration.sessionDirectory = tempDir.path + let entries = await SessionIndexStore.loadRegisteredAgentEntries( + registration: registration, + needle: "", + cwdFilter: cwd, + offset: 0, + limit: 10 + ) + + let entry = try XCTUnwrap(entries.first) + XCTAssertEqual(entry.title, "ping") + XCTAssertEqual(entry.cwd, cwd) + } + + func testPiJSONLTopLevelAssistantTypedContentDoesNotBecomeTitle() async throws { + let tempDir = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-pi-vault-top-level-role-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: tempDir) } + + let cwd = "/tmp/pi top level role" + let projectDirectory = try XCTUnwrap(PiSessionLocator.projectDirectoryName(for: cwd)) + let sessionDir = tempDir.appendingPathComponent(projectDirectory, isDirectory: true) + try FileManager.default.createDirectory(at: sessionDir, withIntermediateDirectories: true) + let sessionFile = sessionDir.appendingPathComponent("019e1c86-def0-72c9-90d4-8543db20f982.jsonl") + try """ + {"role":"assistant","content":[{"type":"text","text":"assistant preface"}]} + {"role":"user","content":[{"type":"text","text":"implement the vault view"}]} + """.write(to: sessionFile, atomically: true, encoding: .utf8) + + var registration = CmuxVaultAgentRegistration.builtInPi + registration.sessionDirectory = tempDir.path + let entries = await SessionIndexStore.loadRegisteredAgentEntries( + registration: registration, + needle: "", + cwdFilter: cwd, + offset: 0, + limit: 10 + ) + + let entry = try XCTUnwrap(entries.first) + XCTAssertEqual(entry.title, "implement the vault view") + XCTAssertEqual(entry.cwd, cwd) + } + + func testPiJSONLMessagesArrayUsesNilRoleTextAsTitle() async throws { + let tempDir = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-pi-vault-messages-nil-role-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: tempDir) } + + let cwd = "/tmp/pi nil role" + let projectDirectory = try XCTUnwrap(PiSessionLocator.projectDirectoryName(for: cwd)) + let sessionDir = tempDir.appendingPathComponent(projectDirectory, isDirectory: true) + try FileManager.default.createDirectory(at: sessionDir, withIntermediateDirectories: true) + let sessionFile = sessionDir.appendingPathComponent("019e1c86-def0-72c9-90d4-8543db20f983.jsonl") + try """ + {"messages":[{"content":[{"type":"text","text":"restore without role"}]},{"role":"assistant","content":[{"type":"text","text":"assistant reply"}]}]} + """.write(to: sessionFile, atomically: true, encoding: .utf8) + + var registration = CmuxVaultAgentRegistration.builtInPi + registration.sessionDirectory = tempDir.path + let entries = await SessionIndexStore.loadRegisteredAgentEntries( + registration: registration, + needle: "", + cwdFilter: cwd, + offset: 0, + limit: 10 + ) + + let entry = try XCTUnwrap(entries.first) + XCTAssertEqual(entry.title, "restore without role") + XCTAssertEqual(entry.cwd, cwd) + } + + func testPiJSONLTypedContentBlocksRequireTextType() async throws { + let tempDir = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-pi-vault-typed-content-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: tempDir) } + + let cwd = "/tmp/pi typed content" + let projectDirectory = try XCTUnwrap(PiSessionLocator.projectDirectoryName(for: cwd)) + let sessionDir = tempDir.appendingPathComponent(projectDirectory, isDirectory: true) + try FileManager.default.createDirectory(at: sessionDir, withIntermediateDirectories: true) + let sessionFile = sessionDir.appendingPathComponent("019e1c86-def0-72c9-90d4-8543db20f984.jsonl") + try """ + {"message":{"role":"user","content":[{"text":"untyped object"},{"type":"image","text":"image fallback"},{"type":"text","text":"typed text title"}]}} + """.write(to: sessionFile, atomically: true, encoding: .utf8) + + var registration = CmuxVaultAgentRegistration.builtInPi + registration.sessionDirectory = tempDir.path + let entries = await SessionIndexStore.loadRegisteredAgentEntries( + registration: registration, + needle: "", + cwdFilter: cwd, + offset: 0, + limit: 10 + ) + + let entry = try XCTUnwrap(entries.first) + XCTAssertEqual(entry.title, "typed text title") + XCTAssertEqual(entry.cwd, cwd) + } + func testPiVaultAgentSnapshotRoundTripBuildsTargetedSessionCommand() throws { let tempDir = FileManager.default.temporaryDirectory .appendingPathComponent("cmux-pi-vault-agent-\(UUID().uuidString)", isDirectory: true)