From 5e97f09c02311bc891eb68ce24fb0f236feae119 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Wed, 22 Apr 2026 18:56:07 -0400 Subject: [PATCH 1/6] Preserve Claude chat resume state and surface orphan lanes Keep Claude runtime resume metadata (sdkSessionId, lane directive, runtimeInvalidated) intact when a managed chat is torn down for a non-terminal reason (idle_ttl, budget_eviction, pool_compaction, paused_run, project_close, shutdown). Only terminal reasons (handle_close, ended_session, model_switch) fully invalidate the runtime now, so subsequent turns can re-attach to the same V2 session instead of starting cold. Render orphan-lane sessions (whose laneId is missing from the current lanes list) as their own collapsible sticky groups in the Work session list on desktop and iOS. Sort by latest startedAt with name tiebreak and label with the session's preserved laneName. Grow the iOS chat history window: request a 2 MB snapshot, merge truncated snapshots with existing events instead of replacing, retain up to 1,000 events per session, and fall back to messageId when assistant text is missing itemId so Claude- and Codex-produced turns stay aligned. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../services/chat/agentChatService.test.ts | 16 +- .../main/services/chat/agentChatService.ts | 21 +- .../terminals/SessionListPane.test.tsx | 112 +++++++++++ .../components/terminals/SessionListPane.tsx | 33 +++ apps/ios/ADE/Services/SyncService.swift | 49 ++++- .../Work/WorkErrorAndMessageHelpers.swift | 8 +- .../ios/ADE/Views/Work/WorkEventMapping.swift | 9 +- .../ADE/Views/Work/WorkSessionGrouping.swift | 31 ++- .../ADE/Views/Work/WorkTranscriptParser.swift | 6 +- apps/ios/ADETests/ADETests.swift | 188 ++++++++++++++++++ docs/features/chat/README.md | 14 ++ .../sync-and-multi-device/ios-companion.md | 25 +++ .../terminals-and-sessions/ui-surfaces.md | 12 ++ 13 files changed, 503 insertions(+), 21 deletions(-) create mode 100644 apps/desktop/src/renderer/components/terminals/SessionListPane.test.tsx diff --git a/apps/desktop/src/main/services/chat/agentChatService.test.ts b/apps/desktop/src/main/services/chat/agentChatService.test.ts index c4895f0d1..1b6762f9b 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.test.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.test.ts @@ -5168,7 +5168,7 @@ describe("createAgentChatService", () => { } }); - it("tears down idle Claude runtimes after the inactivity ttl", async () => { + it("tears down idle Claude runtimes after the inactivity ttl without losing resume state", async () => { vi.useFakeTimers(); try { const close = vi.fn(); @@ -5231,6 +5231,20 @@ describe("createAgentChatService", () => { await vi.advanceTimersByTimeAsync(6 * 60_000); expect(close).toHaveBeenCalledTimes(1); + const persistedAfterIdle = readPersistedChatState(session.id); + expect(persistedAfterIdle.sdkSessionId).toBe("sdk-session-idle-ttl"); + expect(persistedAfterIdle.lastLaneDirectiveKey).toEqual(expect.any(String)); + + await service.runSessionTurn({ + sessionId: session.id, + text: "Follow up with the previous context", + timeoutMs: 15_000, + }); + + expect(unstable_v2_resumeSession).toHaveBeenCalledWith("sdk-session-idle-ttl", expect.any(Object)); + expect(unstable_v2_createSession).toHaveBeenCalledTimes(1); + expect(send).toHaveBeenCalledTimes(3); + expect(String(send.mock.calls[2]?.[0] ?? "")).toContain("Follow up with the previous context"); } finally { vi.useRealTimers(); } diff --git a/apps/desktop/src/main/services/chat/agentChatService.ts b/apps/desktop/src/main/services/chat/agentChatService.ts index ba5ac8f7e..dc3f8b819 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.ts @@ -5522,6 +5522,17 @@ export function createAgentChatService(args: { managed: ManagedChatSession, openCodeReason: "handle_close" | "idle_ttl" | "ended_session" | "model_switch" | "project_close" | "budget_eviction" | "pool_compaction" | "paused_run" | "shutdown" = "handle_close", ): void => { + const preserveClaudeResumeState = + managed.runtime?.kind === "claude" + && ( + openCodeReason === "idle_ttl" + || openCodeReason === "budget_eviction" + || openCodeReason === "pool_compaction" + || openCodeReason === "paused_run" + || openCodeReason === "project_close" + || openCodeReason === "shutdown" + ); + flushBufferedReasoning(managed); flushBufferedText(managed); if (managed.runtime?.kind === "codex") { @@ -5538,6 +5549,10 @@ export function createAgentChatService(args: { if (managed.runtime?.kind === "claude") { // Mark interrupted so the streaming catch block takes the graceful path managed.runtime.interrupted = true; + if (preserveClaudeResumeState) { + managed.runtime.sdkSessionId = managed.runtime.sdkSessionId ?? managed.runtime.v2Session?.sessionId ?? null; + persistChatState(managed); + } cancelClaudeWarmup(managed, managed.runtime, "teardown"); try { managed.runtime.v2Session?.close(); } catch { /* ignore */ } managed.runtime.v2Session = null; @@ -5579,8 +5594,10 @@ export function createAgentChatService(args: { if (rt.pooled) releaseCursorAcpConnection(rt.poolKey); managed.runtime = null; } - managed.runtimeInvalidated = true; - clearLaneDirectiveKey(managed); + managed.runtimeInvalidated = !preserveClaudeResumeState; + if (!preserveClaudeResumeState) { + clearLaneDirectiveKey(managed); + } }; const keepChatSessionOpen = ( diff --git a/apps/desktop/src/renderer/components/terminals/SessionListPane.test.tsx b/apps/desktop/src/renderer/components/terminals/SessionListPane.test.tsx new file mode 100644 index 000000000..7ca259a91 --- /dev/null +++ b/apps/desktop/src/renderer/components/terminals/SessionListPane.test.tsx @@ -0,0 +1,112 @@ +/* @vitest-environment jsdom */ + +import { render, screen } from "@testing-library/react"; +import type { ComponentProps } from "react"; +import { MemoryRouter } from "react-router-dom"; +import { describe, expect, it, vi } from "vitest"; +import type { LaneSummary, TerminalSessionSummary } from "../../../shared/types"; +import { SessionListPane } from "./SessionListPane"; + +vi.mock("./useSessionDelta", () => ({ + useSessionDelta: () => null, +})); + +vi.mock("./ToolLogos", () => ({ + ToolLogo: () => , +})); + +function makeLane(overrides: Partial = {}): LaneSummary { + return { + id: "lane-known", + name: "Known Lane", + laneType: "worktree", + baseRef: "main", + branchRef: "known-lane", + worktreePath: "/tmp/known-lane", + parentLaneId: null, + childCount: 0, + stackDepth: 0, + parentStatus: null, + isEditProtected: false, + status: { dirty: false, ahead: 0, behind: 0, remoteBehind: 0, rebaseInProgress: false }, + color: null, + icon: null, + tags: [], + createdAt: "2026-04-22T10:00:00.000Z", + ...overrides, + }; +} + +function makeSession(overrides: Partial = {}): TerminalSessionSummary { + return { + id: "session-mobile", + laneId: "lane-mobile", + laneName: "Mobile-created lane", + ptyId: null, + tracked: true, + pinned: false, + manuallyNamed: false, + goal: null, + toolType: "codex-chat", + title: "Mobile Tool Streaming UI", + status: "running", + startedAt: "2026-04-22T22:13:02.691Z", + endedAt: null, + exitCode: null, + transcriptPath: ".ade/transcripts/session-mobile.chat.jsonl", + headShaStart: null, + headShaEnd: null, + lastOutputPreview: null, + summary: null, + runtimeState: "running", + resumeCommand: null, + ...overrides, + }; +} + +function renderPane(props: Partial> = {}) { + const session = makeSession(); + return render( + + + , + ); +} + +describe("SessionListPane", () => { + it("renders by-lane sessions whose lane is missing from the cached lane list", () => { + renderPane(); + + expect(screen.getAllByText("Mobile-created lane")).toHaveLength(2); + expect(screen.getByText("Mobile Tool Streaming UI")).toBeTruthy(); + }); +}); diff --git a/apps/desktop/src/renderer/components/terminals/SessionListPane.tsx b/apps/desktop/src/renderer/components/terminals/SessionListPane.tsx index 48c88e52b..034143633 100644 --- a/apps/desktop/src/renderer/components/terminals/SessionListPane.tsx +++ b/apps/desktop/src/renderer/components/terminals/SessionListPane.tsx @@ -151,6 +151,22 @@ export const SessionListPane = React.memo(function SessionListPane({ for (const lane of lanes) map.set(lane.id, lane); return map; }, [lanes]); + const missingLaneSessionGroups = useMemo(() => { + if (!sessionsGroupedByLane) return []; + const knownLaneIds = new Set(lanes.map((lane) => lane.id)); + const latestStartedAt = (sessions: TerminalSessionSummary[]): number => + Math.max(...sessions.map((session) => new Date(session.startedAt).getTime())); + return [...sessionsGroupedByLane.entries()] + .filter(([laneId, sessions]) => !knownLaneIds.has(laneId) && sessions.length > 0) + .sort(([leftLaneId, leftSessions], [rightLaneId, rightSessions]) => { + const leftLatest = latestStartedAt(leftSessions); + const rightLatest = latestStartedAt(rightSessions); + if (leftLatest !== rightLatest) return rightLatest - leftLatest; + const leftName = leftSessions[0]?.laneName ?? leftLaneId; + const rightName = rightSessions[0]?.laneName ?? rightLaneId; + return leftName.localeCompare(rightName); + }); + }, [lanes, sessionsGroupedByLane]); // First-rendered card carries `data-tour="work.sessionItem"` so the Work // tab tour can anchor at a real session. We track whether we've already @@ -245,6 +261,23 @@ export const SessionListPane = React.memo(function SessionListPane({ ); })} + {missingLaneSessionGroups.map(([laneId, list]) => { + const collapsed = workCollapsedLaneIds.includes(laneId); + const label = list[0]?.laneName ?? laneId; + return ( + } + label={label} + count={list.length} + collapsed={collapsed} + onToggleCollapsed={() => toggleWorkLaneCollapsed(laneId)} + > + {renderCards(list)} + + ); + })} ); diff --git a/apps/ios/ADE/Services/SyncService.swift b/apps/ios/ADE/Services/SyncService.swift index 8769538d1..b6f48263b 100644 --- a/apps/ios/ADE/Services/SyncService.swift +++ b/apps/ios/ADE/Services/SyncService.swift @@ -166,7 +166,9 @@ enum SyncRequestTimeout { } private let syncTerminalSubscriptionMaxBytes = 80_000 +private let syncChatSubscriptionMaxBytes = 2_000_000 private let syncTerminalBufferMaxCharacters = 80_000 +private let chatEventHistoryMaxEvents = 1_000 enum SyncBonjourTiming { static let searchRetryNanoseconds: UInt64 = 2_000_000_000 @@ -4057,7 +4059,11 @@ final class SyncService: ObservableObject { if supportsChatStreaming, let dict = payload as? [String: Any], let snapshot = try? decode(dict, as: SyncChatSubscribeSnapshotPayload.self) { - replaceChatEventHistory(sessionId: snapshot.sessionId, events: snapshot.events) + if snapshot.truncated { + mergeChatEventHistory(sessionId: snapshot.sessionId, events: snapshot.events) + } else { + replaceChatEventHistory(sessionId: snapshot.sessionId, events: snapshot.events) + } } case "chat_event": if supportsChatStreaming, @@ -4474,7 +4480,10 @@ final class SyncService: ObservableObject { } private func chatSubscriptionPayload(sessionId: String) -> [String: Any] { - ["sessionId": sessionId] + [ + "sessionId": sessionId, + "maxBytes": syncChatSubscriptionMaxBytes, + ] } private func restoreChatEventSubscriptions() { @@ -4488,9 +4497,7 @@ final class SyncService: ObservableObject { var events = chatEventEnvelopesBySession[envelope.sessionId] ?? [] guard !events.contains(where: { $0.id == envelope.id }) else { return } events.append(envelope) - if events.count > 500 { - events.removeFirst(events.count - 500) - } + events = trimChatEventHistory(events) chatEventEnvelopesBySession[envelope.sessionId] = events chatEventRevisionsBySession[envelope.sessionId, default: 0] += 1 lastSyncAt = Date() @@ -4498,15 +4505,39 @@ final class SyncService: ObservableObject { } func replaceChatEventHistory(sessionId: String, events: [AgentChatEventEnvelope]) { + chatEventEnvelopesBySession[sessionId] = deduplicatedChatEventHistory(events) + chatEventRevisionsBySession[sessionId, default: 0] += 1 + lastSyncAt = Date() + markChatEventsChanged(immediate: true) + } + + func mergeChatEventHistory(sessionId: String, events: [AgentChatEventEnvelope]) { + let current = chatEventEnvelopesBySession[sessionId] ?? [] + chatEventEnvelopesBySession[sessionId] = deduplicatedChatEventHistory(current + events) + chatEventRevisionsBySession[sessionId, default: 0] += 1 + lastSyncAt = Date() + markChatEventsChanged(immediate: true) + } + + private func deduplicatedChatEventHistory(_ events: [AgentChatEventEnvelope]) -> [AgentChatEventEnvelope] { var seen = Set() - chatEventEnvelopesBySession[sessionId] = events.filter { event in + let unique = events.filter { event in guard !seen.contains(event.id) else { return false } seen.insert(event.id) return true } - chatEventRevisionsBySession[sessionId, default: 0] += 1 - lastSyncAt = Date() - markChatEventsChanged(immediate: true) + .sorted { lhs, rhs in + if lhs.timestamp == rhs.timestamp { + return (lhs.sequence ?? 0) < (rhs.sequence ?? 0) + } + return lhs.timestamp < rhs.timestamp + } + return trimChatEventHistory(unique) + } + + private func trimChatEventHistory(_ events: [AgentChatEventEnvelope]) -> [AgentChatEventEnvelope] { + guard events.count > chatEventHistoryMaxEvents else { return events } + return Array(events.suffix(chatEventHistoryMaxEvents)) } private func trimmedTerminalBuffer(_ buffer: String) -> String { diff --git a/apps/ios/ADE/Views/Work/WorkErrorAndMessageHelpers.swift b/apps/ios/ADE/Views/Work/WorkErrorAndMessageHelpers.swift index 8f4bc817e..9a82b6840 100644 --- a/apps/ios/ADE/Views/Work/WorkErrorAndMessageHelpers.swift +++ b/apps/ios/ADE/Views/Work/WorkErrorAndMessageHelpers.swift @@ -26,10 +26,12 @@ func errorPresentation(for category: String) -> WorkErrorPresentation { func buildWorkChatMessages(from transcript: [WorkChatEnvelope]) -> [WorkChatMessage] { var messages: [WorkChatMessage] = [] let metadataByTurn = workTurnModelMetadataByTurn(from: transcript) + var previousEnvelopeWasAssistantText = false for envelope in transcript { switch envelope.event { case .userMessage(let text, let turnId, let steerId, let deliveryState, let processed): + previousEnvelopeWasAssistantText = false // Queued steers render as inline cards above the composer, not in the message stream. if deliveryState == "queued", steerId != nil { continue @@ -63,10 +65,12 @@ func buildWorkChatMessages(from transcript: [WorkChatEnvelope]) -> [WorkChatMess let metadata = turnId .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } .flatMap { metadataByTurn[$0] } + let canMergeWithPreviousAssistant = itemId != nil || previousEnvelopeWasAssistantText if let lastIndex = messages.indices.last, messages[lastIndex].role == "assistant", messages[lastIndex].turnId == turnId, - messages[lastIndex].itemId == itemId { + messages[lastIndex].itemId == itemId, + canMergeWithPreviousAssistant { messages[lastIndex].markdown += text } else { messages.append(WorkChatMessage( @@ -80,7 +84,9 @@ func buildWorkChatMessages(from transcript: [WorkChatEnvelope]) -> [WorkChatMess turnModelId: metadata?.modelId )) } + previousEnvelopeWasAssistantText = true default: + previousEnvelopeWasAssistantText = false continue } } diff --git a/apps/ios/ADE/Views/Work/WorkEventMapping.swift b/apps/ios/ADE/Views/Work/WorkEventMapping.swift index 2bfc09071..bdcfa5e28 100644 --- a/apps/ios/ADE/Views/Work/WorkEventMapping.swift +++ b/apps/ios/ADE/Views/Work/WorkEventMapping.swift @@ -21,8 +21,13 @@ func makeWorkChatEvent(from event: AgentChatEvent) -> WorkChatEvent { switch event { case .userMessage(let text, _, let turnId, let steerId, let deliveryState, let processed): return .userMessage(text: text, turnId: turnId, steerId: steerId, deliveryState: deliveryState, processed: processed) - case .text(let text, _, let turnId, let itemId): - return .assistantText(text: text, turnId: turnId, itemId: itemId) + case .text(let text, let messageId, let turnId, let itemId): + let normalizedMessageId = messageId?.trimmingCharacters(in: .whitespacesAndNewlines) + let normalizedItemId = itemId?.trimmingCharacters(in: .whitespacesAndNewlines) + let stableItemId = normalizedMessageId?.isEmpty == false + ? normalizedMessageId + : (normalizedItemId?.isEmpty == false ? normalizedItemId : nil) + return .assistantText(text: text, turnId: turnId, itemId: stableItemId) case .toolCall(let tool, let args, let itemId, _, let parentItemId, let turnId): return .toolCall( tool: tool, diff --git a/apps/ios/ADE/Views/Work/WorkSessionGrouping.swift b/apps/ios/ADE/Views/Work/WorkSessionGrouping.swift index ff13bfa86..be64c5b5c 100644 --- a/apps/ios/ADE/Views/Work/WorkSessionGrouping.swift +++ b/apps/ios/ADE/Views/Work/WorkSessionGrouping.swift @@ -126,15 +126,36 @@ func workSessionGroupsByLane( } var groups: [WorkSessionGroup] = [] + let knownLaneIds = Set(orderedLanes.map(\.id)) for lane in orderedLanes { guard let list = byLaneId[lane.id], !list.isEmpty else { continue } groups.append(WorkSessionGroup(id: "lane:\(lane.id)", label: lane.name, icon: .laneBranch, tint: ADEColor.textSecondary, sessions: list)) } - // Surface any sessions whose lane isn't in the ordered list (e.g., soft-deleted lanes) at the end. - let accounted = Set(groups.flatMap { $0.sessions.map(\.id) }) - let orphans = sessions.filter { !accounted.contains($0.id) } - if !orphans.isEmpty { - groups.append(WorkSessionGroup(id: "lane:_orphans", label: "Other", icon: .laneBranch, tint: ADEColor.textMuted, sessions: orphans)) + // Surface any sessions whose lane isn't in the ordered list (e.g., soft-deleted lanes) + // as their own per-lane groups so users still recognize which branch each belongs to. + let iso = ISO8601DateFormatter() + iso.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + let isoFallback = ISO8601DateFormatter() + isoFallback.formatOptions = [.withInternetDateTime] + func latestStartedAt(_ list: [TerminalSessionSummary]) -> Date { + list.reduce(.distantPast) { acc, session in + let parsed = iso.date(from: session.startedAt) ?? isoFallback.date(from: session.startedAt) ?? .distantPast + return parsed > acc ? parsed : acc + } + } + let orphanEntries = byLaneId + .filter { laneId, list in !knownLaneIds.contains(laneId) && !list.isEmpty } + .sorted { left, right in + let leftLatest = latestStartedAt(left.value) + let rightLatest = latestStartedAt(right.value) + if leftLatest != rightLatest { return leftLatest > rightLatest } + let leftName = left.value.first?.laneName ?? left.key + let rightName = right.value.first?.laneName ?? right.key + return leftName.localizedCaseInsensitiveCompare(rightName) == .orderedAscending + } + for (laneId, list) in orphanEntries { + let label = list.first?.laneName ?? laneId + groups.append(WorkSessionGroup(id: "lane:\(laneId)", label: label, icon: .laneBranch, tint: ADEColor.textMuted, sessions: list)) } return groups } diff --git a/apps/ios/ADE/Views/Work/WorkTranscriptParser.swift b/apps/ios/ADE/Views/Work/WorkTranscriptParser.swift index 5af30ec58..400e5f0a4 100644 --- a/apps/ios/ADE/Views/Work/WorkTranscriptParser.swift +++ b/apps/ios/ADE/Views/Work/WorkTranscriptParser.swift @@ -42,7 +42,11 @@ func parseWorkChatTranscript(_ raw: String) -> [WorkChatEnvelope] { processed: eventDict["processed"] as? Bool ) case "text": - event = .assistantText(text: stringValue(eventDict["text"]), turnId: turnId, itemId: itemId) + event = .assistantText( + text: stringValue(eventDict["text"]), + turnId: turnId, + itemId: itemId ?? optionalString(eventDict["messageId"]) + ) case "tool_call": event = .toolCall( tool: stringValue(eventDict["tool"]), diff --git a/apps/ios/ADETests/ADETests.swift b/apps/ios/ADETests/ADETests.swift index 789aa8f64..b8951573c 100644 --- a/apps/ios/ADETests/ADETests.swift +++ b/apps/ios/ADETests/ADETests.swift @@ -324,6 +324,7 @@ final class ADETests: XCTestCase { XCTAssertEqual(service.subscribedChatSessionIds, Set(["session-1", "session-2"])) XCTAssertEqual(service.chatSubscriptionPayloads().compactMap { $0["sessionId"] as? String }.sorted(), ["session-1", "session-2"]) + XCTAssertEqual(service.chatSubscriptionPayloads().compactMap { $0["maxBytes"] as? Int }, [2_000_000, 2_000_000]) service.disconnect(clearCredentials: false) @@ -367,6 +368,54 @@ final class ADETests: XCTestCase { XCTAssertEqual(service.chatEventRevision(for: "session-1"), 1) } + @MainActor + func testTruncatedChatSubscribeSnapshotMergesWithExistingHistory() async throws { + let service = SyncService(database: makeDatabase(baseURL: makeTemporaryDirectory())) + let original = AgentChatEventEnvelope( + sessionId: "session-1", + timestamp: "2026-03-17T00:00:00.000Z", + event: .userMessage(text: "Start here", attachments: [], turnId: "turn-1", steerId: nil, deliveryState: nil, processed: nil), + sequence: 1, + provenance: nil + ) + let tail = AgentChatEventEnvelope( + sessionId: "session-1", + timestamp: "2026-03-17T00:00:01.000Z", + event: .text(text: "Still working", messageId: "msg-1", turnId: "turn-1", itemId: "item-1"), + sequence: 2, + provenance: nil + ) + + service.recordChatEventEnvelope(original) + service.mergeChatEventHistory(sessionId: "session-1", events: [original, tail]) + + XCTAssertEqual(service.chatEventHistory(sessionId: "session-1"), [original, tail]) + } + + @MainActor + func testCompleteChatSubscribeSnapshotReplacesExistingHistory() async throws { + let service = SyncService(database: makeDatabase(baseURL: makeTemporaryDirectory())) + let old = AgentChatEventEnvelope( + sessionId: "session-1", + timestamp: "2026-03-17T00:00:00.000Z", + event: .userMessage(text: "Old event", attachments: [], turnId: "turn-1", steerId: nil, deliveryState: nil, processed: nil), + sequence: 1, + provenance: nil + ) + let fresh = AgentChatEventEnvelope( + sessionId: "session-1", + timestamp: "2026-03-17T00:00:01.000Z", + event: .text(text: "Fresh snapshot", messageId: "msg-1", turnId: "turn-1", itemId: "item-1"), + sequence: 2, + provenance: nil + ) + + service.recordChatEventEnvelope(old) + service.replaceChatEventHistory(sessionId: "session-1", events: [fresh]) + + XCTAssertEqual(service.chatEventHistory(sessionId: "session-1"), [fresh]) + } + func testChatCommandRequestPayloadsEncodeExpectedShapes() throws { let subscribe = try jsonDictionary(from: AgentChatSubscriptionRequest(sessionId: "session-1")) XCTAssertEqual(subscribe["sessionId"] as? String, "session-1") @@ -3180,6 +3229,145 @@ final class ADETests: XCTestCase { XCTAssertEqual(activeAgents.first?.toolName, "functions.Read") } + func testWorkChatTranscriptUsesMessageIdToSplitAssistantMessages() { + let raw = """ + {"sessionId":"chat-1","timestamp":"2026-04-22T22:10:00.000Z","sequence":1,"event":{"type":"user_message","text":"Rebase Windows Port","turnId":"turn-1"}} + {"sessionId":"chat-1","timestamp":"2026-04-22T22:10:01.000Z","sequence":2,"event":{"type":"text","text":"I will check the branch.","messageId":"msg-progress","turnId":"turn-1"}} + {"sessionId":"chat-1","timestamp":"2026-04-22T22:10:02.000Z","sequence":3,"event":{"type":"tool_call","tool":"Bash","args":{"command":"git status"},"itemId":"tool-1","turnId":"turn-1"}} + {"sessionId":"chat-1","timestamp":"2026-04-22T22:10:03.000Z","sequence":4,"event":{"type":"text","text":"Merge complete.","messageId":"msg-final","turnId":"turn-1"}} + {"sessionId":"chat-1","timestamp":"2026-04-22T22:10:03.500Z","sequence":5,"event":{"type":"text","text":" I did not push.","messageId":"msg-final","turnId":"turn-1"}} + {"sessionId":"chat-1","timestamp":"2026-04-22T22:10:03.500Z","sequence":6,"event":{"type":"tool_result","tool":"Bash","result":{"synthetic":true,"source":"claude_turn_finalization"},"itemId":"tool-1","turnId":"turn-1","status":"completed"}} + """ + + let transcript = parseWorkChatTranscript(raw) + let messages = buildWorkChatMessages(from: transcript) + let assistantMessages = messages.filter { $0.role == "assistant" } + + XCTAssertEqual(assistantMessages.count, 2) + XCTAssertEqual(assistantMessages.map(\.itemId), ["msg-progress", "msg-final"]) + XCTAssertEqual(assistantMessages.map(\.markdown), [ + "I will check the branch.", + "Merge complete. I did not push.", + ]) + + let snapshot = buildWorkChatTimelineSnapshot( + transcript: transcript, + fallbackEntries: [], + artifacts: [], + localEchoMessages: [] + ) + let visibleKinds = snapshot.timeline.compactMap { entry -> String? in + switch entry.payload { + case .message(let message) where message.role == "user": + return "user" + case .message(let message) where message.role == "assistant": + return "assistant:\(message.itemId ?? "")" + case .toolCard(let card): + return "tool:\(card.id)" + default: + return nil + } + } + + XCTAssertEqual(visibleKinds, [ + "user", + "assistant:msg-progress", + "tool:tool-1", + "assistant:msg-final", + ]) + } + + func testWorkChatMessagesDoNotMergeUnidentifiedAssistantTextAcrossTools() { + let transcript: [WorkChatEnvelope] = [ + WorkChatEnvelope( + sessionId: "chat-1", + timestamp: "2026-04-22T22:10:01.000Z", + sequence: 1, + event: .assistantText(text: "Before tools.", turnId: "turn-1", itemId: nil) + ), + WorkChatEnvelope( + sessionId: "chat-1", + timestamp: "2026-04-22T22:10:02.000Z", + sequence: 2, + event: .toolCall(tool: "Bash", argsText: "{}", itemId: "tool-1", parentItemId: nil, turnId: "turn-1") + ), + WorkChatEnvelope( + sessionId: "chat-1", + timestamp: "2026-04-22T22:10:03.000Z", + sequence: 3, + event: .assistantText(text: "After tools.", turnId: "turn-1", itemId: nil) + ), + ] + + let assistantMessages = buildWorkChatMessages(from: transcript) + .filter { $0.role == "assistant" } + + XCTAssertEqual(assistantMessages.map(\.markdown), [ + "Before tools.", + "After tools.", + ]) + } + + func testWorkSessionGroupsByLaneSurfacesOrphanLanesPerLaneId() { + let knownLane = LaneSummary( + id: "lane-primary", + name: "Primary", + description: nil, + laneType: "primary", + baseRef: "main", + branchRef: "main", + worktreePath: "/tmp/project", + attachedRootPath: nil, + parentLaneId: nil, + childCount: 0, + stackDepth: 0, + parentStatus: nil, + isEditProtected: true, + status: LaneStatus(dirty: false, ahead: 0, behind: 0, remoteBehind: 0, rebaseInProgress: false), + color: nil, + icon: nil, + tags: [], + folder: nil, + createdAt: "2026-03-17T00:00:00.000Z", + archivedAt: nil + ) + let primarySession = makeTerminalSessionSummary( + id: "session-primary", + laneId: "lane-primary", + laneName: "Primary", + toolType: "codex-chat" + ) + // Two distinct soft-deleted lanes — each should render as its own group. + let orphanOldSession = makeTerminalSessionSummary( + id: "session-orphan-old", + laneId: "lane-deleted-a", + laneName: "feature/cleanup", + toolType: "codex-chat" + ) + let orphanNewSession = makeTerminalSessionSummary( + id: "session-orphan-new", + laneId: "lane-deleted-b", + laneName: "feature/recent", + toolType: "codex-chat" + ) + // Same orphan lane appearing twice — must merge into the same group. + let orphanNewSessionSibling = makeTerminalSessionSummary( + id: "session-orphan-new-sibling", + laneId: "lane-deleted-b", + laneName: "feature/recent", + toolType: "codex-chat" + ) + + let groups = workSessionGroupsByLane( + sessions: [primarySession, orphanOldSession, orphanNewSession, orphanNewSessionSibling], + orderedLanes: [knownLane] + ) + + XCTAssertEqual(groups.map(\.id), ["lane:lane-primary", "lane:lane-deleted-a", "lane:lane-deleted-b"]) + XCTAssertEqual(groups.map(\.label), ["Primary", "feature/cleanup", "feature/recent"]) + XCTAssertEqual(groups.last?.sessions.count, 2) + } + func testWorkChatTranscriptPreservesReasoningIdentity() { let raw = """ {"sessionId":"chat-1","timestamp":"2026-04-22T21:11:58.154Z","sequence":6,"event":{"type":"reasoning","text":"The user wants","turnId":"turn-1","itemId":"claude-thinking:turn-1:0","summaryIndex":0}} diff --git a/docs/features/chat/README.md b/docs/features/chat/README.md index 53977ffdb..505325e2f 100644 --- a/docs/features/chat/README.md +++ b/docs/features/chat/README.md @@ -89,6 +89,20 @@ input, and has exceeded its provider-specific inactivity window: free the underlying server sooner). Teardown routes through `teardownRuntime(managed, "idle_ttl")`. +`teardownRuntime` distinguishes **terminal** close reasons +(`handle_close`, `ended_session`, `model_switch`) from **non-terminal** +ones (`idle_ttl`, `budget_eviction`, `pool_compaction`, `paused_run`, +`project_close`, `shutdown`). For Claude runtimes only, a non-terminal +teardown preserves resume state: the service pins +`runtime.sdkSessionId` to the last known V2 session id before releasing +the session, persists chat state immediately, and skips the usual +`runtimeInvalidated = true` + `clearLaneDirectiveKey` cleanup. The next +turn on that chat can therefore rehydrate the same Claude V2 session +instead of creating a fresh one, even though the SDK process was +released to reclaim budget or compact the pool. Terminal closes still +run the full invalidation path so "End chat" and explicit model +switches don't leave stale resume pointers behind. + On app shutdown the service exposes `forceDisposeAll()` — called from `runImmediateProcessCleanup()` in `main.ts`. It stops the cleanup timer, rejects every outstanding `sessionTurnCollector` with a "closed during diff --git a/docs/features/sync-and-multi-device/ios-companion.md b/docs/features/sync-and-multi-device/ios-companion.md index bc1b2de30..1b1ca18db 100644 --- a/docs/features/sync-and-multi-device/ios-companion.md +++ b/docs/features/sync-and-multi-device/ios-companion.md @@ -603,6 +603,31 @@ reflected in the phone's UI on the next descriptor read. runs its polling path on reconnect / catchup to fill any gap; the phone de-duplicates per-event keys so a push and a catchup poll covering the same event produce one rendered message. +- **Chat subscribe requests a 2 MB snapshot window.** The phone sends + `chat_subscribe` with `maxBytes: 2_000_000` + (`syncChatSubscriptionMaxBytes`) so the initial snapshot can carry + long transcripts without the host truncating prematurely. When the + host still responds with `truncated: true`, the phone calls + `mergeChatEventHistory` instead of `replaceChatEventHistory`: the + existing cached events are unioned with the truncated snapshot, + deduplicated by `id`, and re-sorted by `(timestamp, sequence)`. + Non-truncated snapshots take the replace path. Both paths run through + `deduplicatedChatEventHistory` and then through `trimChatEventHistory`, + which caps retained events at `chatEventHistoryMaxEvents = 1_000` + (up from the previous 500-event cap) so very long chats don't evict + their own recent turns on reconnect. +- **Work transcript parser uses `messageId` as a fallback item id.** + `makeWorkChatEvent` (`WorkEventMapping.swift`) and + `parseWorkChatTranscript` (`WorkTranscriptParser.swift`) now fall back + to the `messageId` from `chat_event` when no `itemId` is present, so + streaming assistant-text fragments merge into the same transcript row + even when the host only surfaces a `messageId`. `buildWorkChatMessages` + (`WorkErrorAndMessageHelpers.swift`) tracks a + `previousEnvelopeWasAssistantText` flag and allows merging into the + previous assistant bubble when either (a) the text event has an + `itemId` or (b) the immediately preceding envelope was also assistant + text. This keeps the iOS Work chat from fanning a single assistant + turn into many tiny rows. - **Lane presence is best-effort with a TTL.** The phone re-announces on a 30 s cadence; the host prunes stale entries at 60 s. A phone that crashes without sending `lanes.presence.release` diff --git a/docs/features/terminals-and-sessions/ui-surfaces.md b/docs/features/terminals-and-sessions/ui-surfaces.md index 64402698c..646f88b95 100644 --- a/docs/features/terminals-and-sessions/ui-surfaces.md +++ b/docs/features/terminals-and-sessions/ui-surfaces.md @@ -34,6 +34,18 @@ Lists sessions grouped by one of three modes (controlled by Each group uses a `StickyGroupHeader` with collapsed-state persistence via `workCollapsedLaneIds` / `workCollapsedSectionIds`. +In `by-lane` mode, any session whose `laneId` is not in the current +lanes list is still rendered under its own sticky "orphan lane" group +below the active lane groups. The list is built from +`missingLaneSessionGroups`: every `laneId` from `sessionsGroupedByLane` +that's absent from the `lanes` set becomes a group, labelled with the +session's `laneName` (falling back to the raw `laneId`) and sorted by +most-recent `startedAt`, with ties broken alphabetically. These groups +reuse the same `workCollapsedLaneIds` persistence, so a user who +collapses an orphan group sees it stay collapsed on reload. This keeps +sessions reachable when their lane has been archived, deleted, or not +yet loaded, instead of quietly dropping them from the sidebar. + Also renders: - draft-kind switcher (chat vs terminal) at the top From c50200d73487a6b84d61f7ff99cd6c614306790d Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Wed, 22 Apr 2026 19:18:13 -0400 Subject: [PATCH 2/6] Address PR #175 review feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix shutdown resume-state persistence: move `managed.deleted = true` after `teardownRuntime("shutdown")` in `forceDisposeAll` so its `persistChatState` call actually writes the Claude sdkSessionId/lane directive before the tombstone gate bails (capy-ai). - Flip `WorkEventMapping.stableItemId` priority so `itemId` wins over `messageId`, matching `WorkTranscriptParser` — same Claude turn now groups identically live and on replay (Greptile P1). - Normalize `itemId` via `optionalString` in `WorkTranscriptParser` text branch so empty `itemId` falls through to `messageId` (capy-ai). - Guard `latestStartedAt` in `SessionListPane` against `NaN` from malformed `startedAt` values to keep orphan-lane sort deterministic (Greptile P2). - Document `previousEnvelopeWasAssistantText` reset invariant at the flag's declaration (Greptile P2). Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/desktop/src/main/services/chat/agentChatService.ts | 4 +++- .../src/renderer/components/terminals/SessionListPane.tsx | 8 ++++++-- apps/ios/ADE/Views/Work/WorkErrorAndMessageHelpers.swift | 5 +++++ apps/ios/ADE/Views/Work/WorkEventMapping.swift | 6 +++--- apps/ios/ADE/Views/Work/WorkTranscriptParser.swift | 2 +- 5 files changed, 18 insertions(+), 7 deletions(-) diff --git a/apps/desktop/src/main/services/chat/agentChatService.ts b/apps/desktop/src/main/services/chat/agentChatService.ts index dc3f8b819..efc5ca254 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.ts @@ -13041,7 +13041,6 @@ export function createAgentChatService(args: { } for (const [sessionId, managed] of managedSessions) { try { - managed.deleted = true; clearSubagentSnapshots(sessionId); for (const pending of managed.localPendingInputs.values()) { pending.resolve({ decision: "cancel" }); @@ -13050,7 +13049,10 @@ export function createAgentChatService(args: { managed.closed = true; managed.endedNotified = true; managed.ctoSessionStartedAt = null; + // teardownRuntime must run before `deleted = true` so its persistChatState() + // call can write the preserved Claude resume metadata for "shutdown". teardownRuntime(managed, "shutdown"); + managed.deleted = true; } catch { // ignore emergency shutdown failures } diff --git a/apps/desktop/src/renderer/components/terminals/SessionListPane.tsx b/apps/desktop/src/renderer/components/terminals/SessionListPane.tsx index 034143633..6a61ed07c 100644 --- a/apps/desktop/src/renderer/components/terminals/SessionListPane.tsx +++ b/apps/desktop/src/renderer/components/terminals/SessionListPane.tsx @@ -154,8 +154,12 @@ export const SessionListPane = React.memo(function SessionListPane({ const missingLaneSessionGroups = useMemo(() => { if (!sessionsGroupedByLane) return []; const knownLaneIds = new Set(lanes.map((lane) => lane.id)); - const latestStartedAt = (sessions: TerminalSessionSummary[]): number => - Math.max(...sessions.map((session) => new Date(session.startedAt).getTime())); + const latestStartedAt = (sessions: TerminalSessionSummary[]): number => { + const times = sessions + .map((session) => new Date(session.startedAt).getTime()) + .filter(Number.isFinite); + return times.length > 0 ? Math.max(...times) : -Infinity; + }; return [...sessionsGroupedByLane.entries()] .filter(([laneId, sessions]) => !knownLaneIds.has(laneId) && sessions.length > 0) .sort(([leftLaneId, leftSessions], [rightLaneId, rightSessions]) => { diff --git a/apps/ios/ADE/Views/Work/WorkErrorAndMessageHelpers.swift b/apps/ios/ADE/Views/Work/WorkErrorAndMessageHelpers.swift index 9a82b6840..1a86d7675 100644 --- a/apps/ios/ADE/Views/Work/WorkErrorAndMessageHelpers.swift +++ b/apps/ios/ADE/Views/Work/WorkErrorAndMessageHelpers.swift @@ -26,6 +26,11 @@ func errorPresentation(for category: String) -> WorkErrorPresentation { func buildWorkChatMessages(from transcript: [WorkChatEnvelope]) -> [WorkChatMessage] { var messages: [WorkChatMessage] = [] let metadataByTurn = workTurnModelMetadataByTurn(from: transcript) + // Tracks whether the previous envelope was assistantText so nil-itemId + // streaming fragments can merge into it. MUST be reset to false on every + // non-assistantText branch below — otherwise a subsequent nil-itemId + // fragment could wrongly merge across an intervening tool call or user + // message. Any new `WorkChatEvent` case added here must preserve that reset. var previousEnvelopeWasAssistantText = false for envelope in transcript { diff --git a/apps/ios/ADE/Views/Work/WorkEventMapping.swift b/apps/ios/ADE/Views/Work/WorkEventMapping.swift index bdcfa5e28..3ecc83d0c 100644 --- a/apps/ios/ADE/Views/Work/WorkEventMapping.swift +++ b/apps/ios/ADE/Views/Work/WorkEventMapping.swift @@ -24,9 +24,9 @@ func makeWorkChatEvent(from event: AgentChatEvent) -> WorkChatEvent { case .text(let text, let messageId, let turnId, let itemId): let normalizedMessageId = messageId?.trimmingCharacters(in: .whitespacesAndNewlines) let normalizedItemId = itemId?.trimmingCharacters(in: .whitespacesAndNewlines) - let stableItemId = normalizedMessageId?.isEmpty == false - ? normalizedMessageId - : (normalizedItemId?.isEmpty == false ? normalizedItemId : nil) + let stableItemId = normalizedItemId?.isEmpty == false + ? normalizedItemId + : (normalizedMessageId?.isEmpty == false ? normalizedMessageId : nil) return .assistantText(text: text, turnId: turnId, itemId: stableItemId) case .toolCall(let tool, let args, let itemId, _, let parentItemId, let turnId): return .toolCall( diff --git a/apps/ios/ADE/Views/Work/WorkTranscriptParser.swift b/apps/ios/ADE/Views/Work/WorkTranscriptParser.swift index 400e5f0a4..426a88f66 100644 --- a/apps/ios/ADE/Views/Work/WorkTranscriptParser.swift +++ b/apps/ios/ADE/Views/Work/WorkTranscriptParser.swift @@ -45,7 +45,7 @@ func parseWorkChatTranscript(_ raw: String) -> [WorkChatEnvelope] { event = .assistantText( text: stringValue(eventDict["text"]), turnId: turnId, - itemId: itemId ?? optionalString(eventDict["messageId"]) + itemId: optionalString(eventDict["itemId"]) ?? optionalString(eventDict["messageId"]) ) case "tool_call": event = .toolCall( From 4048884885de1dd3f8e9cad6b7b1fe99ec28dfc7 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Wed, 22 Apr 2026 19:29:27 -0400 Subject: [PATCH 3/6] Unify mobile sync discovery and Tailscale routing --- apps/desktop/src/main/main.ts | 5 + .../sync/deviceRegistryService.test.ts | 52 +++++++++ .../services/sync/deviceRegistryService.ts | 11 +- .../src/main/services/sync/syncHostService.ts | 64 ++++++++++- .../main/services/sync/syncService.test.ts | 1 + .../src/main/services/sync/syncService.ts | 11 ++ apps/ios/ADE/Info.plist | 15 +++ apps/ios/ADE/Services/SyncService.swift | 108 +++++++++--------- .../Settings/SettingsPairingSection.swift | 10 +- apps/ios/ADETests/ADETests.swift | 12 ++ 10 files changed, 231 insertions(+), 58 deletions(-) diff --git a/apps/desktop/src/main/main.ts b/apps/desktop/src/main/main.ts index f7e9b05b8..6196d6a9d 100644 --- a/apps/desktop/src/main/main.ts +++ b/apps/desktop/src/main/main.ts @@ -763,6 +763,9 @@ app.whenReady().then(async () => { const setActiveProject = (projectRoot: string | null): void => { activeProjectRoot = projectRoot ? normalizeProjectRoot(projectRoot) : null; + for (const [root, ctx] of projectContexts) { + ctx.syncService?.setHostDiscoveryEnabled?.(activeProjectRoot != null && root === activeProjectRoot); + } if (activeProjectRoot) { projectLastActivatedAt.set(activeProjectRoot, Date.now()); try { @@ -2435,6 +2438,7 @@ app.whenReady().then(async () => { db, logger, projectRoot, + localDeviceIdPath: path.join(app.getPath("userData"), "sync-device-id"), fileService, laneService, gitService, @@ -2466,6 +2470,7 @@ app.whenReady().then(async () => { getLinearSyncService: () => linearSyncServiceRef, processService, hostStartupEnabled: process.env.ADE_DISABLE_SYNC_HOST !== "1", + hostDiscoveryEnabled: activeProjectRoot != null && normalizeProjectRoot(projectRoot) === activeProjectRoot, notificationEventBus, projectCatalogProvider: { listProjects: listMobileSyncProjects, diff --git a/apps/desktop/src/main/services/sync/deviceRegistryService.test.ts b/apps/desktop/src/main/services/sync/deviceRegistryService.test.ts index 544ecc913..0ece472f6 100644 --- a/apps/desktop/src/main/services/sync/deviceRegistryService.test.ts +++ b/apps/desktop/src/main/services/sync/deviceRegistryService.test.ts @@ -62,6 +62,58 @@ describe("deviceRegistryService", () => { db2.close(); }); + it("can share a desktop device identity across project registries", async () => { + const projectRootA = makeProjectRoot("ade-device-registry-global-a-"); + const projectRootB = makeProjectRoot("ade-device-registry-global-b-"); + const globalDeviceIdPath = path.join(os.tmpdir(), `ade-global-device-${Date.now()}-${Math.random()}`, "sync-device-id"); + + const dbA = await openKvDb(path.join(projectRootA, ".ade", "ade.db"), createLogger() as any); + const dbB = await openKvDb(path.join(projectRootB, ".ade", "ade.db"), createLogger() as any); + const registryA = createDeviceRegistryService({ + db: dbA, + logger: createLogger() as any, + projectRoot: projectRootA, + localDeviceIdPath: globalDeviceIdPath, + }); + const registryB = createDeviceRegistryService({ + db: dbB, + logger: createLogger() as any, + projectRoot: projectRootB, + localDeviceIdPath: globalDeviceIdPath, + }); + + const localA = registryA.ensureLocalDevice(); + const localB = registryB.ensureLocalDevice(); + + expect(localB.deviceId).toBe(localA.deviceId); + expect(localB.siteId).not.toBe(localA.siteId); + + dbA.close(); + dbB.close(); + }); + + it("migrates the legacy project device identity into the shared desktop identity file", async () => { + const projectRoot = makeProjectRoot("ade-device-registry-global-migrate-"); + const legacyDeviceId = "legacy-project-device-id"; + const legacyDeviceIdPath = path.join(projectRoot, ".ade", "secrets", "sync-device-id"); + const globalDeviceIdPath = path.join(os.tmpdir(), `ade-global-device-migrate-${Date.now()}-${Math.random()}`, "sync-device-id"); + fs.mkdirSync(path.dirname(legacyDeviceIdPath), { recursive: true }); + fs.writeFileSync(legacyDeviceIdPath, `${legacyDeviceId}\n`); + + const db = await openKvDb(path.join(projectRoot, ".ade", "ade.db"), createLogger() as any); + const registry = createDeviceRegistryService({ + db, + logger: createLogger() as any, + projectRoot, + localDeviceIdPath: globalDeviceIdPath, + }); + + expect(registry.ensureLocalDevice().deviceId).toBe(legacyDeviceId); + expect(fs.readFileSync(globalDeviceIdPath, "utf8").trim()).toBe(legacyDeviceId); + + db.close(); + }); + it("persists notification preferences in device metadata across registry restarts", async () => { const projectRoot = makeProjectRoot("ade-device-registry-prefs-"); const dbPath = path.join(projectRoot, ".ade", "ade.db"); diff --git a/apps/desktop/src/main/services/sync/deviceRegistryService.ts b/apps/desktop/src/main/services/sync/deviceRegistryService.ts index 0c6c56e67..19c8a3111 100644 --- a/apps/desktop/src/main/services/sync/deviceRegistryService.ts +++ b/apps/desktop/src/main/services/sync/deviceRegistryService.ts @@ -22,6 +22,7 @@ type DeviceRegistryServiceArgs = { db: AdeDb; logger: Logger; projectRoot: string; + localDeviceIdPath?: string; }; type DeviceRow = { @@ -138,12 +139,20 @@ function firstPreferredHost(ipAddresses: string[]): string { export function createDeviceRegistryService(args: DeviceRegistryServiceArgs) { const layout = resolveAdeLayout(args.projectRoot); - const deviceIdPath = path.join(layout.secretsDir, DEVICE_ID_FILE); + const deviceIdPath = args.localDeviceIdPath ?? path.join(layout.secretsDir, DEVICE_ID_FILE); + const legacyProjectDeviceIdPath = path.join(layout.secretsDir, DEVICE_ID_FILE); fs.mkdirSync(path.dirname(deviceIdPath), { recursive: true }); const readOrCreateLocalDeviceId = (): string => { const existing = fs.existsSync(deviceIdPath) ? fs.readFileSync(deviceIdPath, "utf8").trim() : ""; if (existing.length > 0) return existing; + const legacy = deviceIdPath !== legacyProjectDeviceIdPath && fs.existsSync(legacyProjectDeviceIdPath) + ? fs.readFileSync(legacyProjectDeviceIdPath, "utf8").trim() + : ""; + if (legacy.length > 0) { + writeTextAtomic(deviceIdPath, `${legacy}\n`); + return legacy; + } const created = randomUUID(); writeTextAtomic(deviceIdPath, `${created}\n`); return created; diff --git a/apps/desktop/src/main/services/sync/syncHostService.ts b/apps/desktop/src/main/services/sync/syncHostService.ts index 679209515..69c95a433 100644 --- a/apps/desktop/src/main/services/sync/syncHostService.ts +++ b/apps/desktop/src/main/services/sync/syncHostService.ts @@ -173,6 +173,7 @@ type SyncHostServiceArgs = { pinStore: SyncPinStore; bootstrapTokenPath?: string; port?: number; + discoveryEnabled?: boolean; heartbeatIntervalMs?: number; pollIntervalMs?: number; brainStatusIntervalMs?: number; @@ -699,13 +700,18 @@ export function createSyncHostService(args: SyncHostServiceArgs) { let tailnetServeSignature: string | null = null; let tailnetServePublishSequence = 0; let tailnetServeActivePublishToken = 0; + let discoveryEnabled = args.discoveryEnabled !== false; let tailnetDiscoveryStatus: SyncTailnetDiscoveryStatus = { - state: shouldAttemptTailnetServiceAdvertise() ? "disabled" : "unavailable", + state: !discoveryEnabled + ? "disabled" + : shouldAttemptTailnetServiceAdvertise() ? "disabled" : "unavailable", serviceName: SYNC_TAILNET_DISCOVERY_SERVICE_NAME, servicePort: SYNC_TAILNET_DISCOVERY_SERVICE_PORT, target: null, updatedAt: null, - error: shouldAttemptTailnetServiceAdvertise() + error: !discoveryEnabled + ? "Tailnet discovery is disabled for this background project context." + : shouldAttemptTailnetServiceAdvertise() ? "Tailnet discovery has not been published yet." : "Tailscale Serve discovery is not available in this desktop process.", stderr: null, @@ -828,6 +834,10 @@ export function createSyncHostService(args: SyncHostServiceArgs) { const publishLanDiscovery = (port: number): void => { if (disposed) return; + if (!discoveryEnabled) { + unpublishLanDiscovery(); + return; + } const localDevice = args.deviceRegistryService?.ensureLocalDevice() ?? null; const hostName = localDevice?.name ?? os.hostname(); const ipAddresses = uniqueStrings([ @@ -880,6 +890,18 @@ export function createSyncHostService(args: SyncHostServiceArgs) { }); }; + const unpublishLanDiscovery = (): void => { + if (!bonjourAnnouncement) return; + try { + bonjourAnnouncement.stop?.(); + } catch { + // ignore cleanup failures + } + bonjourAnnouncement = null; + bonjourPort = null; + bonjourSignature = null; + }; + const updateTailnetDiscoveryStatus = ( next: SyncTailnetDiscoveryStatus, ): void => { @@ -894,6 +916,19 @@ export function createSyncHostService(args: SyncHostServiceArgs) { options?: { force?: boolean }, ): void => { if (disposed) return; + if (!discoveryEnabled) { + void unpublishTailnetDiscovery(); + updateTailnetDiscoveryStatus({ + state: "disabled", + serviceName: SYNC_TAILNET_DISCOVERY_SERVICE_NAME, + servicePort: SYNC_TAILNET_DISCOVERY_SERVICE_PORT, + target: null, + updatedAt: nowIso(), + error: "Tailnet discovery is disabled for this background project context.", + stderr: null, + }); + return; + } if (!shouldAttemptTailnetServiceAdvertise()) { updateTailnetDiscoveryStatus({ state: "unavailable", @@ -2018,6 +2053,30 @@ export function createSyncHostService(args: SyncHostServiceArgs) { } }, + setDiscoveryEnabled(enabled: boolean): void { + if (discoveryEnabled === enabled) return; + discoveryEnabled = enabled; + const address = server.address(); + if (!enabled) { + unpublishLanDiscovery(); + void unpublishTailnetDiscovery(); + updateTailnetDiscoveryStatus({ + state: "disabled", + serviceName: SYNC_TAILNET_DISCOVERY_SERVICE_NAME, + servicePort: SYNC_TAILNET_DISCOVERY_SERVICE_PORT, + target: null, + updatedAt: nowIso(), + error: "Tailnet discovery is disabled for this background project context.", + stderr: null, + }); + return; + } + if (typeof address === "object" && address) { + publishLanDiscovery(address.port); + publishTailnetDiscovery(address.port, { force: true }); + } + }, + revokePairedDevice(deviceId: string): void { pairingStore.revoke(deviceId); let revokedConnectedPeer = false; @@ -2127,6 +2186,7 @@ export function createSyncHostService(args: SyncHostServiceArgs) { clearInterval(pollTimer); clearInterval(heartbeatTimer); clearInterval(brainStatusTimer); + unpublishLanDiscovery(); try { await unpublishTailnetDiscovery(); } catch { diff --git a/apps/desktop/src/main/services/sync/syncService.test.ts b/apps/desktop/src/main/services/sync/syncService.test.ts index bdd07cca9..34cfc32f5 100644 --- a/apps/desktop/src/main/services/sync/syncService.test.ts +++ b/apps/desktop/src/main/services/sync/syncService.test.ts @@ -37,6 +37,7 @@ const { createSyncHostServiceMock } = vi.hoisted(() => ({ }, handlePtyData() {}, handlePtyExit() {}, + setDiscoveryEnabled: vi.fn(), async dispose() {}, })), })); diff --git a/apps/desktop/src/main/services/sync/syncService.ts b/apps/desktop/src/main/services/sync/syncService.ts index b78d3b56d..173a3b66f 100644 --- a/apps/desktop/src/main/services/sync/syncService.ts +++ b/apps/desktop/src/main/services/sync/syncService.ts @@ -64,6 +64,7 @@ type SyncServiceArgs = { db: AdeDb; logger: Logger; projectRoot: string; + localDeviceIdPath?: string; fileService: ReturnType; laneService: ReturnType; gitService?: ReturnType; @@ -104,6 +105,7 @@ type SyncServiceArgs = { getLinearSyncService?: () => ReturnType | null; processService: ReturnType; hostStartupEnabled?: boolean; + hostDiscoveryEnabled?: boolean; onStatusChanged?: (snapshot: SyncRoleSnapshot) => void; /** * Optional notification bus forwarded to the sync host. The host publishes @@ -278,6 +280,7 @@ export function createSyncService(args: SyncServiceArgs) { db: args.db, logger: args.logger, projectRoot: args.projectRoot, + localDeviceIdPath: args.localDeviceIdPath, }); let hostService: SyncHostService | null = null; @@ -285,6 +288,7 @@ export function createSyncService(args: SyncServiceArgs) { let refreshQueued = false; let disposed = false; const hostStartupEnabled = args.hostStartupEnabled !== false; + let hostDiscoveryEnabled = args.hostDiscoveryEnabled !== false; let activeLocalLanePresenceIds: string[] = []; const localLanePresenceHeartbeatTimer = setInterval(() => { if (disposed || !hostService || activeLocalLanePresenceIds.length === 0) return; @@ -432,6 +436,7 @@ export function createSyncService(args: SyncServiceArgs) { pinStore, bootstrapTokenPath: tokenPath, port: attemptedPort, + discoveryEnabled: hostDiscoveryEnabled, deviceRegistryService, notificationEventBus: args.notificationEventBus ?? null, projectCatalogProvider: args.projectCatalogProvider, @@ -718,6 +723,12 @@ export function createSyncService(args: SyncServiceArgs) { return snapshot; }, + setHostDiscoveryEnabled(enabled: boolean): void { + hostDiscoveryEnabled = enabled; + hostService?.setDiscoveryEnabled(enabled); + void emitStatus(); + }, + async updateLocalDevice(argsIn: { name?: string; deviceType?: "desktop" | "phone" | "vps" | "unknown"; diff --git a/apps/ios/ADE/Info.plist b/apps/ios/ADE/Info.plist index 496c50627..52268c632 100644 --- a/apps/ios/ADE/Info.plist +++ b/apps/ios/ADE/Info.plist @@ -37,6 +37,21 @@ NSAllowsLocalNetworking + NSExceptionDomains + + ade-sync + + NSExceptionAllowsInsecureHTTPLoads + + + ts.net + + NSExceptionAllowsInsecureHTTPLoads + + NSIncludesSubdomains + + + NSBonjourServices diff --git a/apps/ios/ADE/Services/SyncService.swift b/apps/ios/ADE/Services/SyncService.swift index b6f48263b..308e75e63 100644 --- a/apps/ios/ADE/Services/SyncService.swift +++ b/apps/ios/ADE/Services/SyncService.swift @@ -190,11 +190,9 @@ enum SyncTailnetDiscoveryTiming { enum SyncTailnetDiscovery { static let hostCandidates = [ "ade-sync", - "ade-desktop", ] static let portCandidates = [ 8787, - 8788, ] } @@ -217,6 +215,29 @@ func syncIsTailscaleIPv4Address(_ host: String) -> Bool { return first == 100 && (64...127).contains(second) } +func syncNormalizedRouteHost(_ address: String) -> String { + var host = address.trimmingCharacters(in: .whitespacesAndNewlines) + if let schemeRange = host.range(of: "://") { + host = String(host[schemeRange.upperBound...]) + } + if let slash = host.firstIndex(of: "/") { host = String(host[.. Bool { + let host = syncNormalizedRouteHost(address) + if host.isEmpty { return false } + return syncIsTailscaleIPv4Address(host) + || syncIsTailnetDiscoveryHost(host) + || host.hasSuffix(".ts.net") +} + struct SyncReconnectState { private(set) var attempts = 0 @@ -873,9 +894,9 @@ final class SyncService: ObservableObject { discoveredLanAddresses: addressCandidates.filter { host in guard !host.contains(":") else { return false } guard host != "127.0.0.1" else { return false } - return !syncIsTailscaleIPv4Address(host) + return !syncIsTailscaleRoute(host) }, - tailscaleAddress: addressCandidates.first(where: syncIsTailscaleIPv4Address) + tailscaleAddress: addressCandidates.first(where: syncIsTailscaleRoute) ) let previousActiveProjectId = activeProjectId @@ -1216,14 +1237,14 @@ final class SyncService: ObservableObject { } let tailscaleAddress = profile.tailscaleAddress - ?? profile.savedAddressCandidates.first(where: syncIsTailscaleIPv4Address) - ?? profile.lastSuccessfulAddress.flatMap { syncIsTailscaleIPv4Address($0) ? $0 : nil } - let lanAddresses = profile.discoveredLanAddresses.filter { !syncIsTailscaleIPv4Address($0) } - let savedLanAddresses = profile.savedAddressCandidates.filter { !syncIsTailscaleIPv4Address($0) } + ?? profile.savedAddressCandidates.first(where: syncIsTailscaleRoute) + ?? profile.lastSuccessfulAddress.flatMap { syncIsTailscaleRoute($0) ? $0 : nil } + let lanAddresses = profile.discoveredLanAddresses.filter { !syncIsTailscaleRoute($0) } + let savedLanAddresses = profile.savedAddressCandidates.filter { !syncIsTailscaleRoute($0) } let addresses = deduplicatedAddresses( lanAddresses + savedLanAddresses - + (profile.lastSuccessfulAddress.flatMap { syncIsTailscaleIPv4Address($0) ? nil : $0 }.map { [$0] } ?? []) + + (profile.lastSuccessfulAddress.flatMap { syncIsTailscaleRoute($0) ? nil : $0 }.map { [$0] } ?? []) ) guard tailscaleAddress != nil || !addresses.isEmpty else { return nil } let identity = profile.hostIdentity?.trimmingCharacters(in: .whitespacesAndNewlines) @@ -1315,7 +1336,7 @@ final class SyncService: ObservableObject { return } - let connectedOverTailnet = currentAddress.map(syncIsTailscaleIPv4Address) ?? false + let connectedOverTailnet = currentAddress.map(syncIsTailscaleRoute) ?? false let shouldRoamToTailnet = !connectedOverTailnet && profileHasTailnetRoute(profile) @@ -1489,16 +1510,9 @@ final class SyncService: ObservableObject { discoveredLanAddresses: addressCandidates.filter { host in guard !host.contains(":") else { return false } if host == "127.0.0.1" { return false } - let octets = host.split(separator: ".") - guard octets.count == 4 else { return false } - guard let first = octets.first.flatMap({ Int($0) }), - let second = octets.dropFirst().first.flatMap({ Int($0) }) else { - return false - } - let isTailscale = first == 100 && (64...127).contains(second) - return !isTailscale + return !syncIsTailscaleRoute(host) }, - tailscaleAddress: tailscaleAddress + tailscaleAddress: tailscaleAddress ?? addressCandidates.first(where: syncIsTailscaleRoute) ) saveProfile(profile) currentAddress = preferredAddress @@ -3201,35 +3215,20 @@ final class SyncService: ObservableObject { private func syncCanAttemptPlaintextWebSocket(_ address: String) -> Bool { let trimmed = address.trimmingCharacters(in: .whitespacesAndNewlines) if trimmed.isEmpty { return false } - - // Strip a leading scheme so raw hosts like "192.168.1.10:7878" and - // "ws://192.168.1.10:7878" both flow through the same path. - var host = trimmed - if let schemeRange = host.range(of: "://") { - let scheme = host[.. Bool { - if profile.tailscaleAddress.map(syncIsTailscaleIPv4Address) == true { return true } - if profile.lastSuccessfulAddress.map(syncIsTailscaleIPv4Address) == true { return true } - return profile.savedAddressCandidates.contains(where: syncIsTailscaleIPv4Address) + if profile.tailscaleAddress.map(syncIsTailscaleRoute) == true { return true } + if profile.lastSuccessfulAddress.map(syncIsTailscaleRoute) == true { return true } + return profile.savedAddressCandidates.contains(where: syncIsTailscaleRoute) } private func preferTailnetForUpcomingReconnect() { @@ -3408,8 +3407,8 @@ final class SyncService: ObservableObject { let liveLastSuccessful = profile.lastSuccessfulAddress.flatMap { address in liveSet.contains(address) ? [address] : nil } ?? [] - let liveLastSuccessfulTailnet = liveLastSuccessful.filter(syncIsTailscaleIPv4Address) - let liveLastSuccessfulLan = liveLastSuccessful.filter { !syncIsTailscaleIPv4Address($0) } + let liveLastSuccessfulTailnet = liveLastSuccessful.filter(syncIsTailscaleRoute) + let liveLastSuccessfulLan = liveLastSuccessful.filter { !syncIsTailscaleRoute($0) } // Prefer addresses we see RIGHT NOW on the network over anything we have // cached from previous sessions. If the user changed subnets, stale // entries would otherwise consume the first few attempts (each with its @@ -3418,8 +3417,8 @@ final class SyncService: ObservableObject { let prioritizedLive = preferTailnet ? liveLastSuccessfulTailnet + liveTailscale + liveLastSuccessfulLan + liveLan : liveLastSuccessful + liveLan + liveTailscale - let savedTailnet = profile.savedAddressCandidates.filter(syncIsTailscaleIPv4Address) - let savedLan = profile.savedAddressCandidates.filter { !syncIsTailscaleIPv4Address($0) } + let savedTailnet = profile.savedAddressCandidates.filter(syncIsTailscaleRoute) + let savedLan = profile.savedAddressCandidates.filter { !syncIsTailscaleRoute($0) } let fallbackLastSuccessful = liveLastSuccessful.isEmpty ? (profile.lastSuccessfulAddress.map { [$0] } ?? []) : [] let savedProfileTailnet = profile.tailscaleAddress.map { [$0] } ?? [] let fallbackSaved: [String] @@ -3445,9 +3444,9 @@ final class SyncService: ObservableObject { matchesDiscoveredHost(host, profile: profile) } guard !matchingDiscovery.isEmpty else { - let savedTailnet = profile.savedAddressCandidates.filter(syncIsTailscaleIPv4Address) + let savedTailnet = profile.savedAddressCandidates.filter(syncIsTailscaleRoute) let lastSuccessfulTailnet = profile.lastSuccessfulAddress.flatMap { address in - syncIsTailscaleIPv4Address(address) ? [address] : nil + syncIsTailscaleRoute(address) ? [address] : nil } ?? [] return deduplicatedAddresses( (preferTailnet ? [] : lastSuccessfulTailnet) @@ -3463,8 +3462,8 @@ final class SyncService: ObservableObject { let liveLastSuccessful = profile.lastSuccessfulAddress.flatMap { address in liveSet.contains(address) ? [address] : nil } ?? [] - let liveLastSuccessfulTailnet = liveLastSuccessful.filter(syncIsTailscaleIPv4Address) - let liveLastSuccessfulLan = liveLastSuccessful.filter { !syncIsTailscaleIPv4Address($0) } + let liveLastSuccessfulTailnet = liveLastSuccessful.filter(syncIsTailscaleRoute) + let liveLastSuccessfulLan = liveLastSuccessful.filter { !syncIsTailscaleRoute($0) } let prioritizedLive = preferTailnet ? liveLastSuccessfulTailnet + liveTailscale + liveLastSuccessfulLan + liveLan @@ -3871,7 +3870,9 @@ final class SyncService: ObservableObject { saveRemoteCommandDescriptors(commandDescriptors) let matchingDiscovery = discoveredHosts.first { discovered in - discovered.hostIdentity == remoteHostIdentity || discovered.addresses.contains(connectedHost) + discovered.hostIdentity == remoteHostIdentity + || discovered.addresses.contains(connectedHost) + || discovered.tailscaleAddress == connectedHost } // Cap saved candidates to avoid unbounded growth when the user moves // between networks. Put the currently-connected host first, then any @@ -3880,6 +3881,7 @@ final class SyncService: ObservableObject { let savedCandidatesUncapped = deduplicatedAddresses( [connectedHost] + (matchingDiscovery?.addresses ?? []) + + (matchingDiscovery?.tailscaleAddress.map { [$0] } ?? []) + (activeHostProfile?.savedAddressCandidates ?? []) ) let savedCandidates = Array(savedCandidatesUncapped.prefix(6)) @@ -3898,7 +3900,9 @@ final class SyncService: ObservableObject { lastSuccessfulAddress: connectedHost, savedAddressCandidates: savedCandidates, discoveredLanAddresses: discoveredLan, - tailscaleAddress: matchingDiscovery?.tailscaleAddress ?? activeHostProfile?.tailscaleAddress + tailscaleAddress: matchingDiscovery?.tailscaleAddress + ?? (syncIsTailscaleRoute(connectedHost) ? connectedHost : nil) + ?? activeHostProfile?.tailscaleAddress ) saveProfile(profile) startRelayLoop() diff --git a/apps/ios/ADE/Views/Settings/SettingsPairingSection.swift b/apps/ios/ADE/Views/Settings/SettingsPairingSection.swift index 1bebb8af2..5da3e3a11 100644 --- a/apps/ios/ADE/Views/Settings/SettingsPairingSection.swift +++ b/apps/ios/ADE/Views/Settings/SettingsPairingSection.swift @@ -342,13 +342,17 @@ private struct DiscoveredHostRow: View { } private var primaryRoute: String { - host.addresses.first { address in - !isLoopback(address) && !syncIsTailscaleIPv4Address(address) + if let tailscaleAddress = host.tailscaleAddress, + detailPrefix?.localizedCaseInsensitiveContains("tailscale") == true { + return tailscaleAddress + } + return host.addresses.first { address in + !isLoopback(address) && !syncIsTailscaleRoute(address) } ?? host.tailscaleAddress ?? host.addresses.first ?? "No route" } private func inferredRoutePrefix(for route: String) -> String? { - if syncIsTailscaleIPv4Address(route) { + if syncIsTailscaleRoute(route) { return "Tailscale" } return nil diff --git a/apps/ios/ADETests/ADETests.swift b/apps/ios/ADETests/ADETests.swift index b8951573c..ac6ac2107 100644 --- a/apps/ios/ADETests/ADETests.swift +++ b/apps/ios/ADETests/ADETests.swift @@ -138,6 +138,18 @@ final class ADETests: XCTestCase { XCTAssertFalse(syncIsTailscaleIPv4Address("127.0.0.1")) } + func testSyncRecognizesTailscaleRoutes() { + XCTAssertTrue(syncIsTailscaleRoute("100.117.237.95")) + XCTAssertTrue(syncIsTailscaleRoute("ws://100.117.237.95:8787")) + XCTAssertTrue(syncIsTailscaleRoute("HTTPS://ADE-SYNC:8787/sync?source=settings")) + XCTAssertTrue(syncIsTailscaleRoute("ade-sync")) + XCTAssertTrue(syncIsTailscaleRoute("macbook.tailnet.ts.net")) + XCTAssertEqual(syncNormalizedRouteHost("ws://MACBOOK.tailnet.ts.net:8787/sync"), "macbook.tailnet.ts.net") + XCTAssertFalse(syncIsTailscaleRoute("192.168.68.102")) + XCTAssertFalse(syncIsTailscaleRoute("mac.local")) + XCTAssertFalse(syncIsTailscaleRoute("not-ts.net.example.com")) + } + func testSyncBonjourTimingMatchesReliabilityRequirements() { XCTAssertEqual(SyncBonjourTiming.searchRetryNanoseconds, 2_000_000_000) XCTAssertEqual(SyncBonjourTiming.resolveRetryNanoseconds, 2_000_000_000) From 4c208e2d51a2c1ae9474de5aeaabf4d5b2d1f066 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Wed, 22 Apr 2026 20:24:14 -0400 Subject: [PATCH 4/6] Fix chat freeze on remount, preserve resume across shutdown, tighten sync ordering MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Chat history and freeze recovery on remount - Add in-memory per-session chat event ring buffer in agentChatService alongside the on-disk transcript, wired into every commitChatEvent path. - New getChatEventHistory() merges the buffer with the on-disk transcript on first read, so a renderer that missed live events (project switch, tab switch, transient IPC drop) still recovers the full timeline. - Renderer AgentChatPane prefers the new snapshot API and clears its loaded-history gate in the catch path so a transient read failure no longer leaves the pane frozen with no retry. Single-session (Work tile) now force-reloads on mount, matching the other chat surfaces. - Clean up the buffer on deleteSession so a re-created session id doesn't inherit stale events. teardownRuntime no longer clobbers preserved Claude resume metadata - If teardownRuntime runs a second time on a session whose runtime was already torn down (e.g., idle_ttl eviction followed by app shutdown), bail early rather than falling through to the invalidating path that would reset runtimeInvalidated and clearLaneDirectiveKey. - Addresses PR #175 review comment on agentChatService.ts:5526. - Regression test: Claude session survives idle_ttl → shutdown with sdkSessionId and lastLaneDirectiveKey intact. iOS chat event ordering across truncated/merged snapshots - deduplicatedChatEventHistory now parses ISO-8601 timestamps with a fractional-seconds formatter + no-fraction fallback before comparing, so mixed-precision snapshots from the desktop sort chronologically instead of by lexical byte order. Addresses PR #175 review on SyncService.swift:4533. - recordChatEventEnvelope falls back to the full dedup/sort path when a live envelope predates the last-recorded envelope — keeps the common append-in-order fast path but heals out-of-order arrivals after a merge. Addresses PR #175 review on SyncService.swift:4504. - Regression tests for mixed fractional variants and delayed out-of-order live inserts. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../services/chat/agentChatService.test.ts | 147 +++++++++++++++++ .../main/services/chat/agentChatService.ts | 152 +++++++++++++++++- .../src/main/services/ipc/registerIpc.ts | 12 ++ apps/desktop/src/preload/global.d.ts | 8 + apps/desktop/src/preload/preload.ts | 5 + apps/desktop/src/renderer/browserMock.ts | 1 + .../components/chat/AgentChatPane.tsx | 61 +++++-- apps/desktop/src/shared/ipc.ts | 1 + apps/ios/ADE/Services/SyncService.swift | 41 ++++- apps/ios/ADETests/ADETests.swift | 61 +++++++ 10 files changed, 467 insertions(+), 22 deletions(-) diff --git a/apps/desktop/src/main/services/chat/agentChatService.test.ts b/apps/desktop/src/main/services/chat/agentChatService.test.ts index 1b6762f9b..dd1eefec9 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.test.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.test.ts @@ -4488,6 +4488,78 @@ describe("createAgentChatService", () => { }); }); + describe("getChatEventHistory", () => { + it("returns an empty history for an unknown session", async () => { + const { service } = createService(); + const history = service.getChatEventHistory("unknown-session"); + expect(history.events).toEqual([]); + expect(history.truncated).toBe(false); + }); + + it("hydrates history from the on-disk transcript on first read", async () => { + // This is the core contract that fixes chat-history-loss on project + // switch / tab switch: a late subscriber that missed the live broadcast + // still sees the full history, because getChatEventHistory hydrates + // itself from the transcript the first time the session is queried. + const sessionId = "hydrate-test-session"; + const envelope1: AgentChatEventEnvelope = { + sessionId, + timestamp: new Date().toISOString(), + event: { type: "text", text: "persisted-1" }, + sequence: 1, + }; + const envelope2: AgentChatEventEnvelope = { + sessionId, + timestamp: new Date().toISOString(), + event: { type: "text", text: "persisted-2" }, + sequence: 2, + }; + + const { service } = createService(); + // Seed a transcript file at the canonical chat transcript path so the + // service's disk-read fallback path has something to parse, then wire + // the parser mock to surface the persisted envelopes. + const transcriptFile = path.join( + tmpRoot, + ".ade", + "transcripts", + "chat", + `${sessionId}.jsonl`, + ); + fs.writeFileSync(transcriptFile, `${JSON.stringify(envelope1)}\n${JSON.stringify(envelope2)}\n`, "utf8"); + vi.mocked(parseAgentChatTranscript).mockReturnValue([envelope1, envelope2]); + + const history = service.getChatEventHistory(sessionId); + expect(history.sessionId).toBe(sessionId); + expect(history.events).toHaveLength(2); + expect(history.events.map((envelope) => + envelope.event.type === "text" ? envelope.event.text : "", + )).toEqual(["persisted-1", "persisted-2"]); + }); + + it("drops history when the underlying session is deleted", async () => { + // We don't rely on sendMessage emitting events (mock streams vary across + // providers), so we seed the transcript directly to verify the cleanup + // path. deleteSession must remove both the in-memory ring buffer and + // any hydrated-from-disk state so a subsequently-created session with + // the same id doesn't inherit stale events. + const emitted: AgentChatEventEnvelope[] = []; + const { service } = createService({ + onEvent: (event: AgentChatEventEnvelope) => emitted.push(event), + }); + const session = await service.createSession({ + laneId: "lane-1", + provider: "codex", + model: "gpt-5.4", + }); + await service.deleteSession({ sessionId: session.id }); + + const afterDelete = service.getChatEventHistory(session.id); + expect(afterDelete.events).toEqual([]); + expect(afterDelete.truncated).toBe(false); + }); + }); + // -------------------------------------------------------------------------- // Session creation edge cases // -------------------------------------------------------------------------- @@ -5249,6 +5321,81 @@ describe("createAgentChatService", () => { vi.useRealTimers(); } }); + + it("preserves Claude resume metadata across idle_ttl followed by shutdown", async () => { + vi.useFakeTimers(); + try { + const close = vi.fn(); + let streamCall = 0; + const send = vi.fn().mockResolvedValue(undefined); + const setPermissionMode = vi.fn().mockResolvedValue(undefined); + + const sessionHandle = { + send, + stream: vi.fn(() => (async function* () { + streamCall += 1; + if (streamCall === 1) { + yield { + type: "system", + subtype: "init", + session_id: "sdk-session-preserve", + slash_commands: [], + }; + yield { + type: "result", + usage: { input_tokens: 1, output_tokens: 1 }, + }; + return; + } + yield { + type: "assistant", + session_id: "sdk-session-preserve", + message: { + content: [{ type: "text", text: "Done." }], + usage: { input_tokens: 1, output_tokens: 1 }, + }, + }; + yield { type: "result", usage: { input_tokens: 1, output_tokens: 1 } }; + })()), + close, + sessionId: "sdk-session-preserve", + setPermissionMode, + }; + + vi.mocked(unstable_v2_createSession).mockReturnValue(sessionHandle as any); + vi.mocked(unstable_v2_resumeSession).mockReturnValue(sessionHandle as any); + + const { service } = createService(); + const session = await service.createSession({ + laneId: "lane-1", + provider: "claude", + model: "sonnet", + }); + + await service.runSessionTurn({ + sessionId: session.id, + text: "Say hi", + timeoutMs: 15_000, + }); + + // Idle-ttl teardown persists sdkSessionId + laneDirectiveKey. + await vi.advanceTimersByTimeAsync(6 * 60_000); + const persistedAfterIdle = readPersistedChatState(session.id); + expect(persistedAfterIdle.sdkSessionId).toBe("sdk-session-preserve"); + const preservedLaneDirective = persistedAfterIdle.lastLaneDirectiveKey; + expect(preservedLaneDirective).toEqual(expect.any(String)); + + // Shutdown re-enters teardownRuntime with runtime already null. Must + // NOT clobber the preserved sdkSessionId/laneDirectiveKey. + service.forceDisposeAll(); + + const persistedAfterShutdown = readPersistedChatState(session.id); + expect(persistedAfterShutdown.sdkSessionId).toBe("sdk-session-preserve"); + expect(persistedAfterShutdown.lastLaneDirectiveKey).toBe(preservedLaneDirective); + } finally { + vi.useRealTimers(); + } + }); }); // -------------------------------------------------------------------------- diff --git a/apps/desktop/src/main/services/chat/agentChatService.ts b/apps/desktop/src/main/services/chat/agentChatService.ts index efc5ca254..541784ed2 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.ts @@ -2522,6 +2522,26 @@ export function createAgentChatService(args: { const eventSubscribers = new Set<(event: AgentChatEventEnvelope) => void>(); + // In-memory ring buffer of recent chat events per session. Populated on every + // emitted event (see emitChatEvent → commitChatEvent), and also lazily hydrated + // from the on-disk transcript when a snapshot is requested. This is the canonical + // source of truth for "what should the renderer see right now" on resubscribe, + // remount, or project switch — persisted-transcript reads can miss events that + // were emitted while fs.appendFile was still in flight, and a project-switch + // gap drops all IPC deliveries until the user returns. + const CHAT_EVENT_HISTORY_MAX_PER_SESSION = 2_000; + const eventHistoryBySession = new Map(); + const eventHistoryHydratedSessionIds = new Set(); + + const recordChatEventInHistory = (envelope: AgentChatEventEnvelope): void => { + const current = eventHistoryBySession.get(envelope.sessionId) ?? []; + current.push(envelope); + if (current.length > CHAT_EVENT_HISTORY_MAX_PER_SESSION) { + current.splice(0, current.length - CHAT_EVENT_HISTORY_MAX_PER_SESSION); + } + eventHistoryBySession.set(envelope.sessionId, current); + }; + let computerUseArtifactBrokerRef = computerUseArtifactBrokerService ?? null; const layout = resolveAdeLayout(projectRoot); @@ -3665,6 +3685,119 @@ export function createAgentChatService(args: { } }; + // Read the full on-disk transcript for a session without requiring an active + // ManagedChatSession. Used by getChatEventHistory to hydrate the in-memory + // ring buffer on first read, even for sessions that haven't been resumed yet + // (e.g. a chat whose runtime was torn down by idle_ttl / budget_eviction). + const readTranscriptEnvelopesForSessionId = (sessionId: string): AgentChatEventEnvelope[] => { + const managed = managedSessions.get(sessionId); + if (managed?.transcriptPath) { + try { + return parseAgentChatTranscript(fs.readFileSync(managed.transcriptPath, "utf8")) + .filter((entry) => entry.sessionId === sessionId); + } catch { + return []; + } + } + // Fall back to the known transcript layout so sessions that were never + // ensured into managedSessions (e.g. because they were torn down and + // haven't been reopened yet) still surface their history. + const candidates = [ + path.join(transcriptsDir, `${sessionId}.chat.jsonl`), + path.join(chatTranscriptsDir, `${sessionId}.jsonl`), + ]; + for (const candidatePath of candidates) { + try { + if (!fs.existsSync(candidatePath)) continue; + const raw = fs.readFileSync(candidatePath, "utf8"); + return parseAgentChatTranscript(raw).filter((entry) => entry.sessionId === sessionId); + } catch { + // try next candidate + } + } + return []; + }; + + const mergeEnvelopeStreams = ( + base: AgentChatEventEnvelope[], + tail: AgentChatEventEnvelope[], + ): AgentChatEventEnvelope[] => { + if (!base.length) return tail.slice(); + if (!tail.length) return base.slice(); + // Prefer sequence numbers when both sides have them; fall back to timestamps. + const baseBySequence = new Map(); + const baseByTimestamp = new Map(); + for (const entry of base) { + if (typeof entry.sequence === "number") { + baseBySequence.set(entry.sequence, entry); + } + baseByTimestamp.set(`${entry.timestamp}#${entry.event.type}`, entry); + } + const merged = base.slice(); + for (const entry of tail) { + if (typeof entry.sequence === "number" && baseBySequence.has(entry.sequence)) continue; + if (baseByTimestamp.has(`${entry.timestamp}#${entry.event.type}`)) continue; + merged.push(entry); + } + merged.sort((left, right) => { + if (typeof left.sequence === "number" && typeof right.sequence === "number") { + return left.sequence - right.sequence; + } + return Date.parse(left.timestamp) - Date.parse(right.timestamp); + }); + return merged; + }; + + /** + * Return the complete, ordered event history for a chat session. + * + * On first call (or any call that can tolerate a larger read), we merge the + * on-disk transcript with the in-memory ring buffer so that: + * - events that were emitted while the renderer was on a different project + * (and therefore dropped by emitProjectEvent) are still recovered; + * - events that are still in fs.appendFile flight but already recorded in + * the buffer are still delivered; + * - truncating the persistent transcript for size does not lose recent + * events that the buffer still has. + * + * This is the canonical snapshot path for renderer resubscribe / remount. + */ + const getChatEventHistory = ( + sessionId: string, + options?: { maxEvents?: number }, + ): { sessionId: string; events: AgentChatEventEnvelope[]; truncated: boolean } => { + const trimmedId = sessionId.trim(); + if (!trimmedId.length) { + return { sessionId: trimmedId, events: [], truncated: false }; + } + const maxEvents = Math.max( + 1, + Math.min(CHAT_EVENT_HISTORY_MAX_PER_SESSION, Math.floor(options?.maxEvents ?? CHAT_EVENT_HISTORY_MAX_PER_SESSION)), + ); + + // Hydrate the in-memory buffer from disk the first time we see a session, + // so a resubscribe after project switch or app restart has both the + // persisted history and any live events that arrived afterwards. + const bufferExisting = eventHistoryBySession.get(trimmedId) ?? []; + let merged: AgentChatEventEnvelope[]; + if (!eventHistoryHydratedSessionIds.has(trimmedId)) { + const diskEnvelopes = readTranscriptEnvelopesForSessionId(trimmedId); + merged = mergeEnvelopeStreams(diskEnvelopes, bufferExisting); + // Cap after hydration so subsequent writes don't drift past the limit. + if (merged.length > CHAT_EVENT_HISTORY_MAX_PER_SESSION) { + merged = merged.slice(-CHAT_EVENT_HISTORY_MAX_PER_SESSION); + } + eventHistoryBySession.set(trimmedId, merged.slice()); + eventHistoryHydratedSessionIds.add(trimmedId); + } else { + merged = bufferExisting.slice(); + } + + const truncated = merged.length > maxEvents; + const windowed = truncated ? merged.slice(-maxEvents) : merged; + return { sessionId: trimmedId, events: windowed, truncated }; + }; + const deriveTranscriptTurnActive = (entries: AgentChatEventEnvelope[]): boolean => { let turnActive = false; for (const entry of entries) { @@ -5105,6 +5238,7 @@ export function createAgentChatService(args: { }; writeTranscript(managed, envelope); + recordChatEventInHistory(envelope); onEvent?.(envelope); for (const subscriber of eventSubscribers) { try { @@ -5522,8 +5656,18 @@ export function createAgentChatService(args: { managed: ManagedChatSession, openCodeReason: "handle_close" | "idle_ttl" | "ended_session" | "model_switch" | "project_close" | "budget_eviction" | "pool_compaction" | "paused_run" | "shutdown" = "handle_close", ): void => { + flushBufferedReasoning(managed); + flushBufferedText(managed); + + // If a prior teardown (e.g., idle_ttl, budget_eviction) already tore this + // runtime down, re-running teardown on shutdown would see runtime=null, + // compute preserveClaudeResumeState=false, and then clobber the + // previously-preserved sdkSessionId/laneDirectiveKey via the reset below. + // Bail early so the prior teardown's resume metadata stays on disk. + if (!managed.runtime) return; + const preserveClaudeResumeState = - managed.runtime?.kind === "claude" + managed.runtime.kind === "claude" && ( openCodeReason === "idle_ttl" || openCodeReason === "budget_eviction" @@ -5532,9 +5676,6 @@ export function createAgentChatService(args: { || openCodeReason === "project_close" || openCodeReason === "shutdown" ); - - flushBufferedReasoning(managed); - flushBufferedText(managed); if (managed.runtime?.kind === "codex") { managed.runtime.suppressExitError = true; try { managed.runtime.reader.close(); } catch { /* ignore */ } @@ -13008,6 +13149,8 @@ export function createAgentChatService(args: { } else { clearSubagentSnapshots(trimmedSessionId); } + eventHistoryBySession.delete(trimmedSessionId); + eventHistoryHydratedSessionIds.delete(trimmedSessionId); const persistedMetadataPath = metadataPathFor(trimmedSessionId); const dedicatedTranscriptPath = path.join(chatTranscriptsDir, `${trimmedSessionId}.jsonl`); @@ -13820,6 +13963,7 @@ export function createAgentChatService(args: { listSessions, getSessionSummary, getChatTranscript, + getChatEventHistory, ensureIdentitySession, approveToolUse, respondToInput, diff --git a/apps/desktop/src/main/services/ipc/registerIpc.ts b/apps/desktop/src/main/services/ipc/registerIpc.ts index 549e65250..228b1e483 100644 --- a/apps/desktop/src/main/services/ipc/registerIpc.ts +++ b/apps/desktop/src/main/services/ipc/registerIpc.ts @@ -182,6 +182,7 @@ import type { AgentChatInterruptArgs, AgentChatListArgs, AgentChatModelInfo, + AgentChatEventEnvelope, AgentChatModelsArgs, AgentChatPermissionMode, AgentChatRespondToInputArgs, @@ -4408,6 +4409,17 @@ export function registerIpc({ }; }); + ipcMain.handle(IPC.agentChatGetEventHistory, async ( + _event, + arg: { sessionId?: string; maxEvents?: number }, + ): Promise<{ sessionId: string; events: AgentChatEventEnvelope[]; truncated: boolean }> => { + const ctx = getCtx(); + const sessionId = typeof arg?.sessionId === "string" ? arg.sessionId.trim() : ""; + if (!sessionId) return { sessionId: "", events: [], truncated: false }; + const maxEvents = typeof arg?.maxEvents === "number" ? arg.maxEvents : undefined; + return ctx.agentChatService.getChatEventHistory(sessionId, maxEvents != null ? { maxEvents } : undefined); + }); + ipcMain.handle(IPC.computerUseListArtifacts, async (_event, arg: ComputerUseArtifactListArgs = {}): Promise => { const ctx = ensureComputerUseBroker(); return ctx.computerUseArtifactBrokerService.listArtifacts(arg); diff --git a/apps/desktop/src/preload/global.d.ts b/apps/desktop/src/preload/global.d.ts index ff2a13989..a411e212e 100644 --- a/apps/desktop/src/preload/global.d.ts +++ b/apps/desktop/src/preload/global.d.ts @@ -1145,6 +1145,14 @@ declare global { data: string; filename: string; }) => Promise<{ path: string }>; + getEventHistory: (args: { + sessionId: string; + maxEvents?: number; + }) => Promise<{ + sessionId: string; + events: AgentChatEventEnvelope[]; + truncated: boolean; + }>; }; computerUse: { listArtifacts: ( diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index 32a4a5664..9c7652af0 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -1624,6 +1624,11 @@ contextBridge.exposeInMainWorld("ade", { filename: string; }): Promise<{ path: string }> => ipcRenderer.invoke(IPC.agentChatSaveTempAttachment, args), + getEventHistory: async (args: { + sessionId: string; + maxEvents?: number; + }): Promise<{ sessionId: string; events: AgentChatEventEnvelope[]; truncated: boolean }> => + ipcRenderer.invoke(IPC.agentChatGetEventHistory, args), }, computerUse: { listArtifacts: async ( diff --git a/apps/desktop/src/renderer/browserMock.ts b/apps/desktop/src/renderer/browserMock.ts index 4cc0108c6..6bce0d258 100644 --- a/apps/desktop/src/renderer/browserMock.ts +++ b/apps/desktop/src/renderer/browserMock.ts @@ -2382,6 +2382,7 @@ if (typeof window !== "undefined" && !(window as any).ade) { onEvent: noop, slashCommands: resolvedArg([]), fileSearch: resolvedArg([]), + getEventHistory: resolvedArg({ sessionId: "mock", events: [], truncated: false }), }, cto: { getState: resolvedArg({ diff --git a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx index fb2e5e72d..c01935fe2 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx @@ -1434,19 +1434,45 @@ export function AgentChatPane({ loadedHistoryRef.current.add(sessionId); try { - const summary = await window.ade.sessions.get(sessionId); - if (!summary || !isChatToolType(summary.toolType)) return; - const raw = await window.ade.sessions.readTranscriptTail({ - sessionId, - maxBytes: CHAT_HISTORY_READ_MAX_BYTES, - raw: true - }); - const parsed = parseAgentChatTranscript(raw).filter((entry) => entry.sessionId === sessionId); + // Prefer the main-process snapshot API which merges the in-memory event + // ring buffer with the on-disk transcript. This recovers events that + // were emitted while the user was on a different project (IPC dropped), + // events that were still in fs.appendFile flight when a previous load + // ran, and the full history even when the transcript has been truncated + // for size. Fall back to the disk-only readTranscriptTail path if the + // snapshot call fails or the desktop app is running against an older + // main-process build that lacks the handler. + let parsed: AgentChatEventEnvelope[] = []; + let usedSnapshotPath = false; + try { + if (typeof window.ade.agentChat.getEventHistory === "function") { + const snapshot = await window.ade.agentChat.getEventHistory({ + sessionId, + maxEvents: MAX_SELECTED_CHAT_SESSION_EVENTS, + }); + if (snapshot?.events?.length || snapshot?.sessionId === sessionId) { + parsed = (snapshot.events ?? []).filter((entry) => entry.sessionId === sessionId); + usedSnapshotPath = true; + } + } + } catch { + usedSnapshotPath = false; + } + if (!usedSnapshotPath) { + const summary = await window.ade.sessions.get(sessionId); + if (!summary || !isChatToolType(summary.toolType)) return; + const raw = await window.ade.sessions.readTranscriptTail({ + sessionId, + maxBytes: CHAT_HISTORY_READ_MAX_BYTES, + raw: true + }); + parsed = parseAgentChatTranscript(raw).filter((entry) => entry.sessionId === sessionId); + } // If real-time events have already been received for this session - // (via flushQueuedEvents), the on-disk transcript may be stale. - // Merge: use the loaded history as a base but keep any real-time - // events that arrived after the last event in the transcript. + // (via flushQueuedEvents), the snapshot may be stale by a few events. + // Merge: use the snapshot as a base but keep any real-time events that + // arrived after the last snapshot entry. const existing = eventsBySessionRef.current[sessionId] ?? []; let merged: AgentChatEventEnvelope[]; if (existing.length && parsed.length) { @@ -1486,7 +1512,11 @@ export function AgentChatPane({ setPendingInputsBySession((prev) => ({ ...prev, [sessionId]: derived.pendingInputs })); setPendingSteersBySession((prev) => ({ ...prev, [sessionId]: derived.pendingSteers })); } catch { - // Ignore transcript history failures. + // Clear the loaded flag so the caller can retry on next remount or tab + // switch — otherwise a transient failure leaves the UI stuck with no + // events. Without this clearSessionView, a failed initial load + // permanently blocked re-entry until the chat received a new event. + loadedHistoryRef.current.delete(sessionId); } }, [initialSessionSummary, lockSessionId]); @@ -1691,8 +1721,13 @@ export function AgentChatPane({ void loadHistory(selectedSessionId, { force: true }); return; } + // Locked-single-session mode (Work tab tile). Force-reload on every mount + // so that when the pane is unmounted and remounted (tab switch, project + // switch, session tile activation) we always pull the freshest snapshot + // rather than short-circuiting on a stale loadedHistoryRef from the + // previous component instance. const handle = window.setTimeout(() => { - void loadHistory(selectedSessionId); + void loadHistory(selectedSessionId, { force: true }); }, 120); return () => window.clearTimeout(handle); }, [loadHistory, lockedSingleSessionMode, selectedSessionId]); diff --git a/apps/desktop/src/shared/ipc.ts b/apps/desktop/src/shared/ipc.ts index 86e5e4692..f96736444 100644 --- a/apps/desktop/src/shared/ipc.ts +++ b/apps/desktop/src/shared/ipc.ts @@ -148,6 +148,7 @@ export const IPC = { agentChatListSubagents: "ade.agentChat.listSubagents", agentChatGetSessionCapabilities: "ade.agentChat.getSessionCapabilities", agentChatGetTurnFileDiff: "ade.agentChat.getTurnFileDiff", + agentChatGetEventHistory: "ade.agentChat.getEventHistory", computerUseListArtifacts: "ade.computerUse.listArtifacts", computerUseGetOwnerSnapshot: "ade.computerUse.getOwnerSnapshot", computerUseRouteArtifact: "ade.computerUse.routeArtifact", diff --git a/apps/ios/ADE/Services/SyncService.swift b/apps/ios/ADE/Services/SyncService.swift index 308e75e63..e7954b434 100644 --- a/apps/ios/ADE/Services/SyncService.swift +++ b/apps/ios/ADE/Services/SyncService.swift @@ -4500,8 +4500,25 @@ final class SyncService: ObservableObject { func recordChatEventEnvelope(_ envelope: AgentChatEventEnvelope) { var events = chatEventEnvelopesBySession[envelope.sessionId] ?? [] guard !events.contains(where: { $0.id == envelope.id }) else { return } - events.append(envelope) - events = trimChatEventHistory(events) + // Fast path: arrival-order appends stay sorted when timestamps are + // monotonically non-decreasing — common for live streaming. Out-of-order + // deliveries (e.g., a delayed tool_result arriving after a later text + // fragment, or a merge with a historical snapshot) fall through to the + // full dedup/sort in deduplicatedChatEventHistory so bubble order matches + // the replace/merge paths. + let canAppendInOrder: Bool = { + guard let last = events.last else { return true } + let lastDate = Self.parseIso8601(last.timestamp) + let envelopeDate = Self.parseIso8601(envelope.timestamp) + if let lhs = envelopeDate, let rhs = lastDate { return lhs >= rhs } + return envelope.timestamp >= last.timestamp + }() + if canAppendInOrder { + events.append(envelope) + events = trimChatEventHistory(events) + } else { + events = deduplicatedChatEventHistory(events + [envelope]) + } chatEventEnvelopesBySession[envelope.sessionId] = events chatEventRevisionsBySession[envelope.sessionId, default: 0] += 1 lastSyncAt = Date() @@ -4531,10 +4548,24 @@ final class SyncService: ObservableObject { return true } .sorted { lhs, rhs in - if lhs.timestamp == rhs.timestamp { - return (lhs.sequence ?? 0) < (rhs.sequence ?? 0) + // Parse timestamps to Date before comparing — a lexicographic compare + // misorders mixed ISO-8601 variants (e.g., "…56.500Z" sorts before + // "…56Z" because "." < "Z" in ASCII, even though chronologically it's + // half a second later). + let lhsDate = Self.parseIso8601(lhs.timestamp) + let rhsDate = Self.parseIso8601(rhs.timestamp) + if lhsDate == rhsDate { + if lhs.timestamp == rhs.timestamp { + return (lhs.sequence ?? 0) < (rhs.sequence ?? 0) + } + return lhs.timestamp < rhs.timestamp + } + switch (lhsDate, rhsDate) { + case (let l?, let r?): return l < r + case (nil, _?): return true + case (_?, nil): return false + case (nil, nil): return lhs.timestamp < rhs.timestamp } - return lhs.timestamp < rhs.timestamp } return trimChatEventHistory(unique) } diff --git a/apps/ios/ADETests/ADETests.swift b/apps/ios/ADETests/ADETests.swift index a7e7cc166..1c27c5a57 100644 --- a/apps/ios/ADETests/ADETests.swift +++ b/apps/ios/ADETests/ADETests.swift @@ -428,6 +428,67 @@ final class ADETests: XCTestCase { XCTAssertEqual(service.chatEventHistory(sessionId: "session-1"), [fresh]) } + @MainActor + func testChatEventHistoryOrdersByParsedTimestampAcrossMixedFractionalVariants() async throws { + let service = SyncService(database: makeDatabase(baseURL: makeTemporaryDirectory())) + // Lexicographic compare misorders these: "…56Z" > "…56.500Z" because + // "Z" (0x5A) > "." (0x2E) in ASCII. Chronologically "…56Z" comes first. + let noFractional = AgentChatEventEnvelope( + sessionId: "session-1", + timestamp: "2026-03-17T00:00:56Z", + event: .userMessage(text: "first", attachments: [], turnId: "turn-1", steerId: nil, deliveryState: nil, processed: nil), + sequence: 1, + provenance: nil + ) + let withFractional = AgentChatEventEnvelope( + sessionId: "session-1", + timestamp: "2026-03-17T00:00:56.500Z", + event: .text(text: "second", messageId: "msg-1", turnId: "turn-1", itemId: "item-1"), + sequence: 2, + provenance: nil + ) + + service.replaceChatEventHistory(sessionId: "session-1", events: [withFractional, noFractional]) + + let history = service.chatEventHistory(sessionId: "session-1") + XCTAssertEqual(history.map(\.id), [noFractional.id, withFractional.id]) + } + + @MainActor + func testRecordChatEventEnvelopeSortsWhenLiveEventArrivesOutOfOrder() async throws { + let service = SyncService(database: makeDatabase(baseURL: makeTemporaryDirectory())) + let earlier = AgentChatEventEnvelope( + sessionId: "session-1", + timestamp: "2026-03-17T00:00:01.000Z", + event: .userMessage(text: "first", attachments: [], turnId: "turn-1", steerId: nil, deliveryState: nil, processed: nil), + sequence: 1, + provenance: nil + ) + let later = AgentChatEventEnvelope( + sessionId: "session-1", + timestamp: "2026-03-17T00:00:02.000Z", + event: .text(text: "second", messageId: "msg-1", turnId: "turn-1", itemId: "item-1"), + sequence: 2, + provenance: nil + ) + + service.mergeChatEventHistory(sessionId: "session-1", events: [earlier, later]) + // Live envelope arrives out of order (delayed tool_result that predates the + // already-merged later envelope). Must be inserted in chronological order + // rather than appended to the end. + let delayedInsert = AgentChatEventEnvelope( + sessionId: "session-1", + timestamp: "2026-03-17T00:00:01.500Z", + event: .toolResult(tool: "fs_read", result: .string("ok"), itemId: "tool-1", logicalItemId: "tool-1", parentItemId: nil, turnId: "turn-1", status: "completed"), + sequence: 3, + provenance: nil + ) + service.recordChatEventEnvelope(delayedInsert) + + let history = service.chatEventHistory(sessionId: "session-1") + XCTAssertEqual(history.map(\.id), [earlier.id, delayedInsert.id, later.id]) + } + func testChatCommandRequestPayloadsEncodeExpectedShapes() throws { let subscribe = try jsonDictionary(from: AgentChatSubscriptionRequest(sessionId: "session-1")) XCTAssertEqual(subscribe["sessionId"] as? String, "session-1") From d0e23501993117986d8dc52331369891fd88999c Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Wed, 22 Apr 2026 20:52:45 -0400 Subject: [PATCH 5/6] Address second round of review feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Terminal teardown must still invalidate when runtime was already torn down - teardownRuntime no longer skips runtimeInvalidated/clearLaneDirectiveKey for terminal reasons (handle_close / ended_session / model_switch) when managed.runtime is already null. Previously: idle_ttl preserved, then ended_session returned early, leaving a stale sdkSessionId/lane key that a future resume could reattach to. - Added the mirror regression test to the preserve-across-shutdown one. - Fixes capy-ai + coderabbit Major on agentChatService.ts:5667/5678. getChatEventHistory validates sessionId before reading transcript paths - The new IPC-reachable history API builds filesystem paths from sessionId, so reject ids that aren't registered agent-chat sessions up front instead of trusting raw input. Fixes coderabbit Major on agentChatService.ts:3713. Device registry preserves per-project legacy IDs - If a project has a legacy per-project sync-device-id, always use it so existing iOS pairings and sync_cluster_state.brain_device_id stay valid; only write the shared file when it would agree. Prevents opening project B after A from silently adopting A's ID and dropping B into viewer mode. - Fixes capy-ai High on deviceRegistryService.ts:149. mergeEnvelopeStreams dedup by timestamp+type, not sequence - eventSequence restarts at 0 on session rebuild, so the same ordinals legitimately appear in the persisted transcript and the current-run buffer. Dedup on (timestamp, event.type) so fresh live envelopes aren't dropped as false duplicates on first hydration after process restart. - Fixes capy-ai Medium on agentChatService.ts:3738. iOS + renderer polish - Restore port 8788 to iOS tailnet candidates — desktop still falls back to 8788 when 8787 is busy (syncService.ts retry). - Preserve unbracketed IPv6 literals in syncNormalizedRouteHost (strip host:port only when there's exactly one colon). - Fall back from blank laneName to laneId in orphan-lane group labels on both desktop and iOS. - iOS orphan-lane ordering test uses distinct startedAt timestamps so chronological-sort is actually exercised. - AgentChatPane fallback early-return also clears loadedHistoryRef so a session-lookup miss no longer blocks future retries. - browserMock.getEventHistory echoes the requested sessionId. - syncService.test host-mock override stubs setDiscoveryEnabled. - agentChatService.test "drops history" now seeds history before delete so a regression that fails to clear the hydrated cache is actually caught. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../services/chat/agentChatService.test.ts | 129 +++++++++++++++--- .../main/services/chat/agentChatService.ts | 71 ++++++---- .../src/main/services/ipc/registerIpc.ts | 11 +- .../services/sync/deviceRegistryService.ts | 14 +- .../main/services/sync/syncService.test.ts | 1 + apps/desktop/src/renderer/browserMock.ts | 6 +- .../components/chat/AgentChatPane.tsx | 9 +- .../components/terminals/SessionListPane.tsx | 11 +- apps/ios/ADE/Services/SyncService.swift | 11 +- .../ADE/Views/Work/WorkSessionGrouping.swift | 10 +- apps/ios/ADETests/ADETests.swift | 21 ++- 11 files changed, 230 insertions(+), 64 deletions(-) diff --git a/apps/desktop/src/main/services/chat/agentChatService.test.ts b/apps/desktop/src/main/services/chat/agentChatService.test.ts index dd1eefec9..6342b737c 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.test.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.test.ts @@ -4501,36 +4501,34 @@ describe("createAgentChatService", () => { // switch / tab switch: a late subscriber that missed the live broadcast // still sees the full history, because getChatEventHistory hydrates // itself from the transcript the first time the session is queried. - const sessionId = "hydrate-test-session"; + const { service } = createService(); + const session = await service.createSession({ + laneId: "lane-1", + provider: "codex", + model: "gpt-5.4", + }); + const envelope1: AgentChatEventEnvelope = { - sessionId, + sessionId: session.id, timestamp: new Date().toISOString(), event: { type: "text", text: "persisted-1" }, sequence: 1, }; const envelope2: AgentChatEventEnvelope = { - sessionId, + sessionId: session.id, timestamp: new Date().toISOString(), event: { type: "text", text: "persisted-2" }, sequence: 2, }; - const { service } = createService(); - // Seed a transcript file at the canonical chat transcript path so the - // service's disk-read fallback path has something to parse, then wire - // the parser mock to surface the persisted envelopes. - const transcriptFile = path.join( - tmpRoot, - ".ade", - "transcripts", - "chat", - `${sessionId}.jsonl`, - ); + // Seed the transcript file at the path managed.transcriptPath points + // to (set by createSession → managedSessions → row.transcriptPath). + const transcriptFile = path.join(tmpRoot, "transcripts", `${session.id}.chat.jsonl`); fs.writeFileSync(transcriptFile, `${JSON.stringify(envelope1)}\n${JSON.stringify(envelope2)}\n`, "utf8"); vi.mocked(parseAgentChatTranscript).mockReturnValue([envelope1, envelope2]); - const history = service.getChatEventHistory(sessionId); - expect(history.sessionId).toBe(sessionId); + const history = service.getChatEventHistory(session.id); + expect(history.sessionId).toBe(session.id); expect(history.events).toHaveLength(2); expect(history.events.map((envelope) => envelope.event.type === "text" ? envelope.event.text : "", @@ -4552,8 +4550,33 @@ describe("createAgentChatService", () => { provider: "codex", model: "gpt-5.4", }); + + // Seed the transcript on disk and populate the hydrated-from-disk cache + // BEFORE deleting, so a regression where deleteSession fails to clear + // the cache would actually be caught (an empty history trivially stays + // empty). + const envelope: AgentChatEventEnvelope = { + sessionId: session.id, + timestamp: new Date().toISOString(), + event: { type: "text", text: "before-delete" }, + sequence: 1, + }; + // createSession assigns managed.transcriptPath under `transcriptsDir`, + // so the hydration read is served from there (ahead of the + // chatTranscriptsDir fallback). + const transcriptFile = path.join(tmpRoot, "transcripts", `${session.id}.chat.jsonl`); + fs.mkdirSync(path.dirname(transcriptFile), { recursive: true }); + fs.writeFileSync(transcriptFile, `${JSON.stringify(envelope)}\n`, "utf8"); + vi.mocked(parseAgentChatTranscript).mockReturnValue([envelope]); + const beforeDelete = service.getChatEventHistory(session.id); + expect(beforeDelete.events).toHaveLength(1); + await service.deleteSession({ sessionId: session.id }); + // The transcript-returning parser mock is still wired up, so if + // deleteSession fails to clear the cache / on-disk file, the next read + // would still surface envelopes. An empty result proves both the + // in-memory ring buffer and the hydrated state were cleared. const afterDelete = service.getChatEventHistory(session.id); expect(afterDelete.events).toEqual([]); expect(afterDelete.truncated).toBe(false); @@ -5396,6 +5419,80 @@ describe("createAgentChatService", () => { vi.useRealTimers(); } }); + + it("clears Claude resume metadata when a terminal teardown runs after idle_ttl", async () => { + vi.useFakeTimers(); + try { + const close = vi.fn(); + let streamCall = 0; + const send = vi.fn().mockResolvedValue(undefined); + const setPermissionMode = vi.fn().mockResolvedValue(undefined); + + const sessionHandle = { + send, + stream: vi.fn(() => (async function* () { + streamCall += 1; + if (streamCall === 1) { + yield { + type: "system", + subtype: "init", + session_id: "sdk-session-terminal", + slash_commands: [], + }; + yield { type: "result", usage: { input_tokens: 1, output_tokens: 1 } }; + return; + } + yield { + type: "assistant", + session_id: "sdk-session-terminal", + message: { + content: [{ type: "text", text: "Done." }], + usage: { input_tokens: 1, output_tokens: 1 }, + }, + }; + yield { type: "result", usage: { input_tokens: 1, output_tokens: 1 } }; + })()), + close, + sessionId: "sdk-session-terminal", + setPermissionMode, + }; + + vi.mocked(unstable_v2_createSession).mockReturnValue(sessionHandle as any); + vi.mocked(unstable_v2_resumeSession).mockReturnValue(sessionHandle as any); + + const { service } = createService(); + const session = await service.createSession({ + laneId: "lane-1", + provider: "claude", + model: "sonnet", + }); + + await service.runSessionTurn({ + sessionId: session.id, + text: "Say hi", + timeoutMs: 15_000, + }); + + // idle_ttl preserves sdkSessionId/laneDirectiveKey. + await vi.advanceTimersByTimeAsync(6 * 60_000); + const persistedAfterIdle = readPersistedChatState(session.id); + expect(persistedAfterIdle.sdkSessionId).toBe("sdk-session-terminal"); + expect(persistedAfterIdle.lastLaneDirectiveKey).toEqual(expect.any(String)); + + // Terminal teardown (user closes the chat) runs teardownRuntime with + // reason "ended_session" and runtime already null. Must still clear + // the preserved lane directive so a future resume of a different + // chat can't reattach to this ended session's lane context. + // dispose → finishSession → teardownRuntime("ended_session") without + // deleting the persisted state file. + await service.dispose({ sessionId: session.id }); + + const persistedAfterDispose = readPersistedChatState(session.id); + expect(persistedAfterDispose.lastLaneDirectiveKey ?? null).toBeNull(); + } finally { + vi.useRealTimers(); + } + }); }); // -------------------------------------------------------------------------- diff --git a/apps/desktop/src/main/services/chat/agentChatService.ts b/apps/desktop/src/main/services/chat/agentChatService.ts index 541784ed2..41f96bd5d 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.ts @@ -3724,26 +3724,29 @@ export function createAgentChatService(args: { ): AgentChatEventEnvelope[] => { if (!base.length) return tail.slice(); if (!tail.length) return base.slice(); - // Prefer sequence numbers when both sides have them; fall back to timestamps. - const baseBySequence = new Map(); - const baseByTimestamp = new Map(); - for (const entry of base) { - if (typeof entry.sequence === "number") { - baseBySequence.set(entry.sequence, entry); - } - baseByTimestamp.set(`${entry.timestamp}#${entry.event.type}`, entry); - } + // Don't dedup by sequence: `eventSequence` restarts at 0 every time a + // managed session is rebuilt (process restart, project switch), so the + // same ordinals legitimately appear in the persisted transcript (from + // a prior run) and in the in-memory buffer (from the current run) for + // totally different events. Use timestamp+type — writeTranscript and + // recordChatEventInHistory emit from the same envelope so true + // duplicates match exactly. + const baseKeys = new Set(base.map((entry) => `${entry.timestamp}#${entry.event.type}`)); const merged = base.slice(); for (const entry of tail) { - if (typeof entry.sequence === "number" && baseBySequence.has(entry.sequence)) continue; - if (baseByTimestamp.has(`${entry.timestamp}#${entry.event.type}`)) continue; + if (baseKeys.has(`${entry.timestamp}#${entry.event.type}`)) continue; merged.push(entry); } merged.sort((left, right) => { + // Timestamp is cross-run consistent; sequence is only a tiebreak + // within the same run. + const leftTime = Date.parse(left.timestamp); + const rightTime = Date.parse(right.timestamp); + if (leftTime !== rightTime) return leftTime - rightTime; if (typeof left.sequence === "number" && typeof right.sequence === "number") { return left.sequence - right.sequence; } - return Date.parse(left.timestamp) - Date.parse(right.timestamp); + return 0; }); return merged; }; @@ -3770,6 +3773,13 @@ export function createAgentChatService(args: { if (!trimmedId.length) { return { sessionId: trimmedId, events: [], truncated: false }; } + // Validate the session belongs to an agent chat before reading any + // transcript path — this function is reachable via IPC and builds + // filesystem paths from `trimmedId` downstream. + const row = sessionService.get(trimmedId); + if (!row || !isChatToolType(row.toolType)) { + return { sessionId: trimmedId, events: [], truncated: false }; + } const maxEvents = Math.max( 1, Math.min(CHAT_EVENT_HISTORY_MAX_PER_SESSION, Math.floor(options?.maxEvents ?? CHAT_EVENT_HISTORY_MAX_PER_SESSION)), @@ -5659,23 +5669,30 @@ export function createAgentChatService(args: { flushBufferedReasoning(managed); flushBufferedText(managed); - // If a prior teardown (e.g., idle_ttl, budget_eviction) already tore this - // runtime down, re-running teardown on shutdown would see runtime=null, - // compute preserveClaudeResumeState=false, and then clobber the - // previously-preserved sdkSessionId/laneDirectiveKey via the reset below. - // Bail early so the prior teardown's resume metadata stays on disk. - if (!managed.runtime) return; + const reasonAllowsPreservation = + openCodeReason === "idle_ttl" + || openCodeReason === "budget_eviction" + || openCodeReason === "pool_compaction" + || openCodeReason === "paused_run" + || openCodeReason === "project_close" + || openCodeReason === "shutdown"; + + // If a prior teardown (e.g., idle_ttl) already released the runtime: + // - Non-terminal reasons keep the prior teardown's preserved resume + // metadata on disk (bail). + // - Terminal reasons (handle_close, ended_session, model_switch) must + // still invalidate so a future resume can't reattach to a session + // the user actually closed. + if (!managed.runtime) { + if (!reasonAllowsPreservation) { + managed.runtimeInvalidated = true; + clearLaneDirectiveKey(managed); + } + return; + } const preserveClaudeResumeState = - managed.runtime.kind === "claude" - && ( - openCodeReason === "idle_ttl" - || openCodeReason === "budget_eviction" - || openCodeReason === "pool_compaction" - || openCodeReason === "paused_run" - || openCodeReason === "project_close" - || openCodeReason === "shutdown" - ); + managed.runtime.kind === "claude" && reasonAllowsPreservation; if (managed.runtime?.kind === "codex") { managed.runtime.suppressExitError = true; try { managed.runtime.reader.close(); } catch { /* ignore */ } diff --git a/apps/desktop/src/main/services/ipc/registerIpc.ts b/apps/desktop/src/main/services/ipc/registerIpc.ts index 228b1e483..c2225c8ec 100644 --- a/apps/desktop/src/main/services/ipc/registerIpc.ts +++ b/apps/desktop/src/main/services/ipc/registerIpc.ts @@ -177,12 +177,12 @@ import type { AgentChatDeleteArgs, AgentChatDisposeArgs, AgentChatGetSummaryArgs, + AgentChatEventEnvelope, AgentChatHandoffArgs, AgentChatHandoffResult, AgentChatInterruptArgs, AgentChatListArgs, AgentChatModelInfo, - AgentChatEventEnvelope, AgentChatModelsArgs, AgentChatPermissionMode, AgentChatRespondToInputArgs, @@ -4416,7 +4416,14 @@ export function registerIpc({ const ctx = getCtx(); const sessionId = typeof arg?.sessionId === "string" ? arg.sessionId.trim() : ""; if (!sessionId) return { sessionId: "", events: [], truncated: false }; - const maxEvents = typeof arg?.maxEvents === "number" ? arg.maxEvents : undefined; + // Only forward maxEvents when it is a finite positive number; the service + // layer applies its own clamp but guarding here avoids ambiguous NaN/0 + // inputs from untrusted renderer IPC. + const rawMaxEvents = typeof arg?.maxEvents === "number" ? arg.maxEvents : undefined; + const maxEvents = + rawMaxEvents != null && Number.isFinite(rawMaxEvents) && rawMaxEvents > 0 + ? rawMaxEvents + : undefined; return ctx.agentChatService.getChatEventHistory(sessionId, maxEvents != null ? { maxEvents } : undefined); }); diff --git a/apps/desktop/src/main/services/sync/deviceRegistryService.ts b/apps/desktop/src/main/services/sync/deviceRegistryService.ts index 19c8a3111..df3149c18 100644 --- a/apps/desktop/src/main/services/sync/deviceRegistryService.ts +++ b/apps/desktop/src/main/services/sync/deviceRegistryService.ts @@ -144,15 +144,23 @@ export function createDeviceRegistryService(args: DeviceRegistryServiceArgs) { fs.mkdirSync(path.dirname(deviceIdPath), { recursive: true }); const readOrCreateLocalDeviceId = (): string => { - const existing = fs.existsSync(deviceIdPath) ? fs.readFileSync(deviceIdPath, "utf8").trim() : ""; - if (existing.length > 0) return existing; + const shared = fs.existsSync(deviceIdPath) ? fs.readFileSync(deviceIdPath, "utf8").trim() : ""; const legacy = deviceIdPath !== legacyProjectDeviceIdPath && fs.existsSync(legacyProjectDeviceIdPath) ? fs.readFileSync(legacyProjectDeviceIdPath, "utf8").trim() : ""; + // If this project has a legacy per-project sync-device-id, always prefer + // it so existing iOS pairings and `sync_cluster_state.brain_device_id` + // references stay valid. The shared file is only written when it would + // agree (empty or already matching) — never overridden with a different + // value, so opening project B after A no longer flips B's identity to + // A's ID and drops B into viewer mode. if (legacy.length > 0) { - writeTextAtomic(deviceIdPath, `${legacy}\n`); + if (shared.length === 0 || shared === legacy) { + writeTextAtomic(deviceIdPath, `${legacy}\n`); + } return legacy; } + if (shared.length > 0) return shared; const created = randomUUID(); writeTextAtomic(deviceIdPath, `${created}\n`); return created; diff --git a/apps/desktop/src/main/services/sync/syncService.test.ts b/apps/desktop/src/main/services/sync/syncService.test.ts index 34cfc32f5..91237674d 100644 --- a/apps/desktop/src/main/services/sync/syncService.test.ts +++ b/apps/desktop/src/main/services/sync/syncService.test.ts @@ -542,6 +542,7 @@ describe.skipIf(!isCrsqliteAvailable())("syncService", () => { }, handlePtyData() {}, handlePtyExit() {}, + setDiscoveryEnabled: vi.fn(), dispose: attemptedPort === 8787 ? disposeFirstAttempt : disposeSecondAttempt, }; }) as any); diff --git a/apps/desktop/src/renderer/browserMock.ts b/apps/desktop/src/renderer/browserMock.ts index 6bce0d258..c93f3d586 100644 --- a/apps/desktop/src/renderer/browserMock.ts +++ b/apps/desktop/src/renderer/browserMock.ts @@ -2382,7 +2382,11 @@ if (typeof window !== "undefined" && !(window as any).ade) { onEvent: noop, slashCommands: resolvedArg([]), fileSearch: resolvedArg([]), - getEventHistory: resolvedArg({ sessionId: "mock", events: [], truncated: false }), + getEventHistory: async (arg: { sessionId: string; maxEvents?: number }) => ({ + sessionId: typeof arg?.sessionId === "string" ? arg.sessionId : "", + events: [], + truncated: false, + }), }, cto: { getState: resolvedArg({ diff --git a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx index c01935fe2..14683b24d 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx @@ -1460,7 +1460,14 @@ export function AgentChatPane({ } if (!usedSnapshotPath) { const summary = await window.ade.sessions.get(sessionId); - if (!summary || !isChatToolType(summary.toolType)) return; + if (!summary || !isChatToolType(summary.toolType)) { + // Clear the loaded flag so a subsequent remount/tab switch can retry. + // Without this, a transient lookup miss (e.g. session summary not yet + // propagated on project switch) would leave the UI permanently + // unable to hydrate history. Mirrors the catch-block recovery below. + loadedHistoryRef.current.delete(sessionId); + return; + } const raw = await window.ade.sessions.readTranscriptTail({ sessionId, maxBytes: CHAT_HISTORY_READ_MAX_BYTES, diff --git a/apps/desktop/src/renderer/components/terminals/SessionListPane.tsx b/apps/desktop/src/renderer/components/terminals/SessionListPane.tsx index 6a61ed07c..759fdf96a 100644 --- a/apps/desktop/src/renderer/components/terminals/SessionListPane.tsx +++ b/apps/desktop/src/renderer/components/terminals/SessionListPane.tsx @@ -160,14 +160,18 @@ export const SessionListPane = React.memo(function SessionListPane({ .filter(Number.isFinite); return times.length > 0 ? Math.max(...times) : -Infinity; }; + const orphanLabel = (name: string | null | undefined, fallback: string): string => { + const trimmed = (name ?? "").trim(); + return trimmed.length > 0 ? trimmed : fallback; + }; return [...sessionsGroupedByLane.entries()] .filter(([laneId, sessions]) => !knownLaneIds.has(laneId) && sessions.length > 0) .sort(([leftLaneId, leftSessions], [rightLaneId, rightSessions]) => { const leftLatest = latestStartedAt(leftSessions); const rightLatest = latestStartedAt(rightSessions); if (leftLatest !== rightLatest) return rightLatest - leftLatest; - const leftName = leftSessions[0]?.laneName ?? leftLaneId; - const rightName = rightSessions[0]?.laneName ?? rightLaneId; + const leftName = orphanLabel(leftSessions[0]?.laneName, leftLaneId); + const rightName = orphanLabel(rightSessions[0]?.laneName, rightLaneId); return leftName.localeCompare(rightName); }); }, [lanes, sessionsGroupedByLane]); @@ -267,7 +271,8 @@ export const SessionListPane = React.memo(function SessionListPane({ })} {missingLaneSessionGroups.map(([laneId, list]) => { const collapsed = workCollapsedLaneIds.includes(laneId); - const label = list[0]?.laneName ?? laneId; + const trimmedLaneName = (list[0]?.laneName ?? "").trim(); + const label = trimmedLaneName.length > 0 ? trimmedLaneName : laneId; return ( String { if let question = host.firstIndex(of: "?") { host = String(host[.. acc ? parsed : acc } } + func orphanLabel(_ name: String?, fallback: String) -> String { + let trimmed = name?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + return trimmed.isEmpty ? fallback : trimmed + } let orphanEntries = byLaneId .filter { laneId, list in !knownLaneIds.contains(laneId) && !list.isEmpty } .sorted { left, right in let leftLatest = latestStartedAt(left.value) let rightLatest = latestStartedAt(right.value) if leftLatest != rightLatest { return leftLatest > rightLatest } - let leftName = left.value.first?.laneName ?? left.key - let rightName = right.value.first?.laneName ?? right.key + let leftName = orphanLabel(left.value.first?.laneName, fallback: left.key) + let rightName = orphanLabel(right.value.first?.laneName, fallback: right.key) return leftName.localizedCaseInsensitiveCompare(rightName) == .orderedAscending } for (laneId, list) in orphanEntries { - let label = list.first?.laneName ?? laneId + let label = orphanLabel(list.first?.laneName, fallback: laneId) groups.append(WorkSessionGroup(id: "lane:\(laneId)", label: label, icon: .laneBranch, tint: ADEColor.textMuted, sessions: list)) } return groups diff --git a/apps/ios/ADETests/ADETests.swift b/apps/ios/ADETests/ADETests.swift index 1c27c5a57..1558fc3c8 100644 --- a/apps/ios/ADETests/ADETests.swift +++ b/apps/ios/ADETests/ADETests.swift @@ -3408,27 +3408,35 @@ final class ADETests: XCTestCase { id: "session-primary", laneId: "lane-primary", laneName: "Primary", - toolType: "codex-chat" + toolType: "codex-chat", + startedAt: "2026-03-25T12:00:00.000Z" ) // Two distinct soft-deleted lanes — each should render as its own group. + // `lane-deleted-a` wins the orphan sort because its latest session started + // more recently than the latest on `lane-deleted-b`. let orphanOldSession = makeTerminalSessionSummary( id: "session-orphan-old", laneId: "lane-deleted-a", laneName: "feature/cleanup", - toolType: "codex-chat" + toolType: "codex-chat", + startedAt: "2026-03-25T11:30:00.000Z" ) let orphanNewSession = makeTerminalSessionSummary( id: "session-orphan-new", laneId: "lane-deleted-b", laneName: "feature/recent", - toolType: "codex-chat" + toolType: "codex-chat", + startedAt: "2026-03-25T10:45:00.000Z" ) // Same orphan lane appearing twice — must merge into the same group. + // This sibling is older than `orphanOldSession` so the ordering assertion + // below exercises the latest-startedAt-per-lane comparison. let orphanNewSessionSibling = makeTerminalSessionSummary( id: "session-orphan-new-sibling", laneId: "lane-deleted-b", laneName: "feature/recent", - toolType: "codex-chat" + toolType: "codex-chat", + startedAt: "2026-03-25T10:30:00.000Z" ) let groups = workSessionGroupsByLane( @@ -5797,7 +5805,8 @@ final class ADETests: XCTestCase { runtimeState: String = "running", status: String = "running", title: String = "Codex chat", - lastOutputPreview: String? = nil + lastOutputPreview: String? = nil, + startedAt: String = "2026-03-25T00:00:00.000Z" ) -> TerminalSessionSummary { TerminalSessionSummary( id: id, @@ -5811,7 +5820,7 @@ final class ADETests: XCTestCase { toolType: toolType, title: title, status: status, - startedAt: "2026-03-25T00:00:00.000Z", + startedAt: startedAt, endedAt: nil, exitCode: nil, transcriptPath: "", From ca6a3020e577c496ce6bc7f772e63242c44bb444 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Wed, 22 Apr 2026 21:11:10 -0400 Subject: [PATCH 6/6] Harden mergeEnvelopeStreams dedup against same-millisecond Claude fragments Claude V2 emits multiple text/reasoning deltas inside tight streaming loops, so two legitimate envelopes can legitimately share the same millisecond timestamp and event.type. The previous timestamp+type dedup key would collapse those distinct fragments into one on the first hydration, silently dropping the later fragment from the resumed history. Include a payload signature (JSON.stringify of the event) in the dedup key so true duplicates still collapse while distinct fragments at the same timestamp survive. Sequence stays out of the key since it restarts per run. Added regression test with two text envelopes at a shared timestamp. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../services/chat/agentChatService.test.ts | 37 +++++++++++++++++++ .../main/services/chat/agentChatService.ts | 21 ++++++----- 2 files changed, 49 insertions(+), 9 deletions(-) diff --git a/apps/desktop/src/main/services/chat/agentChatService.test.ts b/apps/desktop/src/main/services/chat/agentChatService.test.ts index 6342b737c..58c153a1f 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.test.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.test.ts @@ -4535,6 +4535,43 @@ describe("createAgentChatService", () => { )).toEqual(["persisted-1", "persisted-2"]); }); + it("keeps Claude streaming fragments that share a timestamp when hydrating", async () => { + // Claude V2 emits multiple text deltas inside tight streaming loops, + // so two legitimate envelopes with type:"text" can land on the same + // millisecond. A naive timestamp+type dedup key would collapse these; + // the cross-run-safe dedup must keep distinct payloads separate. + const { service } = createService(); + const session = await service.createSession({ + laneId: "lane-1", + provider: "claude", + model: "sonnet", + }); + + const sharedTimestamp = new Date().toISOString(); + const envelope1: AgentChatEventEnvelope = { + sessionId: session.id, + timestamp: sharedTimestamp, + event: { type: "text", text: "fragment-a" }, + sequence: 1, + }; + const envelope2: AgentChatEventEnvelope = { + sessionId: session.id, + timestamp: sharedTimestamp, + event: { type: "text", text: "fragment-b" }, + sequence: 2, + }; + const transcriptFile = path.join(tmpRoot, "transcripts", `${session.id}.chat.jsonl`); + fs.writeFileSync(transcriptFile, `${JSON.stringify(envelope1)}\n${JSON.stringify(envelope2)}\n`, "utf8"); + vi.mocked(parseAgentChatTranscript).mockReturnValue([envelope1, envelope2]); + + const history = service.getChatEventHistory(session.id); + expect(history.events).toHaveLength(2); + expect(history.events.map((e) => e.event.type === "text" ? e.event.text : "")).toEqual([ + "fragment-a", + "fragment-b", + ]); + }); + it("drops history when the underlying session is deleted", async () => { // We don't rely on sendMessage emitting events (mock streams vary across // providers), so we seed the transcript directly to verify the cleanup diff --git a/apps/desktop/src/main/services/chat/agentChatService.ts b/apps/desktop/src/main/services/chat/agentChatService.ts index 41f96bd5d..4015d6f4f 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.ts @@ -3718,23 +3718,26 @@ export function createAgentChatService(args: { return []; }; + const envelopeDedupKey = (entry: AgentChatEventEnvelope): string => { + // Cross-run-safe key: two envelopes are true duplicates iff timestamp, + // type, AND payload all match. Sequence numbers can't be trusted (they + // restart per run), and Claude streaming emits multiple text/reasoning + // fragments within the same millisecond + type — timestamp+type alone + // would wrongly collapse those into one. JSON.stringify is fine at our + // scale (≤2000 events, events typically <1KB). + return `${entry.timestamp}#${entry.event.type}#${JSON.stringify(entry.event)}`; + }; + const mergeEnvelopeStreams = ( base: AgentChatEventEnvelope[], tail: AgentChatEventEnvelope[], ): AgentChatEventEnvelope[] => { if (!base.length) return tail.slice(); if (!tail.length) return base.slice(); - // Don't dedup by sequence: `eventSequence` restarts at 0 every time a - // managed session is rebuilt (process restart, project switch), so the - // same ordinals legitimately appear in the persisted transcript (from - // a prior run) and in the in-memory buffer (from the current run) for - // totally different events. Use timestamp+type — writeTranscript and - // recordChatEventInHistory emit from the same envelope so true - // duplicates match exactly. - const baseKeys = new Set(base.map((entry) => `${entry.timestamp}#${entry.event.type}`)); + const baseKeys = new Set(base.map(envelopeDedupKey)); const merged = base.slice(); for (const entry of tail) { - if (baseKeys.has(`${entry.timestamp}#${entry.event.type}`)) continue; + if (baseKeys.has(envelopeDedupKey(entry))) continue; merged.push(entry); } merged.sort((left, right) => {