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
37 changes: 35 additions & 2 deletions packages/cli/src/serve/multi-workspace-sessions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3043,7 +3043,7 @@ describe('multi-workspace session dispatch', () => {
sessionId,
cwd: SECONDARY_CWD,
timestamp: '2026-07-08T00:00:00.000Z',
prompt: 'x'.repeat(4 * 1024 * 1024),
prompt: 'x'.repeat(33 * 1024 * 1024),
mtime: new Date('2026-07-08T00:00:00.000Z'),
});
const { app, secondaryBridge } = makeHarness({
Expand All @@ -3058,7 +3058,7 @@ describe('multi-workspace session dispatch', () => {
expect(response.body).toMatchObject({
code: 'transcript_page_too_large',
sessionId,
maxBytes: 4 * 1024 * 1024,
maxBytes: 32 * 1024 * 1024,
});
expect(response.body.pageBytes).toBeGreaterThan(
response.body.maxBytes as number,
Expand All @@ -3068,6 +3068,39 @@ describe('multi-workspace session dispatch', () => {
});
});

it('serves an indivisible record that exceeds the reader page budget', async () => {
await withRuntimeDir(async () => {
const sessionId = '550e8400-e29b-41d4-a716-446655440280';
const prompt = 'x'.repeat(5 * 1024 * 1024);
await writeStoredSession({
sessionId,
cwd: SECONDARY_CWD,
timestamp: '2026-07-08T00:00:00.000Z',
prompt,
mtime: new Date('2026-07-08T00:00:00.000Z'),
});
const { app, secondaryBridge } = makeHarness({
secondaryTrusted: false,
});

const response = await request(app)
.get(`/workspaces/secondary-id/session/${sessionId}/transcript`)
.set('Host', host());

// A single record cannot be split, so it rides over the 4 MiB reader
// budget (hard ceiling remains the 32 MiB serialization cap).
expect(response.status).toBe(200);
expect(
response.body.events.some(
(event: { data?: { content?: { text?: string } } }) =>
event.data?.content?.text?.length === prompt.length,
),
).toBe(true);
expect(secondaryBridge.spawnCalls).toEqual([]);
expect(secondaryBridge.restoreCalls).toEqual([]);
});
});

it('enforces the workspace transcript cursor byte boundary', () => {
expect(
workspaceTranscriptCursorExceedsLimitForTesting(
Expand Down
56 changes: 34 additions & 22 deletions packages/core/src/services/session-transcript-reader.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,6 @@ import {
resetSessionTranscriptIndexCacheForTest,
setSessionTranscriptIndexCacheMaxBytesForTest,
SessionTranscriptCursorCodec,
SessionTranscriptPageTooLargeError,
SessionTranscriptSnapshotUnavailableError,
SessionTranscriptReader,
} from './session-transcript-reader.js';
Expand Down Expand Up @@ -229,25 +228,25 @@ describe('SessionTranscriptReader', () => {
expect(second.hasMore).toBe(false);
});

it('rejects a single aggregate record over the page byte budget', async () => {
it('returns a single aggregate record that exceeds the page byte budget', async () => {
const first = record('u1', null, 'first');
const second = record('u1', null, 'second fragment');
await writeRecords([first, second, record('a1', 'u1', 'reply')]);
const aggregateBytes =
Buffer.byteLength(JSON.stringify(first)) +
Buffer.byteLength(JSON.stringify(second));

await expect(
new SessionTranscriptReader(workspaceDir).readPage(sessionId, {
const page = await new SessionTranscriptReader(workspaceDir).readPage(
sessionId,
{
limit: 1,
maxBytes: aggregateBytes - 1,
}),
).rejects.toMatchObject({
name: 'SessionTranscriptPageTooLargeError',
sessionId,
pageBytes: aggregateBytes,
maxBytes: aggregateBytes - 1,
} satisfies Partial<SessionTranscriptPageTooLargeError>);
},
);

// An indivisible record rides over budget rather than dead-ending the page.
expect(page.records.map((item) => item.uuid)).toEqual(['u1']);
expect(page.hasMore).toBe(true);
});

it('pages only the active parentUuid chain and skips abandoned branches', async () => {
Expand Down Expand Up @@ -595,7 +594,7 @@ describe('SessionTranscriptReader', () => {
expect(page.hasMore).toBe(false);
});

it('rejects a backward turn that exceeds maxBytes after alignment', async () => {
it('returns a backward turn that exceeds maxBytes after alignment', async () => {
const toolCall = record('a-tool', 'u1', 'call tool');
const toolResult = {
...record('t1', 'a-tool', 'tool result'),
Expand All @@ -610,13 +609,23 @@ describe('SessionTranscriptReader', () => {
record('u2', 'a-final', 'next prompt'),
]);

await expect(
new SessionTranscriptReader(workspaceDir).readPage(sessionId, {
const page = await new SessionTranscriptReader(workspaceDir).readPage(
sessionId,
{
beforeRecordId: 'u2',
limit: 2,
maxBytes: Buffer.byteLength(JSON.stringify(finalAnswer)),
}),
).rejects.toBeInstanceOf(SessionTranscriptPageTooLargeError);
},
);

// The turn cannot be split across pages, so it rides over budget whole.
expect(page.records.map((item) => item.uuid)).toEqual([
'u1',
'a-tool',
't1',
'a-final',
]);
expect(page.hasMore).toBe(false);
});

it('rejects a backward boundary outside the active chain', async () => {
Expand Down Expand Up @@ -1009,12 +1018,15 @@ describe('SessionTranscriptReader', () => {
`${gluedLine}\n${JSON.stringify(record('a1', 'u1', 'reply'))}\n`,
);

await expect(
new SessionTranscriptReader(workspaceDir).readPage(sessionId, {
limit: 1,
maxBytes: Buffer.byteLength(gluedLine) * 2 - 1,
}),
).rejects.toBeInstanceOf(SessionTranscriptPageTooLargeError);
const page = await new SessionTranscriptReader(workspaceDir).readPage(
sessionId,
{ limit: 2, maxBytes: Buffer.byteLength(gluedLine) * 2 },
);

// Conservative per-fragment counting spends the whole budget on the glued
// aggregate, so the next record must wait for the following page.
expect(page.records.map((item) => item.uuid)).toEqual(['u1']);
expect(page.hasMore).toBe(true);
});

it('skips non-ChatRecord JSON lines while indexing', async () => {
Expand Down
38 changes: 10 additions & 28 deletions packages/core/src/services/session-transcript-reader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -460,7 +460,6 @@ function recordSegmentBytes(index: TranscriptIndex, uuid: string): number {

function selectPageUuids(
index: TranscriptIndex,
sessionId: string,
position: number,
limit: number,
maxBytes: number | undefined,
Expand All @@ -472,10 +471,9 @@ function selectPageUuids(
let selectedBytes = 0;
for (const uuid of candidates) {
const bytes = recordSegmentBytes(index, uuid);
if (selected.length === 0 && bytes > maxBytes) {
throw new SessionTranscriptPageTooLargeError(sessionId, bytes, maxBytes);
}
if (selectedBytes + bytes > maxBytes) break;
// A single aggregate record may itself exceed the budget; it cannot be
// split, so always take at least one record or pagination dead-ends.
if (selected.length > 0 && selectedBytes + bytes > maxBytes) break;
selected.push(uuid);
selectedBytes += bytes;
}
Expand All @@ -489,7 +487,6 @@ function isReplayTurnStart(index: TranscriptIndex, uuid: string): boolean {

function selectBackwardPageUuids(
index: TranscriptIndex,
sessionId: string,
position: number,
limit: number,
maxBytes: number | undefined,
Expand All @@ -510,14 +507,15 @@ function selectBackwardPageUuids(
for (let i = position - 1; i >= start; i--) {
const uuid = index.activeUuids[i]!;
const bytes = recordSegmentBytes(index, uuid);
// A turn cannot be split across pages; always take at least one record
// so an oversized turn cannot dead-end backward pagination.
if (
selectedStart === position &&
selectedStart < position &&
maxBytes !== undefined &&
bytes > maxBytes
selectedBytes + bytes > maxBytes
) {
throw new SessionTranscriptPageTooLargeError(sessionId, bytes, maxBytes);
break;
}
Comment on lines 512 to 518

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

[Suggestion] The backward-pagination oversized-turn fix (replacing throw with break) can be reverted on its own with every affected test staying green — no test in this diff independently gates the backward path's first-record-exceeds-budget handling. — Concrete cost: a future refactor re-introducing a throw in selectBackwardPageUuids would not be caught.

The existing test sets maxBytes exactly equal to the first backward record's byte count, so the old bytes > maxBytes throw condition is never true. A test with maxBytes strictly less than the first record's bytes (e.g. Buffer.byteLength(JSON.stringify(finalAnswer)) - 1) would trigger the old throw and pin the fix.

中文说明

向后分页的超大 turn 修复(将 throw 替换为 break)可以被单独回退而所有受影响的测试仍为绿色——本 diff 中没有测试独立地固定住向后路径的首条记录超预算处理。具体代价:未来在 selectBackwardPageUuids 中重新引入 throw 的重构不会被捕获。

现有测试将 maxBytes 设为恰好等于首条向后记录的字节数,因此旧的 bytes > maxBytes 抛出条件永远不为真。建议将 maxBytes 设为严格小于首条记录字节数(如 Buffer.byteLength(JSON.stringify(finalAnswer)) - 1),即可触发旧抛出并固定住此修复。

— qwen3.8-max-preview via Qwen Code /review (v0.21.3)

if (maxBytes !== undefined && selectedBytes + bytes > maxBytes) break;
selectedStart = i;
selectedBytes += bytes;
}
Expand All @@ -530,7 +528,6 @@ function selectBackwardPageUuids(
break;
}
}
let expandedSelection = false;
if (alignedToReplayBoundary && selectedStart > 0) {
let previousTurnStart = selectedStart - 1;
while (
Expand All @@ -541,7 +538,6 @@ function selectBackwardPageUuids(
}
if (previousTurnStart < 0) {
selectedStart = 0;
expandedSelection = true;
}
} else if (!alignedToReplayBoundary) {
while (
Expand All @@ -550,19 +546,6 @@ function selectBackwardPageUuids(
) {
selectedStart--;
}
expandedSelection = true;
}
if (expandedSelection && maxBytes !== undefined) {
const alignedBytes = index.activeUuids
.slice(selectedStart, position)
.reduce((total, uuid) => total + recordSegmentBytes(index, uuid), 0);
if (alignedBytes > maxBytes) {
throw new SessionTranscriptPageTooLargeError(
sessionId,
alignedBytes,
maxBytes,
);
}
}

return {
Expand Down Expand Up @@ -1124,11 +1107,10 @@ export class SessionTranscriptReader {
}
const backwardPage =
direction === 'backward'
? selectBackwardPageUuids(index, sessionId, position, limit, maxBytes)
? selectBackwardPageUuids(index, position, limit, maxBytes)
: undefined;
const pageUuids =
backwardPage?.uuids ??
selectPageUuids(index, sessionId, position, limit, maxBytes);
backwardPage?.uuids ?? selectPageUuids(index, position, limit, maxBytes);
const nextPosition =
backwardPage?.nextPosition ?? position + pageUuids.length;
const records = await readAggregatedRecords(index, pageUuids);
Expand Down
23 changes: 22 additions & 1 deletion packages/web-shell/client/components/MessageList.dom.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -227,7 +227,7 @@ function mount(
loadingOlderHistory?: boolean;
historyCapacityReached?: boolean;
historyPaginationError?: boolean;
onLoadOlderHistory?: () => Promise<void>;
onLoadOlderHistory?: (options?: { force?: boolean }) => Promise<void>;
transcriptBlockCount?: number;
transcriptActivity?: {
getSnapshot(): {
Expand Down Expand Up @@ -1705,6 +1705,27 @@ describe('MessageList — turn collapse (DOM)', () => {
expect(onLoadOlderHistory).not.toHaveBeenCalled();
});

it('retries loading older history with force when the retry button is clicked', async () => {
const onLoadOlderHistory = vi.fn().mockResolvedValue(undefined);
const c = mount([userMsg('u1')], undefined, {
historyPaginationError: true,
onLoadOlderHistory,
});

const button = Array.from(c.querySelectorAll('button')).find(
(el) => el.textContent === 'Retry',
);
expect(button).toBeDefined();

await act(async () => {
button!.dispatchEvent(new MouseEvent('click', { bubbles: true }));
await Promise.resolve();
});

expect(onLoadOlderHistory).toHaveBeenCalledTimes(1);
expect(onLoadOlderHistory).toHaveBeenCalledWith({ force: true });
});

it('does not smooth-scroll when existing session history loads after an empty render', () => {
const scrollTo = vi.fn();
let scrollTop = 0;
Expand Down
16 changes: 16 additions & 0 deletions packages/web-shell/client/components/MessageList.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,22 @@
padding: 4px 0 12px;
}

.historyRetryButton {
margin-left: 8px;
padding: 2px 10px;
background: transparent;
border: 1px solid var(--border);
border-radius: 6px;
cursor: pointer;
font: inherit;
font-size: 12px;
color: var(--muted-foreground);
}

.historyRetryButton:hover {
background: var(--subtle-bg, rgba(128, 128, 128, 0.06));
}

.list > * {
width: min(
100%,
Expand Down
25 changes: 19 additions & 6 deletions packages/web-shell/client/components/MessageList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ interface MessageListProps {
loadingOlderHistory?: boolean;
historyCapacityReached?: boolean;
historyPaginationError?: boolean;
onLoadOlderHistory?: () => Promise<void>;
onLoadOlderHistory?: (options?: { force?: boolean }) => Promise<void>;
transcriptBlockCount?: number;
transcriptActivity?: {
getSnapshot(): {
Expand Down Expand Up @@ -3186,14 +3186,14 @@ export const MessageList = memo(
}, [visibleItems, headerOffset, performScrollToRow]);

const loadOlderHistory = useCallback(
async (allowRetry = false) => {
async (allowRetry = false, force = false) => {
const el = containerRef.current;
if (
!el ||
!onLoadOlderHistory ||
loadingOlderHistory ||
olderHistoryLoadInFlight.current ||
historyPaginationError ||
(historyPaginationError && !force) ||
(olderHistoryRetryBlocked.current && !allowRetry)
) {
return;
Expand All @@ -3205,7 +3205,7 @@ export const MessageList = memo(
const previousTop = el.scrollTop;
followPausedByUserRef.current = true;
try {
await onLoadOlderHistory();
await onLoadOlderHistory(force ? { force: true } : undefined);
setOlderHistoryAnchor({
scrollHeight: previousHeight,
scrollTop: previousTop,
Expand All @@ -3220,6 +3220,10 @@ export const MessageList = memo(
[loadingOlderHistory, onLoadOlderHistory, historyPaginationError],
);

const retryOlderHistory = useCallback(() => {
void loadOlderHistory(true, true);
}, [loadOlderHistory]);

// Rules 2 & 3: detect scroll direction to toggle follow mode.
// Runs synchronously in the scroll handler — no rAF needed since
// the browser already coalesces scroll events.
Expand Down Expand Up @@ -3846,8 +3850,17 @@ export const MessageList = memo(
{historyPaginationError &&
!showLoadingSkeleton &&
!historyCapacityReached && (
<div className={styles.historyStatus} role="status">
{t('history.paginationError')}
<div className={styles.historyStatus}>
<span role="status">{t('history.paginationError')}</span>
{onLoadOlderHistory && (
<button
type="button"
className={styles.historyRetryButton}
onClick={retryOlderHistory}
>
{t('history.retry')}
</button>
)}
</div>
)}
<SessionTimeline
Expand Down
Loading
Loading