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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 64 additions & 1 deletion apps/ade-cli/src/tuiClient/__tests__/appPolling.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -134,15 +134,32 @@ function chat(overrides: Partial<AgentChatSessionSummary> = {}): AgentChatSessio
};
}

function event(sessionId: string, sequence: number, type: string): AgentChatEventEnvelope {
function event(
sessionId: string,
sequence: number,
type: string,
overrides: Partial<AgentChatEventEnvelope> = {},
): AgentChatEventEnvelope {
return {
sessionId,
sequence,
timestamp: `2026-01-01T00:00:0${sequence}.000Z`,
event: { type } as AgentChatEventEnvelope["event"],
...overrides,
};
}

function deferred<T>(): {
promise: Promise<T>;
resolve: (value: T) => void;
} {
let resolve!: (value: T) => void;
const promise = new Promise<T>((resolvePromise) => {
resolve = resolvePromise;
});
return { promise, resolve };
}

async function flushAsyncEffects() {
await act(async () => {
for (let i = 0; i < 12; i++) {
Expand Down Expand Up @@ -314,6 +331,52 @@ describe("AdeCodeApp polling", () => {
await unmountApp(instance);
});

it("keeps live output flushed while selected-chat history is in flight", async () => {
mocks.listLanes.mockResolvedValue([lane({ worktreeAvailable: true })]);
const history = deferred<{
sessionId: string;
events: AgentChatEventEnvelope[];
truncated: boolean;
}>();
mocks.getChatHistory.mockReturnValue(history.promise);
const instance = await renderApp(
<AdeCodeApp project={{ ...project, sessionHint: "chat-1" }} />,
);

expect(mocks.getChatHistory).toHaveBeenCalledWith(connection, "chat-1");

const delayedLive = event("chat-1", 2, "text", {
event: { type: "text", text: "delayed output survives hydration" },
});
await act(async () => {
[...chatListeners][0]?.(delayedLive);
await vi.advanceTimersByTimeAsync(40);
});
await flushAsyncEffects();

history.resolve({
sessionId: "chat-1",
events: [
event("chat-1", 1, "user_message", {
event: {
type: "user_message",
text: "start",
turnId: "turn-1",
messageId: "message-1",
},
}),
event("chat-1", 3, "done", {
event: { type: "done", turnId: "turn-1", status: "completed" },
}),
],
truncated: false,
});
await flushAsyncEffects();

expect(stripAnsi(instance.lastFrame() ?? "")).toContain("delayed output survives hydration");
await unmountApp(instance);
});

it("starts remote project launches from remote context instead of local saved lane state", async () => {
mocks.listLanes.mockResolvedValue([
lane({
Expand Down
128 changes: 128 additions & 0 deletions apps/ade-cli/src/tuiClient/__tests__/olderHistory.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@ import { describe, expect, it } from "vitest";
import type { AgentChatEventEnvelope } from "../../../../desktop/src/shared/types/chat";
import {
advanceOlderHistoryCursor,
captureTuiHistoryArrivalWatermark,
mergeDetachedTuiHistoryTail,
mergeHydratedTuiHistory,
prependOlderTuiHistory,
resolveSnapshotHistoryCursor,
shouldRequestOlderTuiHistory,
Expand Down Expand Up @@ -211,6 +213,132 @@ describe("mergeDetachedTuiHistoryTail", () => {
expect(merged.filter((entry) => entry.sequence === 100)).toHaveLength(1);
expect(merged.at(-1)?.sequence).toBe(101);
});

it("places delayed live output before a later terminal event", () => {
const merged = mergeDetachedTuiHistoryTail(
[envelope(10, { timestamp: "2026-01-01T12:10:00.000Z" })],
[
envelope(12, {
timestamp: "2026-01-01T12:12:00.000Z",
event: { type: "done", turnId: "turn-1", status: "completed" },
}),
envelope(11, { timestamp: "2026-01-01T12:11:00.000Z" }),
],
);

expect(merged.map((entry) => entry.sequence)).toEqual([10, 11, 12]);
});
});

describe("mergeHydratedTuiHistory", () => {
it("keeps scrollback ordered and drops a replayed old turn after the latest tail", () => {
const older = envelope(10, { timestamp: "2026-01-01T12:00:00.000Z" });
const latest = envelope(20, { timestamp: "2026-01-01T12:20:00.000Z" });
const replayedOldTurn = envelope(11, { timestamp: "2026-01-01T12:05:00.000Z" });
const genuinelyLive = envelope(21, { timestamp: "2026-01-01T12:21:00.000Z" });

const merged = mergeHydratedTuiHistory(
[{ ...latest }],
[older, latest, replayedOldTurn],
[genuinelyLive],
captureTuiHistoryArrivalWatermark([older, latest, replayedOldTurn]),
);

expect(merged.map((entry) => entry.sequence)).toEqual([10, 20, 21]);
expect(merged[0]).toBe(older);
expect(merged[1]).toBe(latest);
expect(merged[2]).toBe(genuinelyLive);
});

it("uses the TUI semantic identity when a replay changes transport metadata", () => {
const older = envelope(9, {
timestamp: "2026-01-01T12:09:00.000Z",
event: { type: "text", text: "older scrollback" },
});
const originalPrompt = envelope(10, {
timestamp: "2026-01-01T12:10:00.000Z",
event: { type: "user_message", text: "ship it", turnId: "turn-1", messageId: "message-1" },
});
const replayedPrompt = envelope(99, {
timestamp: "2026-01-01T12:10:05.000Z",
event: { type: "user_message", text: "ship it", turnId: "turn-1", messageId: "message-1" },
});

const merged = mergeHydratedTuiHistory(
[replayedPrompt],
[older, originalPrompt],
[],
captureTuiHistoryArrivalWatermark([older, originalPrompt]),
);

expect(merged).toEqual([older, originalPrompt]);
});

it("preserves delayed live output flushed while hydration is in flight", () => {
const prompt = envelope(10, {
timestamp: "2026-01-01T12:10:00.000Z",
event: { type: "user_message", text: "ship it", turnId: "turn-1", messageId: "message-1" },
});
const done = envelope(12, {
timestamp: "2026-01-01T12:12:00.000Z",
event: { type: "done", turnId: "turn-1", status: "completed" },
});
const staleReplay = envelope(9, {
timestamp: "2026-01-01T12:09:00.000Z",
event: { type: "text", text: "stale replay" },
});
const delayedPending = envelope(11, {
timestamp: "2026-01-01T12:11:00.000Z",
event: { type: "text", text: "delayed pending output" },
});

const existingAtRequestStart = [prompt, done, staleReplay];
const arrivalWatermark = captureTuiHistoryArrivalWatermark(existingAtRequestStart);
const pending = [delayedPending];
const existingAtMerge = [...existingAtRequestStart, ...pending.splice(0)];

expect(pending).toEqual([]);
const merged = mergeHydratedTuiHistory(
[{ ...prompt }, { ...done }],
existingAtMerge,
pending,
arrivalWatermark,
);

expect(merged).toEqual([prompt, delayedPending, done]);
expect(merged[1]).toBe(delayedPending);
});

it("caps a large hydrated merge while preserving the chronological newest tail", () => {
const existingCount = TUI_LOADED_EVENT_CAP + 25;
const existing = Array.from({ length: existingCount }, (_, index) => {
const sequence = index + 1;
return envelope(sequence, {
timestamp: new Date(Date.UTC(2026, 0, 1) + sequence).toISOString(),
});
});
const snapshotTail = existing.slice(-TUI_SNAPSHOT_DISPLAY_CAP);
const pending = Array.from({ length: 10 }, (_, index) => {
const sequence = existingCount + index + 1;
return envelope(sequence, {
timestamp: new Date(Date.UTC(2026, 0, 1) + sequence).toISOString(),
});
});

const merged = mergeHydratedTuiHistory(
snapshotTail,
existing,
pending,
captureTuiHistoryArrivalWatermark(existing),
);

expect(merged).toHaveLength(TUI_LOADED_EVENT_CAP);
expect(merged[0]?.sequence).toBe(existingCount + pending.length - TUI_LOADED_EVENT_CAP + 1);
expect(merged.at(-1)?.sequence).toBe(existingCount + pending.length);
expect(merged.every((entry, index) => (
index === 0 || entry.sequence === (merged[index - 1]?.sequence ?? 0) + 1
))).toBe(true);
});
});

describe("splitSnapshotForDisplay", () => {
Expand Down
38 changes: 28 additions & 10 deletions apps/ade-cli/src/tuiClient/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -271,7 +271,7 @@ import { latestExpandableFailureId, renderObject, summarizeDiffChanges } from ".
import { startTuiHeartbeat, type TuiHeartbeat } from "./heartbeat";
import { clipboardScratchDir, isImageFilePath, latestOpenableImageTarget, readClipboardImageAttachment, readImageDimensions } from "./imageTargets";
import { appendReservedTuiEvent, dedupeTuiEvents, reserveTuiEventDedupKey, syncTuiEventDedupKeys } from "./eventDedup";
import { advanceOlderHistoryCursor, mergeDetachedTuiHistoryTail, prependOlderTuiHistory, resolveSnapshotHistoryCursor, shouldRequestOlderTuiHistory, splitSnapshotForDisplay, takeNewestChunk, TUI_LOADED_EVENT_CAP, TUI_SNAPSHOT_DISPLAY_CAP, type OlderHistoryStatus } from "./olderHistory";
import { advanceOlderHistoryCursor, captureTuiHistoryArrivalWatermark, mergeDetachedTuiHistoryTail, mergeHydratedTuiHistory, prependOlderTuiHistory, resolveSnapshotHistoryCursor, shouldRequestOlderTuiHistory, splitSnapshotForDisplay, takeNewestChunk, TUI_LOADED_EVENT_CAP, TUI_SNAPSHOT_DISPLAY_CAP, type OlderHistoryStatus } from "./olderHistory";
import { coalesceTextDeltaEnvelopes } from "./assistantTextIdentity";
import {
EMPTY_BRACKETED_PASTE_STATE,
Expand Down Expand Up @@ -3680,7 +3680,18 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath,
});
}, []);

const mergeHydratedEventsWithLive = useCallback((sessionId: string, displayEvents: AgentChatEventEnvelope[]) => {
const captureHydratedEventsWatermark = useCallback((sessionId: string) => (
captureTuiHistoryArrivalWatermark([
...(eventsBySessionIdRef.current[sessionId] ?? []),
...pendingChatEnvelopesRef.current.filter((envelope) => envelope.sessionId === sessionId),
])
), []);

const mergeHydratedEventsWithLive = useCallback((
sessionId: string,
displayEvents: AgentChatEventEnvelope[],
arrivalWatermark: ReadonlySet<string>,
) => {
const existing = eventsBySessionIdRef.current[sessionId] ?? [];
const pending = pendingChatEnvelopesRef.current.filter((envelope) => envelope.sessionId === sessionId);
if (detachedHistorySessionIdsRef.current.has(sessionId)) {
Expand All @@ -3693,10 +3704,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath,
);
}
if (existing.length === 0 && pending.length === 0) return displayEvents;
return dedupeTuiEvents(
[...displayEvents, ...existing, ...pending],
Math.max(TUI_LOADED_EVENT_CAP, displayEvents.length, existing.length + pending.length),
);
return mergeHydratedTuiHistory(displayEvents, existing, pending, arrivalWatermark);
}, []);

const commitActiveSessionEvents = useCallback((
Expand Down Expand Up @@ -4760,6 +4768,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath,

const generation = drawerPreviewGenerationRef.current + 1;
drawerPreviewGenerationRef.current = generation;
const historyArrivalWatermark = captureHydratedEventsWatermark(sessionId);
void (async () => {
try {
const history = await getChatHistory(conn, sessionId);
Expand Down Expand Up @@ -4801,7 +4810,11 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath,
// displayed-oldest ← buffer ← tailStartOffset seams contiguous.
const dedupedHistory = dedupeTuiEvents(visibleHistory, Math.max(1, visibleHistory.length));
const { display, buffer: olderBuffer } = splitSnapshotForDisplay(dedupedHistory);
const historyEvents = mergeHydratedEventsWithLive(sessionId, display);
const historyEvents = mergeHydratedEventsWithLive(
sessionId,
display,
historyArrivalWatermark,
);
loadedSessionIdRef.current = sessionId;
commitActiveSessionEvents(sessionId, historyEvents, history.events.length);
// A locally cleared transcript view must not page older history back in.
Expand Down Expand Up @@ -4835,7 +4848,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath,
));
}
})();
}, [clearOlderHistoryCursor, commitActiveSessionEvents, mergeHydratedEventsWithLive, seedOlderHistoryCursor, selectActiveLaneId, selectActiveSessionId, setDraftChatMode, setGridView, setSessionInterrupted, setSessionStreaming, setStreaming]);
}, [captureHydratedEventsWatermark, clearOlderHistoryCursor, commitActiveSessionEvents, mergeHydratedEventsWithLive, seedOlderHistoryCursor, selectActiveLaneId, selectActiveSessionId, setDraftChatMode, setGridView, setSessionInterrupted, setSessionStreaming, setStreaming]);
const toggleDrawerClosedCliGroup = useCallback((laneId: string | null) => {
if (!laneId) return;
setDrawerClosedCliExpandedLaneIds((prev) => {
Expand Down Expand Up @@ -7401,6 +7414,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath,
nextSessionId,
});
if (shouldHydrateHistory) {
const historyArrivalWatermark = captureHydratedEventsWatermark(nextSessionId);
const history = await getChatHistory(conn, nextSessionId);
if (!isCurrentRefresh()) return;
if (history.unavailable === true) {
Expand Down Expand Up @@ -7430,7 +7444,11 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath,
// displayed-oldest ← buffer ← tailStartOffset seams contiguous.
const dedupedHistory = dedupeTuiEvents(visibleHistory, Math.max(1, visibleHistory.length));
const { display, buffer: olderBuffer } = splitSnapshotForDisplay(dedupedHistory);
nextEvents = mergeHydratedEventsWithLive(nextSessionId, display);
nextEvents = mergeHydratedEventsWithLive(
nextSessionId,
display,
historyArrivalWatermark,
);
const activeModelId = nextSession?.modelId ?? null;
const fallbackContext = activeModelId ? getModelById(activeModelId)?.contextWindow ?? null : null;
const stats = latestTokenStats(history.events, fallbackContext);
Expand Down Expand Up @@ -7587,7 +7605,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath,
}
if (draftMode) draftSeededFromHistoryRef.current = true;
}
}, [clearedAt, clearOlderHistoryCursor, commitActiveSessionEvents, drawerLaneId, loadProviderModels, mergeHydratedEventsWithLive, modelState.provider, project, seedOlderHistoryCursor, selectActiveLaneId, selectActiveSessionId, selectedDrawerChatAction, setDraftChatMode, setSessionInterrupted, setSessionStreaming, setStreaming]);
}, [captureHydratedEventsWatermark, clearedAt, clearOlderHistoryCursor, commitActiveSessionEvents, drawerLaneId, loadProviderModels, mergeHydratedEventsWithLive, modelState.provider, project, seedOlderHistoryCursor, selectActiveLaneId, selectActiveSessionId, selectedDrawerChatAction, setDraftChatMode, setSessionInterrupted, setSessionStreaming, setStreaming]);

const renameLane = useCallback(async (laneIdArg: string | null, name: string) => {
const conn = connectionRef.current;
Expand Down
47 changes: 45 additions & 2 deletions apps/ade-cli/src/tuiClient/olderHistory.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
import type { AgentChatEventEnvelope } from "../../../desktop/src/shared/types/chat";
import {
captureAgentChatHistoryArrivalWatermark,
mergeAgentChatHistorySnapshot,
orderAgentChatEventsChronologically,
} from "../../../desktop/src/shared/chatHistoryMerge";
import { dedupeTuiEvents, tuiEventDedupKey } from "./eventDedup";

/**
Expand Down Expand Up @@ -139,13 +144,51 @@ export function mergeDetachedTuiHistoryTail(
snapshotTail: readonly AgentChatEventEnvelope[],
bufferedLiveEvents: readonly AgentChatEventEnvelope[],
): AgentChatEventEnvelope[] {
const combined = [...snapshotTail, ...bufferedLiveEvents];
const combined = mergeAgentChatHistorySnapshot(
[...snapshotTail],
[...snapshotTail, ...bufferedLiveEvents],
{ identityKey: tuiEventDedupKey },
);
return dedupeTuiEvents(
combined,
orderAgentChatEventsChronologically(combined),
Math.min(TUI_LOADED_EVENT_CAP, Math.max(1, combined.length)),
);
}

/**
* Freeze the TUI-semantic identities already resident when an asynchronous
* history request starts. Include both rendered and pending envelopes at the
* call site so a pending pre-request replay cannot masquerade as in-flight
* output if it flushes before the snapshot returns.
*/
export function captureTuiHistoryArrivalWatermark(
events: readonly AgentChatEventEnvelope[],
): ReadonlySet<string> {
return captureAgentChatHistoryArrivalWatermark(events, tuiEventDedupKey);
}

/**
* Reconcile a refreshed authoritative tail with cached scrollback and events
* received while hydration was in flight. Older cached rows stay before the
* tail; replayed historical rows are never appended after it.
*/
export function mergeHydratedTuiHistory(
snapshotTail: readonly AgentChatEventEnvelope[],
existing: readonly AgentChatEventEnvelope[],
pending: readonly AgentChatEventEnvelope[],
arrivalWatermark: ReadonlySet<string>,
): AgentChatEventEnvelope[] {
const merged = mergeAgentChatHistorySnapshot(
[...snapshotTail],
[...existing, ...pending],
{ arrivalWatermark, identityKey: tuiEventDedupKey },
);
return dedupeTuiEvents(
orderAgentChatEventsChronologically(merged),
Math.min(TUI_LOADED_EVENT_CAP, Math.max(1, merged.length)),
);
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

export function shouldRequestOlderTuiHistory(args: {
scrollMaxOffset: number;
scrollOffset: number;
Expand Down
Loading