diff --git a/Sources/CodeIsland/AppState.swift b/Sources/CodeIsland/AppState.swift index 33a8bf53..899a51c3 100644 --- a/Sources/CodeIsland/AppState.swift +++ b/Sources/CodeIsland/AppState.swift @@ -2281,6 +2281,7 @@ final class AppState { ("cursor", "\(home)/.cursor/projects"), ("copilot", "\(home)/.copilot/session-state"), ("opencode", "\(home)/.local/share/opencode"), + ("kimi", "\(home)/.kimi-code/sessions"), ("kimi", "\(home)/.kimi/sessions"), ] let fm = FileManager.default @@ -3239,12 +3240,14 @@ final class AppState { private nonisolated static func findKimiPids(candidatePids: [pid_t]? = nil) -> [pid_t] { findPids( matchingPathSubstrings: [ + "/.kimi-code/bin/kimi", "/.local/bin/kimi", "/.local/share/uv/tools/kimi-cli/", ], argSubstrings: [ "/kimi-cli/", "kimi_cli", + "/.kimi-code/", ], candidatePids: candidatePids ) @@ -3278,8 +3281,10 @@ final class AppState { let home = FileManager.default.homeDirectoryForCurrentUser.path let fm = FileManager.default - let sessionsBase = "\(home)/.kimi/sessions" - guard fm.fileExists(atPath: sessionsBase) else { return [] } + // Legacy kimi-cli hashes cwd under sessions/; kimi-code may only need + // session_index.jsonl, so do not bail when these dirs are absent. + let sessionsBases = ["\(home)/.kimi-code/sessions", "\(home)/.kimi/sessions"] + .filter { fm.fileExists(atPath: $0) } var results: [DiscoveredSession] = [] var seenSessionIds: Set = [] @@ -3287,53 +3292,135 @@ final class AppState { for pid in kimiPids { guard let cwd = getCwd(for: pid), !cwd.isEmpty, !isSubagentWorktree(cwd) else { continue } let processStart = getProcessStartTime(pid) - let workdirHash = md5Hash(of: cwd) - let workdirPath = "\(sessionsBase)/\(workdirHash)" - guard fm.fileExists(atPath: workdirPath), - let sessionDirs = try? fm.contentsOfDirectory(atPath: workdirPath) else { continue } - var bestPath: String? - var bestDate = Date.distantPast - var bestSessionId: String? - - for sessionId in sessionDirs { - let wirePath = "\(workdirPath)/\(sessionId)/wire.jsonl" - guard fm.fileExists(atPath: wirePath), - let attrs = try? fm.attributesOfItem(atPath: wirePath), - let modified = attrs[.modificationDate] as? Date, - modified > bestDate else { continue } - if let start = processStart, modified < start.addingTimeInterval(-10) { - continue - } - bestPath = wirePath - bestDate = modified - bestSessionId = sessionId + // Prefer kimi-code session_index.jsonl (workDir → sessionDir mapping). + if let indexed = discoverKimiCodeSessionFromIndex( + home: home, + cwd: cwd, + pid: pid, + processStart: processStart, + fm: fm + ), !seenSessionIds.contains(indexed.sessionId) { + seenSessionIds.insert(indexed.sessionId) + results.append(indexed) + continue } - guard let path = bestPath, let sessionId = bestSessionId else { continue } - let freshnessLimit: TimeInterval = processStart != nil ? -300 : -30 - if bestDate.timeIntervalSinceNow < freshnessLimit { continue } + // Legacy kimi-cli: ~/.kimi/sessions///wire.jsonl + let workdirHash = md5Hash(of: cwd) + for sessionsBase in sessionsBases { + let workdirPath = "\(sessionsBase)/\(workdirHash)" + guard fm.fileExists(atPath: workdirPath), + let sessionDirs = try? fm.contentsOfDirectory(atPath: workdirPath) else { continue } + + var bestPath: String? + var bestDate = Date.distantPast + var bestSessionId: String? + + for sessionId in sessionDirs { + let wirePath = "\(workdirPath)/\(sessionId)/wire.jsonl" + guard fm.fileExists(atPath: wirePath), + let attrs = try? fm.attributesOfItem(atPath: wirePath), + let modified = attrs[.modificationDate] as? Date, + modified > bestDate else { continue } + if let start = processStart, modified < start.addingTimeInterval(-10) { + continue + } + bestPath = wirePath + bestDate = modified + bestSessionId = sessionId + } - let (_, messages) = readRecentFromKimiTranscript(path: path) - guard !seenSessionIds.contains(sessionId) else { continue } - seenSessionIds.insert(sessionId) + guard let path = bestPath, let sessionId = bestSessionId else { continue } + let freshnessLimit: TimeInterval = processStart != nil ? -300 : -30 + if bestDate.timeIntervalSinceNow < freshnessLimit { continue } - results.append(DiscoveredSession( - sessionId: sessionId, - cwd: cwd, - tty: nil, - model: nil, - pid: pid, - modifiedAt: bestDate, - recentMessages: messages, - source: "kimi" - )) + let (_, messages) = readRecentFromKimiTranscript(path: path) + guard !seenSessionIds.contains(sessionId) else { continue } + seenSessionIds.insert(sessionId) + + results.append(DiscoveredSession( + sessionId: sessionId, + cwd: cwd, + tty: nil, + model: nil, + pid: pid, + modifiedAt: bestDate, + recentMessages: messages, + source: "kimi" + )) + break + } } return results } - private nonisolated static func readRecentFromKimiTranscript(path: String) -> (String?, [ChatMessage]) { + /// kimi-code tracks sessions in `~/.kimi-code/session_index.jsonl` with + /// `{ sessionId, sessionDir, workDir }` — workdir folders are no longer md5(cwd). + private nonisolated static func discoverKimiCodeSessionFromIndex( + home: String, + cwd: String, + pid: pid_t, + processStart: Date?, + fm: FileManager + ) -> DiscoveredSession? { + let indexPath = "\(home)/.kimi-code/session_index.jsonl" + guard fm.fileExists(atPath: indexPath), + let data = fm.contents(atPath: indexPath), + let text = String(data: data, encoding: .utf8) else { return nil } + + var best: (sessionId: String, sessionDir: String, modified: Date)? + for line in text.components(separatedBy: "\n") where !line.isEmpty { + guard let lineData = line.data(using: .utf8), + let json = try? JSONSerialization.jsonObject(with: lineData) as? [String: Any], + let workDir = json["workDir"] as? String, + workDir == cwd, + let sessionId = json["sessionId"] as? String, + let sessionDir = json["sessionDir"] as? String + else { continue } + + let statePath = "\(sessionDir)/state.json" + let stampPath = fm.fileExists(atPath: statePath) ? statePath : sessionDir + guard let attrs = try? fm.attributesOfItem(atPath: stampPath), + let modified = attrs[.modificationDate] as? Date else { continue } + if let start = processStart, modified < start.addingTimeInterval(-10) { + continue + } + if best == nil || modified > best!.modified { + best = (sessionId, sessionDir, modified) + } + } + + guard let match = best else { return nil } + let freshnessLimit: TimeInterval = processStart != nil ? -300 : -30 + if match.modified.timeIntervalSinceNow < freshnessLimit { return nil } + + // kimi-code keeps the transcript under agents/main/wire.jsonl. + let wirePath = "\(match.sessionDir)/agents/main/wire.jsonl" + let messages: [ChatMessage] + if fm.fileExists(atPath: wirePath) { + messages = readRecentFromKimiTranscript(path: wirePath).1 + } else { + messages = [] + } + + return DiscoveredSession( + sessionId: match.sessionId, + cwd: cwd, + tty: nil, + model: nil, + pid: pid, + modifiedAt: match.modified, + recentMessages: messages, + source: "kimi" + ) + } + + /// Parse recent chat turns from a Kimi wire.jsonl transcript. + /// Supports legacy kimi-cli (`message.type = TurnBegin|ContentPart|TurnEnd`) + /// and kimi-code (`turn.prompt` / `context.append_message` / `content.part`). + internal nonisolated static func readRecentFromKimiTranscript(path: String) -> (String?, [ChatMessage]) { guard let handle = FileHandle(forReadingAtPath: path) else { return (nil, []) } defer { handle.closeFile() } @@ -3347,59 +3434,91 @@ final class AppState { var previousUserText: String? var previousAssistantText: String = "" + func flushTurn() { + if let userText = previousUserText, !userText.isEmpty { + messages.append(ChatMessage(isUser: true, text: userText)) + if !previousAssistantText.isEmpty { + messages.append(ChatMessage(isUser: false, text: previousAssistantText)) + } + } + previousUserText = nil + previousAssistantText = "" + } + + func textParts(from value: Any?) -> String { + let parts: [[String: Any]] + if let typed = value as? [[String: Any]] { + parts = typed + } else if let anyParts = value as? [Any] { + parts = anyParts.compactMap { $0 as? [String: Any] } + } else { + return "" + } + return parts.compactMap { part -> String? in + if let type = part["type"] as? String, type != "text" { return nil } + return part["text"] as? String + }.joined() + } + for line in text.components(separatedBy: "\n") where !line.isEmpty { guard let lineData = line.data(using: .utf8), - let json = try? JSONSerialization.jsonObject(with: lineData) as? [String: Any], - let message = json["message"] as? [String: Any], - let type = message["type"] as? String + let json = try? JSONSerialization.jsonObject(with: lineData) as? [String: Any] else { continue } - switch type { - case "TurnBegin": - if let userText = previousUserText, !userText.isEmpty { - messages.append(ChatMessage(isUser: true, text: userText)) - if !previousAssistantText.isEmpty { - messages.append(ChatMessage(isUser: false, text: previousAssistantText)) + // Legacy kimi-cli envelope: { "message": { "type": "TurnBegin"|... } } + if let message = json["message"] as? [String: Any], + let type = message["type"] as? String { + switch type { + case "TurnBegin": + flushTurn() + if let payload = message["payload"] as? [String: Any] { + previousUserText = textParts(from: payload["user_input"]) } + case "ContentPart": + if let payload = message["payload"] as? [String: Any], + payload["type"] as? String == "text", + let textContent = payload["text"] as? String { + previousAssistantText += textContent + } + case "TurnEnd": + flushTurn() + default: + break } - previousUserText = nil - previousAssistantText = "" - if let payload = message["payload"] as? [String: Any], - let userInput = payload["user_input"] as? [[String: Any]] { - let texts = userInput.compactMap { part -> String? in - guard part["type"] as? String == "text" else { return nil } - return part["text"] as? String + continue + } + + // kimi-code wire protocol (v1.4+). + switch json["type"] as? String { + case "turn.prompt": + flushTurn() + previousUserText = textParts(from: json["input"]) + case "context.append_message": + if let message = json["message"] as? [String: Any], + message["role"] as? String == "user" { + let userText = textParts(from: message["content"]) + if !userText.isEmpty { + // Prefer turn.prompt when both exist for the same turn; + // only start a turn here if we don't already have one open. + if previousUserText == nil { + previousUserText = userText + } } - previousUserText = texts.joined() } - case "ContentPart": - if let payload = message["payload"] as? [String: Any], - payload["type"] as? String == "text", - let textContent = payload["text"] as? String { + case "context.append_loop_event": + if let event = json["event"] as? [String: Any], + event["type"] as? String == "content.part", + let part = event["part"] as? [String: Any], + part["type"] as? String == "text", + let textContent = part["text"] as? String { previousAssistantText += textContent } - case "TurnEnd": - if let userText = previousUserText, !userText.isEmpty { - messages.append(ChatMessage(isUser: true, text: userText)) - if !previousAssistantText.isEmpty { - messages.append(ChatMessage(isUser: false, text: previousAssistantText)) - } - } - previousUserText = nil - previousAssistantText = "" default: break } } - // flush final turn - if let userText = previousUserText, !userText.isEmpty { - messages.append(ChatMessage(isUser: true, text: userText)) - if !previousAssistantText.isEmpty { - messages.append(ChatMessage(isUser: false, text: previousAssistantText)) - } - } - + flushTurn() return (nil, Array(messages.suffix(3))) } diff --git a/Sources/CodeIsland/ConfigInstaller.swift b/Sources/CodeIsland/ConfigInstaller.swift index 1470b67e..985269f8 100644 --- a/Sources/CodeIsland/ConfigInstaller.swift +++ b/Sources/CodeIsland/ConfigInstaller.swift @@ -30,7 +30,8 @@ enum HookFormat { case traecli /// GitHub Copilot CLI style: [{type, bash, timeoutSec}] with top-level version case copilot - /// Kimi Code CLI style: TOML [[hooks]] arrays in ~/.kimi/config.toml + /// Kimi Code CLI style: TOML [[hooks]] arrays in ~/.kimi-code/config.toml + /// (legacy kimi-cli used ~/.kimi/config.toml). case kimi /// Kiro CLI style: per-agent JSON file at ~/.kiro/agents/.json /// with hooks keyed by camelCase event names and `timeout_ms` (#127). @@ -194,6 +195,44 @@ struct ConfigInstaller { return raw.isEmpty ? "~/.codex/\(filename)" : "$CODEX_HOME/\(filename)" } + // MARK: - Kimi Code home resolution + + /// Modern Kimi Code CLI data root (`~/.kimi-code`). Prefer this over legacy + /// kimi-cli (`~/.kimi`) per https://www.kimi.com/code/docs/kimi-code-cli/guides/migration.html + static func kimiCodeHome() -> String { NSHomeDirectory() + "/.kimi-code" } + + /// Legacy kimi-cli data root (`~/.kimi`). Migration leaves this intact. + static func kimiLegacyHome() -> String { NSHomeDirectory() + "/.kimi" } + + /// Resolve the Kimi config directory to use for hooks install/status. + /// Prefers `~/.kimi-code` when present; falls back to `~/.kimi`; defaults to + /// the modern path when neither exists (display / future install target). + static func kimiHome(fm: FileManager = .default) -> String { + let modern = kimiCodeHome() + let legacy = kimiLegacyHome() + if fm.fileExists(atPath: modern) { return modern } + if fm.fileExists(atPath: legacy) { return legacy } + return modern + } + + /// Whether any Kimi Code / kimi-cli install footprint is on this machine. + static func kimiPresenceDetected(fm: FileManager = .default) -> Bool { + let modern = kimiCodeHome() + let legacy = kimiLegacyHome() + return fm.fileExists(atPath: modern) + || fm.fileExists(atPath: legacy) + || fm.isExecutableFile(atPath: modern + "/bin/kimi") + || fm.fileExists(atPath: modern + "/config.toml") + || fm.fileExists(atPath: legacy + "/config.toml") + } + + static func displayKimiConfigPath(fm: FileManager = .default) -> String { + let home = kimiHome(fm: fm) + if home.hasSuffix("/.kimi-code") { return "~/.kimi-code/config.toml" } + if home.hasSuffix("/.kimi") { return "~/.kimi/config.toml" } + return "~/.kimi-code/config.toml" + } + // MARK: - All supported CLIs private static let builtInCLIs: [CLIConfig] = [ @@ -409,12 +448,15 @@ struct ConfigInstaller { ("errorOccurred", 5, true), ] ), - // Kimi Code CLI — TOML hooks in ~/.kimi/config.toml + // Kimi Code CLI — TOML hooks in ~/.kimi-code/config.toml (legacy: ~/.kimi). + // See https://www.kimi.com/code/docs/kimi-code-cli/customization/hooks.html CLIConfig( name: "Kimi Code CLI", source: "kimi", - configPath: ".kimi/config.toml", configKey: "hooks", + configPath: "config.toml", configKey: "hooks", format: .kimi, - events: defaultEvents(for: .kimi) + events: defaultEvents(for: .kimi), + rootOverride: { ConfigInstaller.kimiHome() }, + displayPathOverride: { ConfigInstaller.displayKimiConfigPath() } ), // Kiro CLI — agent-scoped JSON at ~/.kiro/agents/codeisland.json. // User must launch with `kiro --agent codeisland` for hooks to fire (#127). @@ -876,6 +918,8 @@ struct ConfigInstaller { // ZCode's config lives one level below the app's real root (~/.zcode/cli/), // so detect against the root itself rather than cli.dirPath (#245). if source == "zcode" { return FileManager.default.fileExists(atPath: NSHomeDirectory() + "/.zcode") } + // Kimi Code CLI moved from ~/.kimi (kimi-cli) to ~/.kimi-code. + if source == "kimi" { return kimiPresenceDetected() } guard let cli = allCLIs.first(where: { $0.source == source }) else { return false } return FileManager.default.fileExists(atPath: cli.dirPath) } @@ -1251,17 +1295,12 @@ struct ConfigInstaller { static func installExternalHooks(cli: CLIConfig, fm: FileManager) -> Bool { if cli.format == .cline { return installClineHooks(cli: cli, fm: fm) } if cli.format == .kimi { - // Kimi: do not create ~/.kimi or config files unless there is already - // evidence of an existing Kimi installation/configuration. - let rootDir = NSHomeDirectory() + "/.kimi" - let sessionsDir = rootDir + "/sessions" - let hasKimiPresence = - fm.fileExists(atPath: cli.dirPath) || - fm.fileExists(atPath: rootDir) || - fm.fileExists(atPath: sessionsDir) - guard hasKimiPresence else { return true } - if !fm.fileExists(atPath: cli.dirPath) { - try? fm.createDirectory(atPath: cli.dirPath, withIntermediateDirectories: true) + // Kimi: do not create ~/.kimi-code (or legacy ~/.kimi) unless there is + // already evidence of an existing install. Prefer the modern root. + guard kimiPresenceDetected(fm: fm) else { return true } + let rootDir = kimiHome(fm: fm) + if !fm.fileExists(atPath: rootDir) { + try? fm.createDirectory(atPath: rootDir, withIntermediateDirectories: true) } return installKimiHooks(cli: cli, fm: fm) } @@ -2373,12 +2412,18 @@ struct ConfigInstaller { } private static func isKimiHooksInstalled(cli: CLIConfig, fm: FileManager) -> Bool { - guard fm.fileExists(atPath: cli.fullPath), - let data = fm.contents(atPath: cli.fullPath), - let contents = String(data: data, encoding: .utf8) else { return false } - - return cli.events.allSatisfy { (event, _, _) in - contentsContainsKimiHook(contents, event: event) + // Prefer modern home, but also treat legacy ~/.kimi as installed if our + // hooks are still there (migration leaves the old tree intact). + var candidates = [kimiCodeHome() + "/config.toml", kimiLegacyHome() + "/config.toml"] + let resolved = cli.fullPath + if !candidates.contains(resolved) { candidates.append(resolved) } + return candidates.contains { path in + guard fm.fileExists(atPath: path), + let data = fm.contents(atPath: path), + let contents = String(data: data, encoding: .utf8) else { return false } + return cli.events.allSatisfy { (event, _, _) in + contentsContainsKimiHook(contents, event: event) + } } } @@ -2421,31 +2466,38 @@ struct ConfigInstaller { return } if cli.format == .kimi { - guard fm.fileExists(atPath: cli.fullPath), - let data = fm.contents(atPath: cli.fullPath), - var contents = String(data: data, encoding: .utf8) else { return } - contents = removeKimiHooks(from: contents) - - // Restore commented-out legacy scalar hooks - let lines = contents.components(separatedBy: "\n") - var restored: [String] = [] - for line in lines { - let trimmed = line.trimmingCharacters(in: .whitespaces) - if trimmed == "# [CodeIsland] commented out legacy scalar hooks to avoid TOML conflict" { - continue + // Scrub modern + legacy homes; also honor absolute cli.fullPath + // (hermetic tests / custom roots). + var paths = [kimiCodeHome() + "/config.toml", kimiLegacyHome() + "/config.toml"] + let resolved = cli.fullPath + if !paths.contains(resolved) { paths.append(resolved) } + for path in paths { + guard fm.fileExists(atPath: path), + let data = fm.contents(atPath: path), + var contents = String(data: data, encoding: .utf8) else { continue } + contents = removeKimiHooks(from: contents) + + // Restore commented-out legacy scalar hooks + let lines = contents.components(separatedBy: "\n") + var restored: [String] = [] + for line in lines { + let trimmed = line.trimmingCharacters(in: .whitespaces) + if trimmed == "# [CodeIsland] commented out legacy scalar hooks to avoid TOML conflict" { + continue + } + if trimmed.range(of: #"^#\s*hooks\s*="#, options: .regularExpression) != nil { + restored.append(line.replacingOccurrences(of: #"^#\s*"#, with: "", options: .regularExpression)) + } else { + restored.append(line) + } } - if trimmed.range(of: #"^#\s*hooks\s*="#, options: .regularExpression) != nil { - restored.append(line.replacingOccurrences(of: #"^#\s*"#, with: "", options: .regularExpression)) - } else { - restored.append(line) + while let last = restored.last, last.trimmingCharacters(in: .whitespaces).isEmpty { + restored.removeLast() } - } - while let last = restored.last, last.trimmingCharacters(in: .whitespaces).isEmpty { - restored.removeLast() - } - contents = restored.joined(separator: "\n") + contents = restored.joined(separator: "\n") - fm.createFile(atPath: cli.fullPath, contents: contents.data(using: .utf8)) + fm.createFile(atPath: path, contents: contents.data(using: .utf8)) + } return } diff --git a/Sources/CodeIslandCore/SessionSnapshot.swift b/Sources/CodeIslandCore/SessionSnapshot.swift index ffa7dec0..231f0406 100644 --- a/Sources/CodeIslandCore/SessionSnapshot.swift +++ b/Sources/CodeIslandCore/SessionSnapshot.swift @@ -180,6 +180,12 @@ public struct SessionSnapshot: Sendable { "qoder work": "qoderwork", "kimi-cli": "kimi", "kimicli": "kimi", + "kimi-code": "kimi", + "kimicode": "kimi", + "kimi_code": "kimi", + "kimi-code-cli": "kimi", + "kimicodecli": "kimi", + "kimi_code_cli": "kimi", "kiro-cli": "kiro", "kirocli": "kiro", "codebuddycn": "codybuddycn", @@ -290,7 +296,7 @@ public struct SessionSnapshot: Sendable { static let agentMetadataDirNames: Set = [ ".claude", ".cursor", ".codex", ".gemini", ".qoder", ".qoderwork", ".trae", ".trae-cn", ".kiro", ".copilot", ".factory", ".codebuddy", - ".codybuddycn", ".stepfun", ".workbuddy", ".hermes", ".kimi", + ".codybuddycn", ".stepfun", ".workbuddy", ".hermes", ".kimi", ".kimi-code", ".pi", ".omp", ".qwen", ".zcode", ".openclaw", ".codeisland", ] @@ -1256,15 +1262,44 @@ public func extractMetadata(into sessions: inout [String: SessionSnapshot], sess } } +/// Pull a non-empty string from a hook JSON value. +/// Supports plain strings and content-part arrays such as Kimi Code CLI's +/// `prompt: [{ "type": "text", "text": "..." }]`. +private func stringFromHookJSONValue(_ value: Any?) -> String? { + if let value = value as? String { + // Trim only to detect empty / whitespace-only payloads; return the + // original value so callers preserve any leading/trailing + // whitespace inside code-snippet prompts and multi-line content. + return value.trimmingCharacters(in: CharacterSet.whitespacesAndNewlines).isEmpty + ? nil : value + } + + let parts: [[String: Any]] + if let typed = value as? [[String: Any]] { + parts = typed + } else if let anyParts = value as? [Any] { + parts = anyParts.compactMap { $0 as? [String: Any] } + if parts.isEmpty { return nil } + } else { + return nil + } + + let texts = parts.compactMap { part -> String? in + if let type = part["type"] as? String, type != "text" { return nil } + guard let text = part["text"] as? String, + !text.trimmingCharacters(in: CharacterSet.whitespacesAndNewlines).isEmpty else { + return nil + } + return text + } + let joined = texts.joined() + return joined.isEmpty ? nil : joined +} + private func firstStringFromDict(_ dict: [String: Any], keys: [String]) -> String? { for key in keys { - if let value = dict[key] as? String { - // Trim only to detect empty / whitespace-only payloads; return the - // original value so callers preserve any leading/trailing - // whitespace inside code-snippet prompts and multi-line content. - if !value.trimmingCharacters(in: CharacterSet.whitespacesAndNewlines).isEmpty { - return value - } + if let value = stringFromHookJSONValue(dict[key]) { + return value } } return nil diff --git a/Tests/CodeIslandCoreTests/KimiHookPayloadTests.swift b/Tests/CodeIslandCoreTests/KimiHookPayloadTests.swift new file mode 100644 index 00000000..97b83375 --- /dev/null +++ b/Tests/CodeIslandCoreTests/KimiHookPayloadTests.swift @@ -0,0 +1,65 @@ +import XCTest +@testable import CodeIslandCore + +/// Kimi Code CLI sends UserPromptSubmit.prompt as a content-part array, not a string. +final class KimiHookPayloadTests: XCTestCase { + private func apply(_ payload: [String: Any], to sessions: inout [String: SessionSnapshot]) throws -> [SideEffect] { + let data = try JSONSerialization.data(withJSONObject: payload) + let event = try XCTUnwrap(HookEvent(from: data)) + return reduceEvent(sessions: &sessions, event: event, maxHistory: 20) + } + + func testKimiUserPromptSubmitContentPartArrayBecomesChatText() throws { + let sessionId = "session_kimi_prompt_array" + var sessions: [String: SessionSnapshot] = [:] + + _ = try apply([ + "hook_event_name": "UserPromptSubmit", + "session_id": sessionId, + "_source": "kimi", + "cwd": "/Users/dev/Code", + "prompt": [ + ["type": "text", "text": "Optimize the node parameter menu"], + ], + ], to: &sessions) + + let session = try XCTUnwrap(sessions[sessionId]) + XCTAssertEqual(session.status, .processing) + XCTAssertEqual(session.lastUserPrompt, "Optimize the node parameter menu") + XCTAssertEqual(session.recentMessages.last?.isUser, true) + XCTAssertEqual(session.recentMessages.last?.text, "Optimize the node parameter menu") + } + + func testKimiUserPromptSubmitJoinsMultipleTextParts() throws { + let sessionId = "session_kimi_prompt_multi" + var sessions: [String: SessionSnapshot] = [:] + + _ = try apply([ + "hook_event_name": "UserPromptSubmit", + "session_id": sessionId, + "_source": "kimi", + "prompt": [ + ["type": "text", "text": "Hello "], + ["type": "text", "text": "world"], + ["type": "image", "url": "https://example.com/a.png"], + ], + ], to: &sessions) + + let session = try XCTUnwrap(sessions[sessionId]) + XCTAssertEqual(session.lastUserPrompt, "Hello world") + } + + func testPlainStringPromptStillWorks() throws { + let sessionId = "session_plain_prompt" + var sessions: [String: SessionSnapshot] = [:] + + _ = try apply([ + "hook_event_name": "UserPromptSubmit", + "session_id": sessionId, + "_source": "claude", + "prompt": "Keep string prompts working", + ], to: &sessions) + + XCTAssertEqual(sessions[sessionId]?.lastUserPrompt, "Keep string prompts working") + } +} diff --git a/Tests/CodeIslandTests/ConfigInstallerTests.swift b/Tests/CodeIslandTests/ConfigInstallerTests.swift index a4e26cb7..df627d3b 100644 --- a/Tests/CodeIslandTests/ConfigInstallerTests.swift +++ b/Tests/CodeIslandTests/ConfigInstallerTests.swift @@ -254,6 +254,30 @@ final class ConfigInstallerTests: XCTestCase { XCTAssertFalse(ConfigInstaller.contentsContainsKimiHook(toml, event: "SessionStart")) } + func testKimiHomePrefersKimiCodeOverLegacy() throws { + let fm = FileManager.default + let tempHome = fm.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try fm.createDirectory(at: tempHome, withIntermediateDirectories: true) + defer { try? fm.removeItem(at: tempHome) } + + // Simulate HOME via NSHomeDirectory is not overridable easily; exercise + // path helpers against the real home by checking relative naming only. + let modern = ConfigInstaller.kimiCodeHome() + let legacy = ConfigInstaller.kimiLegacyHome() + XCTAssertTrue(modern.hasSuffix("/.kimi-code")) + XCTAssertTrue(legacy.hasSuffix("/.kimi")) + XCTAssertNotEqual(modern, legacy) + + // When ~/.kimi-code exists on this machine (kimi-code install), presence + // and preferred home must point at the modern root. + if fm.fileExists(atPath: modern) { + XCTAssertTrue(ConfigInstaller.kimiPresenceDetected()) + XCTAssertEqual(ConfigInstaller.kimiHome(), modern) + XCTAssertTrue(ConfigInstaller.cliExists(source: "kimi")) + XCTAssertEqual(ConfigInstaller.displayKimiConfigPath(), "~/.kimi-code/config.toml") + } + } + func testKimiHookFormatEvents() { let events = ConfigInstaller.defaultEvents(for: .kimi) let eventNames = events.map { $0.0 } @@ -272,7 +296,8 @@ final class ConfigInstallerTests: XCTestCase { XCTAssertEqual(notificationTimeout, 600, "Kimi max timeout is 600") } - /// Hermetic integration test: uses a temporary directory instead of touching ~/.kimi/config.toml. + /// Hermetic integration test: uses a temporary directory instead of touching + /// ~/.kimi-code/config.toml (or legacy ~/.kimi/config.toml). func testInstallKimiHooksIntegration() throws { let fm = FileManager.default let tempDir = fm.temporaryDirectory.appendingPathComponent(UUID().uuidString) diff --git a/Tests/CodeIslandTests/KimiTranscriptTests.swift b/Tests/CodeIslandTests/KimiTranscriptTests.swift new file mode 100644 index 00000000..30e7dbfe --- /dev/null +++ b/Tests/CodeIslandTests/KimiTranscriptTests.swift @@ -0,0 +1,50 @@ +import XCTest +@testable import CodeIsland + +final class KimiTranscriptTests: XCTestCase { + func testReadsKimiCodeWireProtocol() throws { + let fm = FileManager.default + let dir = fm.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try fm.createDirectory(at: dir, withIntermediateDirectories: true) + defer { try? fm.removeItem(at: dir) } + + let path = dir.appendingPathComponent("wire.jsonl").path + let lines = [ + #"{"type":"turn.prompt","input":[{"type":"text","text":"First question"}],"origin":{"kind":"user"}}"#, + #"{"type":"context.append_loop_event","event":{"type":"content.part","part":{"type":"think","think":"ignore"}}}"#, + #"{"type":"context.append_loop_event","event":{"type":"content.part","part":{"type":"text","text":"First answer"}}}"#, + #"{"type":"turn.prompt","input":[{"type":"text","text":"Second question"}],"origin":{"kind":"user"}}"#, + #"{"type":"context.append_loop_event","event":{"type":"content.part","part":{"type":"text","text":"Second "}}}"#, + #"{"type":"context.append_loop_event","event":{"type":"content.part","part":{"type":"text","text":"answer"}}}"#, + ] + try lines.joined(separator: "\n").write(toFile: path, atomically: true, encoding: .utf8) + + let messages = AppState.readRecentFromKimiTranscript(path: path).1 + // Reader keeps only the last 3 chat rows. + XCTAssertEqual(messages.map(\.isUser), [false, true, false]) + XCTAssertEqual(messages.map(\.text), [ + "First answer", + "Second question", + "Second answer", + ]) + } + + func testReadsLegacyKimiCliWireProtocol() throws { + let fm = FileManager.default + let dir = fm.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try fm.createDirectory(at: dir, withIntermediateDirectories: true) + defer { try? fm.removeItem(at: dir) } + + let path = dir.appendingPathComponent("wire.jsonl").path + let lines = [ + #"{"message":{"type":"TurnBegin","payload":{"user_input":[{"type":"text","text":"Legacy hi"}]}}}"#, + #"{"message":{"type":"ContentPart","payload":{"type":"text","text":"Legacy bye"}}}"#, + #"{"message":{"type":"TurnEnd"}}"#, + ] + try lines.joined(separator: "\n").write(toFile: path, atomically: true, encoding: .utf8) + + let messages = AppState.readRecentFromKimiTranscript(path: path).1 + XCTAssertEqual(messages.map(\.text), ["Legacy hi", "Legacy bye"]) + XCTAssertEqual(messages.map(\.isUser), [true, false]) + } +}