Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 15 additions & 1 deletion apps/desktop/src/main/services/chat/agentChatService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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();
}
Expand Down
25 changes: 22 additions & 3 deletions apps/desktop/src/main/services/chat/agentChatService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Comment thread
arul28 marked this conversation as resolved.
Resolved
&& (
openCodeReason === "idle_ttl"
|| openCodeReason === "budget_eviction"
|| openCodeReason === "pool_compaction"
|| openCodeReason === "paused_run"
|| openCodeReason === "project_close"
|| openCodeReason === "shutdown"
Comment thread
arul28 marked this conversation as resolved.
Resolved
);

flushBufferedReasoning(managed);
flushBufferedText(managed);
if (managed.runtime?.kind === "codex") {
Expand All @@ -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;
Expand Down Expand Up @@ -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 = (
Expand Down Expand Up @@ -13024,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" });
Expand All @@ -13033,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
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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: () => <span data-testid="tool-logo" />,
}));

function makeLane(overrides: Partial<LaneSummary> = {}): 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> = {}): 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<ComponentProps<typeof SessionListPane>> = {}) {
const session = makeSession();
return render(
<MemoryRouter>
<SessionListPane
lanes={[makeLane()]}
runningFiltered={[session]}
awaitingInputFiltered={[]}
endedFiltered={[]}
loading={false}
filterLaneId="all"
setFilterLaneId={vi.fn()}
filterStatus="all"
setFilterStatus={vi.fn()}
q=""
setQ={vi.fn()}
selectedSessionId={null}
draftKind="chat"
showingDraft={false}
onShowDraftKind={vi.fn()}
onSelectSession={vi.fn()}
onResume={vi.fn()}
resumingSessionId={null}
onInfoClick={vi.fn()}
onContextMenu={vi.fn()}
sessionListOrganization="by-lane"
setSessionListOrganization={vi.fn()}
workCollapsedLaneIds={[]}
toggleWorkLaneCollapsed={vi.fn()}
workCollapsedSectionIds={[]}
toggleWorkSectionCollapsed={vi.fn()}
sessionsGroupedByLane={new Map([[session.laneId, [session]]])}
{...props}
/>
</MemoryRouter>,
);
}

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();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,26 @@ 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 => {
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]) => {
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
Expand Down Expand Up @@ -245,6 +265,23 @@ export const SessionListPane = React.memo(function SessionListPane({
</StickyGroupHeader>
);
})}
{missingLaneSessionGroups.map(([laneId, list]) => {
const collapsed = workCollapsedLaneIds.includes(laneId);
const label = list[0]?.laneName ?? laneId;
return (
<StickyGroupHeader
key={laneId}
sectionId={laneId}
icon={<GitBranch size={11} weight="regular" className="shrink-0 text-muted-fg/55" />}
label={label}
count={list.length}
collapsed={collapsed}
onToggleCollapsed={() => toggleWorkLaneCollapsed(laneId)}
>
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
{renderCards(list)}
</StickyGroupHeader>
);
})}
</div>
);

Expand Down
49 changes: 40 additions & 9 deletions apps/ios/ADE/Services/SyncService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -4474,7 +4480,10 @@ final class SyncService: ObservableObject {
}

private func chatSubscriptionPayload(sessionId: String) -> [String: Any] {
["sessionId": sessionId]
[
"sessionId": sessionId,
"maxBytes": syncChatSubscriptionMaxBytes,
]
}

private func restoreChatEventSubscriptions() {
Expand All @@ -4488,25 +4497,47 @@ 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)
Comment thread
arul28 marked this conversation as resolved.
Resolved
chatEventEnvelopesBySession[envelope.sessionId] = events
chatEventRevisionsBySession[envelope.sessionId, default: 0] += 1
lastSyncAt = Date()
markChatEventsChanged()
}

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<String>()
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
Comment thread
arul28 marked this conversation as resolved.
Resolved
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 {
Expand Down
13 changes: 12 additions & 1 deletion apps/ios/ADE/Views/Work/WorkErrorAndMessageHelpers.swift
Original file line number Diff line number Diff line change
Expand Up @@ -26,10 +26,17 @@ 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 {
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
Expand Down Expand Up @@ -63,10 +70,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(
Expand All @@ -80,7 +89,9 @@ func buildWorkChatMessages(from transcript: [WorkChatEnvelope]) -> [WorkChatMess
turnModelId: metadata?.modelId
))
}
previousEnvelopeWasAssistantText = true
default:
previousEnvelopeWasAssistantText = false
continue
}
}
Expand Down
Loading
Loading