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
17 changes: 14 additions & 3 deletions OpenCodeClient/OpenCodeClient/AppState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}()

Expand Down
53 changes: 53 additions & 0 deletions OpenCodeClient/OpenCodeClientTests/OpenCodeClientTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand Down
3 changes: 2 additions & 1 deletion working.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down