Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 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
113 changes: 113 additions & 0 deletions apps/ade-cli/src/tuiClient/__tests__/olderHistory.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type { AgentChatEventEnvelope } from "../../../../desktop/src/shared/type
import {
advanceOlderHistoryCursor,
mergeDetachedTuiHistoryTail,
mergeHydratedTuiHistory,
prependOlderTuiHistory,
resolveSnapshotHistoryCursor,
shouldRequestOlderTuiHistory,
Expand Down Expand Up @@ -211,6 +212,118 @@ 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],
);

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],
[],
);

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

it("preserves delayed pending output that sorts inside the refreshed snapshot", () => {
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 merged = mergeHydratedTuiHistory(
[{ ...prompt }, { ...done }],
[prompt, done, staleReplay],
[delayedPending],
);

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);

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
7 changes: 2 additions & 5 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, 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 @@ -3693,10 +3693,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);
}, []);

const commitActiveSessionEvents = useCallback((
Expand Down
38 changes: 36 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,42 @@ 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)),
);
}

/**
* 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[],
): AgentChatEventEnvelope[] {
const arrivalWatermark = captureAgentChatHistoryArrivalWatermark(
existing,
tuiEventDedupKey,
);
Comment thread
arul28 marked this conversation as resolved.
Outdated
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
1 change: 1 addition & 0 deletions apps/desktop/src/preload/global.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1713,6 +1713,7 @@ declare global {
onEvent: (
cb: (ev: AgentChatEventEnvelope) => void,
pin?: OpenProjectBinding | null,
options?: { forcePinned?: boolean },
) => () => void;
slashCommands: (
args: AgentChatSlashCommandsArgs,
Expand Down
61 changes: 59 additions & 2 deletions apps/desktop/src/preload/preload.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6928,6 +6928,7 @@ describe("per-chat runtime routing", () => {

it("streams a pinned This Mac chat while the window is remote-bound", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-07-27T18:02:00.500Z"));
try {
const { bridge, invoke, on } = await mountBridge(machineB);
let streamAttempt = 0;
Expand Down Expand Up @@ -7032,6 +7033,62 @@ describe("per-chat runtime routing", () => {
}
});

it("force-pins a retained chat even when its binding is currently active", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-07-27T18:02:00.500Z"));
try {
const { bridge, invoke, on } = await mountBridge(machineA);
const envelope = {
sessionId: "retained-chat-on-a",
timestamp: "2026-07-27T18:02:00.500Z",
event: { type: "text", text: "still from captured project" },
};
invoke.mockImplementation(async (channel: string, arg?: unknown) => {
if (channel === IPC.appGetWindowSession) {
return {
windowId: 1,
project: { rootPath: machineA.rootPath, displayName: machineA.displayName },
binding: machineA,
};
}
if (channel === IPC.localRuntimeStreamEvents) {
return {
events: [{
id: 1,
timestamp: envelope.timestamp,
category: "runtime",
payload: envelope,
}],
nextCursor: 1,
hasMore: false,
eventEpoch: "local-epoch-a",
};
}
throw new Error(`unexpected IPC: ${channel} ${JSON.stringify(arg)}`);
});

await bridge.app.getWindowSession();
const callback = vi.fn();
const unsubscribe = bridge.agentChat.onEvent(
callback,
machineA,
{ forcePinned: true },
);
await vi.advanceTimersByTimeAsync(0);

expect(invoke).toHaveBeenCalledWith(IPC.localRuntimeStreamEvents, {
rootPath: "/repo-a",
request: { cursor: 0, limit: 200 },
});
expect(callback).toHaveBeenCalledWith(envelope);
expect(on.mock.calls.some(([channel]) => channel === IPC.agentChatEvent)).toBe(false);

unsubscribe();
} finally {
vi.useRealTimers();
}
});

it("backs off repeated pinned chat poll failures and resets after success", async () => {
vi.useFakeTimers();
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
Expand Down Expand Up @@ -7254,7 +7311,7 @@ describe("per-chat runtime routing", () => {
{
id: "target-b",
projectId: "project-b",
request: { cursor: 0, limit: 200 },
request: { cursor: 0, limit: 200, replay: false },
},
{
id: "target-b",
Expand All @@ -7264,7 +7321,7 @@ describe("per-chat runtime routing", () => {
{
id: "target-b",
projectId: "project-b",
request: { cursor: 0, limit: 200 },
request: { cursor: 0, limit: 200, replay: false },
},
]);
expect(callback).toHaveBeenCalledWith(oldEnvelope);
Expand Down
33 changes: 29 additions & 4 deletions apps/desktop/src/preload/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2959,16 +2959,28 @@ function subscribeRemoteAppControlEvents(
function subscribeAgentChatEvents(
cb: (payload: AgentChatEventEnvelope) => void,
pin?: OpenProjectBinding | null,
options?: { forcePinned?: boolean },
): () => void {
const removeLocal = agentChatEventFanout(cb);
if (pin && pin.key !== currentProjectBinding?.key) {
const forcePinned = Boolean(pin && options?.forcePinned === true);
const removeLocal = forcePinned ? () => undefined : agentChatEventFanout(cb);
if (pin && (forcePinned || pin.key !== currentProjectBinding?.key)) {
let cancelled = false;
let timer: ReturnType<typeof setTimeout> | null = null;
let cursor = 0;
let eventEpoch: string | null = null;
let replaySuppressed = pin.kind === "remote";
const startedAtMs = pin.kind === "local" ? Date.now() : 0;
let consecutiveFailures = 0;
const seenLocalEventIds = new Set<number>();
const dispatchPinnedLocalEvent = (event: RemoteRuntimeBufferedEvent): void => {
const eventTime = Date.parse(event.timestamp);
if (
startedAtMs > 0
&& Number.isFinite(eventTime)
&& eventTime < startedAtMs - 1_000
) {
return;
}
if (seenLocalEventIds.has(event.id)) return;
seenLocalEventIds.add(event.id);
while (seenLocalEventIds.size > 1_000) {
Expand All @@ -2993,7 +3005,11 @@ function subscribeAgentChatEvents(
const poll = async (): Promise<void> => {
let delay = REMOTE_RUNTIME_EVENT_IDLE_POLL_MS;
try {
const request = { cursor, limit: 200 };
const request = {
cursor,
limit: 200,
...(replaySuppressed && cursor === 0 ? { replay: false } : {}),
} satisfies RemoteRuntimeStreamEventsRequest;
const batch = await ipcRenderer.invoke(
pin.kind === "remote"
? IPC.remoteRuntimeStreamEvents
Expand All @@ -3016,12 +3032,14 @@ function subscribeAgentChatEvents(
eventEpoch = batchEpoch;
if (epochChanged) {
cursor = 0;
replaySuppressed = pin.kind === "remote";
delay = 0;
resetForEpochChange = true;
}
}
if (!resetForEpochChange) {
cursor = Number.isFinite(batch.nextCursor) ? Math.max(0, Math.floor(batch.nextCursor)) : cursor;
if (request.replay === false) replaySuppressed = false;
for (const event of batch.events ?? []) {
if (pin.kind === "local") {
dispatchPinnedLocalEvent(event);
Expand Down Expand Up @@ -3162,12 +3180,17 @@ function subscribePinnedProjectRuntimeEvents<T>(
let timer: ReturnType<typeof setTimeout> | null = null;
let cursor = 0;
let eventEpoch: string | null = null;
let replaySuppressed = true;
let consecutiveFailures = 0;

const poll = async (): Promise<void> => {
let delay = REMOTE_RUNTIME_EVENT_IDLE_POLL_MS;
try {
const request = { cursor, limit: 200 };
const request = {
cursor,
limit: 200,
...(replaySuppressed && cursor === 0 ? { replay: false } : {}),
} satisfies RemoteRuntimeStreamEventsRequest;
const batch = await ipcRenderer.invoke(
pin.kind === "remote"
? IPC.remoteRuntimeStreamEvents
Expand All @@ -3190,6 +3213,7 @@ function subscribePinnedProjectRuntimeEvents<T>(
eventEpoch = batchEpoch;
if (epochChanged) {
cursor = 0;
replaySuppressed = true;
delay = 0;
resetForEpochChange = true;
Comment on lines 3214 to 3218

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Replay the new epoch before advancing its cursor

When a pinned runtime reconnects with a different eventEpoch, this branch discards the returned batch via resetForEpochChange and makes the next cursor-zero request with replay: false. The runtime subscription implementation in multiProjectRpcServer.ts:821-830 responds to that flag with no events and advances directly to latestCursor(), so every computer-use, lane-delete, or other pinned event already buffered in the new epoch is permanently skipped. Keep replay enabled for an epoch reset and dedupe/process the new-epoch backlog instead.

Useful? React with 👍 / 👎.

}
Expand All @@ -3198,6 +3222,7 @@ function subscribePinnedProjectRuntimeEvents<T>(
cursor = Number.isFinite(batch.nextCursor)
? Math.max(0, Math.floor(batch.nextCursor))
: cursor;
if (request.replay === false) replaySuppressed = false;
for (const event of batch.events ?? []) {
const payload = decode(event.payload);
if (!payload) continue;
Expand Down
Loading