diff --git a/Sources/CodeIsland/AppState.swift b/Sources/CodeIsland/AppState.swift index 4c7c2144..587f94d0 100644 --- a/Sources/CodeIsland/AppState.swift +++ b/Sources/CodeIsland/AppState.swift @@ -1362,7 +1362,8 @@ final class AppState { // approved in the terminal — resume those (and only those) as approved. resolveOrphanPermissionsOnActivity(event) - let effects = reduceEvent(sessions: &sessions, event: event, maxHistory: maxHistory) + let effects = reduceEvent(sessions: &sessions, event: event, maxHistory: maxHistory, + replyCompletePlaceholder: L10n.shared["reply_complete_placeholder"]) // Cursor Agent Tasks often fire Claude-format hooks without `--source`, // leaving ghost Claude cards. Rebrand + fold before the rest of the diff --git a/Sources/CodeIsland/L10n.swift b/Sources/CodeIsland/L10n.swift index 3fd6b99d..749b3809 100644 --- a/Sources/CodeIsland/L10n.swift +++ b/Sources/CodeIsland/L10n.swift @@ -393,6 +393,7 @@ final class L10n: ObservableObject { "scroll_for_more": "Scroll for more", "scroll_hidden": "more below", "lines": "lines", + "reply_complete_placeholder": "Reply complete", ] private static let de: [String: String] = en.merging([ @@ -741,6 +742,7 @@ final class L10n: ObservableObject { "scroll_for_more": "Für mehr scrollen", "scroll_hidden": "weitere unten", "lines": "Zeilen", + "reply_complete_placeholder": "Antwort abgeschlossen", ]) { _, localized in localized } private static let zh: [String: String] = [ @@ -1093,6 +1095,7 @@ final class L10n: ObservableObject { "scroll_for_more": "向下滚动查看更多", "scroll_hidden": "个未显示", "lines": "行", + "reply_complete_placeholder": "回复完成", ] private static let zhHant: [String: String] = [ @@ -1445,6 +1448,7 @@ final class L10n: ObservableObject { "scroll_for_more": "向下捲動查看更多", "scroll_hidden": "個未顯示", "lines": "行", + "reply_complete_placeholder": "回覆完成", ] private static let ja: [String: String] = [ @@ -1797,6 +1801,7 @@ final class L10n: ObservableObject { "scroll_for_more": "さらに表示するにはスクロール", "scroll_hidden": "下にさらにあります", "lines": "行", + "reply_complete_placeholder": "返信完了", ] private static let ko: [String: String] = [ @@ -2149,6 +2154,7 @@ final class L10n: ObservableObject { "scroll_for_more": "더 보려면 스크롤", "scroll_hidden": "아래에 더 있음", "lines": "줄", + "reply_complete_placeholder": "답변 완료", ] private static let tr: [String: String] = [ @@ -2501,5 +2507,6 @@ final class L10n: ObservableObject { "scroll_for_more": "Daha fazla için kaydır", "scroll_hidden": "aşağıda", "lines": "satır", + "reply_complete_placeholder": "Yanıt tamamlandı", ] } diff --git a/Sources/CodeIslandCore/SessionSnapshot.swift b/Sources/CodeIslandCore/SessionSnapshot.swift index 6bfe801c..657c4367 100644 --- a/Sources/CodeIslandCore/SessionSnapshot.swift +++ b/Sources/CodeIslandCore/SessionSnapshot.swift @@ -915,7 +915,8 @@ public enum SideEffect: Equatable { public func reduceEvent( sessions: inout [String: SessionSnapshot], event: HookEvent, - maxHistory: Int + maxHistory: Int, + replyCompletePlaceholder: String = "Reply complete" ) -> [SideEffect] { let sessionId = event.sessionId ?? "default" let eventName = EventNormalizer.normalize(event.eventName) @@ -1111,7 +1112,7 @@ public func reduceEvent( sessions[sessionId]?.addRecentMessage(ChatMessage(isUser: false, text: msg)) } else if sessions[sessionId]?.lastAssistantMessage == nil, sessions[sessionId]?.recentMessages.last?.isUser == true { - sessions[sessionId]?.addRecentMessage(ChatMessage(isUser: false, text: "[回复完成]")) + sessions[sessionId]?.addRecentMessage(ChatMessage(isUser: false, text: replyCompletePlaceholder)) } // Cline tasks are single-round — treat completion/cancellation as session end, // and latch a flag so out-of-order in-flight tool events don't revive it. @@ -1157,7 +1158,7 @@ public func reduceEvent( } else if sessions[sessionId]?.lastAssistantMessage == nil, sessions[sessionId]?.recentMessages.last?.isUser == true { // No reply content from hook (e.g. CodeBuddy) -- add placeholder - sessions[sessionId]?.addRecentMessage(ChatMessage(isUser: false, text: "[回复完成]")) + sessions[sessionId]?.addRecentMessage(ChatMessage(isUser: false, text: replyCompletePlaceholder)) } // Try to capture user prompt from Stop event if not already set if sessions[sessionId]?.lastUserPrompt == nil { diff --git a/Tests/CodeIslandCoreTests/ReplyCompletePlaceholderTests.swift b/Tests/CodeIslandCoreTests/ReplyCompletePlaceholderTests.swift new file mode 100644 index 00000000..4426bb5b --- /dev/null +++ b/Tests/CodeIslandCoreTests/ReplyCompletePlaceholderTests.swift @@ -0,0 +1,135 @@ +import XCTest +@testable import CodeIslandCore + +final class ReplyCompletePlaceholderTests: XCTestCase { + private func hookEvent(_ payload: [String: Any]) throws -> HookEvent { + let data = try JSONSerialization.data(withJSONObject: payload) + guard let event = HookEvent(from: data) else { + XCTFail("HookEvent should decode payload: \(payload)") + throw NSError(domain: "ReplyCompletePlaceholderTests", code: 1) + } + return event + } + + // MARK: - TaskRoundComplete (Cline TaskComplete → normalized) + + func testTaskRoundCompleteUsesInjectedPlaceholderWhenNoMessageContent() throws { + var session = SessionSnapshot() + session.source = "cline" + session.addRecentMessage(ChatMessage(isUser: true, text: "do the thing")) + var sessions = ["cline-session": session] + + let event = try hookEvent([ + "hook_event_name": "TaskComplete", + "session_id": "cline-session", + "_source": "cline", + ]) + _ = reduceEvent(sessions: &sessions, event: event, maxHistory: 20, + replyCompletePlaceholder: "답변 완료") + + let last = sessions["cline-session"]?.recentMessages.last + XCTAssertEqual(last?.isUser, false) + XCTAssertEqual(last?.text, "답변 완료") + } + + func testTaskRoundCompleteUsesMessageContentWhenPresent() throws { + var session = SessionSnapshot() + session.source = "cline" + session.addRecentMessage(ChatMessage(isUser: true, text: "do the thing")) + var sessions = ["cline-session": session] + + let event = try hookEvent([ + "hook_event_name": "TaskComplete", + "session_id": "cline-session", + "_source": "cline", + "message": "Here is your answer", + ]) + _ = reduceEvent(sessions: &sessions, event: event, maxHistory: 20, + replyCompletePlaceholder: "Reply complete") + + let last = sessions["cline-session"]?.recentMessages.last + XCTAssertEqual(last?.text, "Here is your answer") + } + + // MARK: - Stop event + + func testStopEventUsesInjectedPlaceholderWhenNoMessageContent() throws { + var session = SessionSnapshot() + session.source = "codebuddy" + session.addRecentMessage(ChatMessage(isUser: true, text: "do the thing")) + var sessions = ["cb-session": session] + + let event = try hookEvent([ + "hook_event_name": "Stop", + "session_id": "cb-session", + "_source": "codebuddy", + ]) + _ = reduceEvent(sessions: &sessions, event: event, maxHistory: 20, + replyCompletePlaceholder: "返信完了") + + let last = sessions["cb-session"]?.recentMessages.last + XCTAssertEqual(last?.isUser, false) + XCTAssertEqual(last?.text, "返信完了") + } + + func testStopEventUsesMessageContentWhenPresent() throws { + var session = SessionSnapshot() + session.source = "codebuddy" + session.addRecentMessage(ChatMessage(isUser: true, text: "do the thing")) + var sessions = ["cb-session": session] + + let event = try hookEvent([ + "hook_event_name": "Stop", + "session_id": "cb-session", + "_source": "codebuddy", + "message": "Task finished", + ]) + _ = reduceEvent(sessions: &sessions, event: event, maxHistory: 20, + replyCompletePlaceholder: "Reply complete") + + let last = sessions["cb-session"]?.recentMessages.last + XCTAssertEqual(last?.text, "Task finished") + } + + // MARK: - Default value + + func testDefaultPlaceholderIsEnglishWithoutBrackets() throws { + var session = SessionSnapshot() + session.source = "codebuddy" + session.addRecentMessage(ChatMessage(isUser: true, text: "do the thing")) + var sessions = ["cb-session": session] + + let event = try hookEvent([ + "hook_event_name": "Stop", + "session_id": "cb-session", + "_source": "codebuddy", + ]) + _ = reduceEvent(sessions: &sessions, event: event, maxHistory: 20) + + let last = sessions["cb-session"]?.recentMessages.last + XCTAssertEqual(last?.text, "Reply complete") + XCTAssertFalse(last?.text.hasPrefix("[") ?? true, "Default placeholder must not start with '['") + } + + // MARK: - Placeholder skipped when prior assistant message exists + + func testPlaceholderIsSkippedWhenLastAssistantMessageAlreadySet() throws { + var session = SessionSnapshot() + session.source = "codebuddy" + session.lastAssistantMessage = "previous reply" + session.addRecentMessage(ChatMessage(isUser: true, text: "follow-up")) + var sessions = ["cb-session": session] + + let event = try hookEvent([ + "hook_event_name": "Stop", + "session_id": "cb-session", + "_source": "codebuddy", + ]) + _ = reduceEvent(sessions: &sessions, event: event, maxHistory: 20, + replyCompletePlaceholder: "Reply complete") + + let messages = sessions["cb-session"]?.recentMessages ?? [] + XCTAssertFalse(messages.contains(where: { $0.text == "Reply complete" }), + "Placeholder must not be added when lastAssistantMessage is already set") + } +} diff --git a/Tests/CodeIslandTests/L10nTests.swift b/Tests/CodeIslandTests/L10nTests.swift index 1a9d8cda..d844ff09 100644 --- a/Tests/CodeIslandTests/L10nTests.swift +++ b/Tests/CodeIslandTests/L10nTests.swift @@ -157,4 +157,46 @@ final class L10nTests: XCTestCase { let formattedUpdate = String(format: updateAvailable, "1.0.19", "1.0.18") XCTAssertEqual(formattedUpdate, "CodeIsland 1.0.19 ist verfügbar (aktuell: 1.0.18). Möchtest du es herunterladen?") } + + // MARK: - reply_complete_placeholder + + func testReplyCompletePlaceholderExistsInAllLanguages() { + let languages = ["en", "de", "zh", "zh-Hant", "ja", "ko", "tr"] + for lang in languages { + guard let dict = L10n.strings[lang] else { + XCTFail("Language '\(lang)' not found in L10n.strings") + continue + } + XCTAssertTrue(dict.keys.contains("reply_complete_placeholder"), + "Language '\(lang)' is missing reply_complete_placeholder") + } + } + + func testReplyCompletePlaceholderHasNoBrackets() { + let languages = ["en", "de", "zh", "zh-Hant", "ja", "ko", "tr"] + for lang in languages { + let value = L10n.strings[lang]?["reply_complete_placeholder"] ?? "" + XCTAssertFalse(value.hasPrefix("["), + "'\(lang)' reply_complete_placeholder must not start with '['") + XCTAssertFalse(value.hasSuffix("]"), + "'\(lang)' reply_complete_placeholder must not end with ']'") + } + } + + func testReplyCompletePlaceholderReturnsLocalizedValuePerLanguage() { + let expected: [String: String] = [ + "en": "Reply complete", + "de": "Antwort abgeschlossen", + "zh": "回复完成", + "zh-Hant": "回覆完成", + "ja": "返信完了", + "ko": "답변 완료", + "tr": "Yanıt tamamlandı", + ] + for (lang, translation) in expected { + L10n.shared.language = lang + XCTAssertEqual(L10n.shared["reply_complete_placeholder"], translation, + "Wrong reply_complete_placeholder for language '\(lang)'") + } + } }