Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion Sources/CodeIsland/AppState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 7 additions & 0 deletions Sources/CodeIsland/L10n.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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([
Expand Down Expand Up @@ -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] = [
Expand Down Expand Up @@ -1093,6 +1095,7 @@ final class L10n: ObservableObject {
"scroll_for_more": "向下滚动查看更多",
"scroll_hidden": "个未显示",
"lines": "行",
"reply_complete_placeholder": "回复完成",
]

private static let zhHant: [String: String] = [
Expand Down Expand Up @@ -1445,6 +1448,7 @@ final class L10n: ObservableObject {
"scroll_for_more": "向下捲動查看更多",
"scroll_hidden": "個未顯示",
"lines": "行",
"reply_complete_placeholder": "回覆完成",
]

private static let ja: [String: String] = [
Expand Down Expand Up @@ -1797,6 +1801,7 @@ final class L10n: ObservableObject {
"scroll_for_more": "さらに表示するにはスクロール",
"scroll_hidden": "下にさらにあります",
"lines": "行",
"reply_complete_placeholder": "返信完了",
]

private static let ko: [String: String] = [
Expand Down Expand Up @@ -2149,6 +2154,7 @@ final class L10n: ObservableObject {
"scroll_for_more": "더 보려면 스크롤",
"scroll_hidden": "아래에 더 있음",
"lines": "줄",
"reply_complete_placeholder": "답변 완료",
]

private static let tr: [String: String] = [
Expand Down Expand Up @@ -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ı",
]
}
7 changes: 4 additions & 3 deletions Sources/CodeIslandCore/SessionSnapshot.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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 {
Expand Down
135 changes: 135 additions & 0 deletions Tests/CodeIslandCoreTests/ReplyCompletePlaceholderTests.swift
Original file line number Diff line number Diff line change
@@ -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")
}
}
42 changes: 42 additions & 0 deletions Tests/CodeIslandTests/L10nTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)'")
}
}
}