From f48357dcadce2cdffdbd92c86eb9880851dc2041 Mon Sep 17 00:00:00 2001 From: Yan Wang Date: Fri, 13 Mar 2026 16:20:00 -0700 Subject: [PATCH 1/2] fix(chat): hybrid dedupe for optimistic rows with plugin-prefixed text --- OpenCodeClient/OpenCodeClient/AppState.swift | 17 ++++-- .../OpenCodeClientTests.swift | 53 +++++++++++++++++++ 2 files changed, 67 insertions(+), 3 deletions(-) diff --git a/OpenCodeClient/OpenCodeClient/AppState.swift b/OpenCodeClient/OpenCodeClient/AppState.swift index 3e44bb3..e1d3cf9 100644 --- a/OpenCodeClient/OpenCodeClient/AppState.swift +++ b/OpenCodeClient/OpenCodeClient/AppState.swift @@ -974,11 +974,22 @@ final class AppState { let text = normalizeComparableText( m.parts.first(where: { $0.isText })?.text ?? "" ) - guard !text.isEmpty, text == lastLoadedText else { return true } + guard !text.isEmpty else { return true } + + let textMatches = text == lastLoadedText || lastLoadedText.hasSuffix(text) let created = normalizeEpochMs(m.info.time.created) - if created == 0 || lastLoadedCreated == 0 { return false } - return abs(lastLoadedCreated - created) > 10 * 60 * 1000 + let timestampClose: Bool = { + if created == 0 || lastLoadedCreated == 0 { return true } + return abs(lastLoadedCreated - created) <= 60 * 1000 + }() + + // Drop the optimistic row when: + // - texts match exactly or server text ends with optimistic text (plugin prefix), OR + // - timestamps are within 60 s (covers arbitrary server-side text transforms) + // Either signal alone is sufficient; together they're very strong. + if textMatches || timestampClose { return false } + return true } }() diff --git a/OpenCodeClient/OpenCodeClientTests/OpenCodeClientTests.swift b/OpenCodeClient/OpenCodeClientTests/OpenCodeClientTests.swift index 30bf897..8b6ba95 100644 --- a/OpenCodeClient/OpenCodeClientTests/OpenCodeClientTests.swift +++ b/OpenCodeClient/OpenCodeClientTests/OpenCodeClientTests.swift @@ -2185,6 +2185,59 @@ struct AppStateFlowTests { #expect(state.partsByMessage[tempMessageID] == nil) } + @Test @MainActor func loadMessagesDedupesOptimisticRowWhenServerPrependsPluginPrefix() async { + let apiClient = MockAPIClient() + let now = Int(Date().timeIntervalSince1970 * 1000) + let prefixedText = "[analyze-mode]\nANALYSIS MODE. Gather context.\n---\nhello world" + await apiClient.setMessagesResult([ + Self.makeMessageRow( + messageID: "m-user", + sessionID: "s1", + role: "user", + text: prefixedText, + created: now, + completed: now + ), + Self.makeMessageRow(messageID: "m-assistant", sessionID: "s1", text: "reply") + ]) + let state = AppState(apiClient: apiClient, sseClient: MockSSEClient(), sshTunnelManager: SSHTunnelManager()) + state.currentSessionID = "s1" + state.sessionStatuses["s1"] = SessionStatus(type: "busy", attempt: nil, message: nil, next: nil) + + let tempMessageID = state.appendOptimisticUserMessage("hello world") + await state.loadMessages() + + #expect(state.messages.map(\.info.id) == ["m-user", "m-assistant"]) + #expect(state.messages.contains(where: { $0.info.id == tempMessageID }) == false) + #expect(state.partsByMessage[tempMessageID] == nil) + } + + @Test @MainActor func loadMessagesDedupesOptimisticRowByTimestampAlone() async { + let apiClient = MockAPIClient() + let now = Int(Date().timeIntervalSince1970 * 1000) + await apiClient.setMessagesResult([ + Self.makeMessageRow( + messageID: "m-user", + sessionID: "s1", + role: "user", + text: "completely different server text", + created: now, + completed: now + ), + Self.makeMessageRow(messageID: "m-assistant", sessionID: "s1", text: "reply") + ]) + let state = AppState(apiClient: apiClient, sseClient: MockSSEClient(), sshTunnelManager: SSHTunnelManager()) + state.currentSessionID = "s1" + state.sessionStatuses["s1"] = SessionStatus(type: "busy", attempt: nil, message: nil, next: nil) + + let tempMessageID = state.appendOptimisticUserMessage("original user text") + await state.loadMessages() + + #expect(state.messages.map(\.info.id) == ["m-user", "m-assistant"]) + #expect(state.messages.contains(where: { $0.info.id == tempMessageID }) == false) + #expect(state.partsByMessage[tempMessageID] == nil) + } + @Test @MainActor func messageUpdatedIgnoresOtherSession() async { let apiClient = MockAPIClient() let state = AppState(apiClient: apiClient, sseClient: MockSSEClient(), sshTunnelManager: SSHTunnelManager()) From fbd2b7cd8089b4be066760f62037d13aa01f7c08 Mon Sep 17 00:00:00 2001 From: Yan Wang Date: Fri, 13 Mar 2026 16:20:00 -0700 Subject: [PATCH 2/2] docs: record ghost-row prefix diagnosis and hybrid dedupe fix --- working.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/working.md b/working.md index b3732ad..1a259f4 100644 --- a/working.md +++ b/working.md @@ -2,9 +2,10 @@ ## 2026-03-13 -- 回滚了上一版 root-only session 列表交互:iPhone 和 iPad 的 session 列表重新按完整树状层级展示 child/subagent sessions,避免 stop 等会话上下文在列表里“消失”。 +- 回滚了上一版 root-only session 列表交互:iPhone 和 iPad 的 session 列表重新按完整树状层级展示 child/subagent sessions,避免 stop 等会话上下文在列表里"消失"。 - 完成了 session list 回归保护的第一轮 P0 / P1:单元测试现在会锁住 `sessionTree` 和 `sidebarSessions` 的职责边界,UI smoke test 也会直接检查 child session 仍然可见。 - 将测试计划文档重写并收敛为 `docs/tests.md`,改成介绍当前测试体系、已完成的 behavior guards,以及后续值得继续补的 future work。 +- 修复发送消息后偶发重复 ghost row:第一轮修复 whitespace normalization(PR #14)未覆盖 oh-my-opencode 插件在服务端给 user message 拼接 `[analyze-mode]` 前缀的场景。诊断确认根因后,改为混合策略——时间戳窗口(60 s)或后缀包含匹配任一命中即 dedupe optimistic row。新增 plugin-prefix 和 timestamp-only 两条回归测试。 ## 2026-03-12