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
50 changes: 35 additions & 15 deletions docs/design/web-shell-history-pagination.md
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,33 @@ transcript becomes scrollable or history is exhausted. A failed or partial page
leaves the current transcript intact, stops automatic retries, and surfaces the
existing daemon notice path.

### Live-session retention

The 50,000-block store limit remains a final safety cap. Web Shell also applies
a 500-block reload trigger to sessions that remain open for a long time. Once a
transcript exceeds that trigger, the agent is idle, no SSE event has arrived for
two minutes, and the reader remains at the live tail, Web Shell reloads the same
session with `historyPageSize: 100`. The old SSE subscription is closed by the
normal session-switch cleanup. The load response supplies the bounded replay
and its atomic `lastEventId`; the provider rebuilds the transcript and starts a
new SSE subscription from that watermark.

The existing transcript remains mounted while this background load is in
flight. Once the bounded replay arrives, the provider resets and dispatches it
in one store notification, so the reader never sees an empty or loading state.

Loading an already attached session with a page size refreshes only its UI
replay. It does not restart the agent or reload the model-facing conversation.
The bridge reads a fresh persisted page while the session EventBus watermark is
stable and returns it through the normal load envelope. If events arrive during
that read, the bridge retries and otherwise falls back to its existing replay.

After reload, upward scrolling follows the same `beforeRecordId` and opaque
cursor pagination used by historical sessions. Scrolling upward cancels the
reload timer. Returning to the live tail starts a new two-minute quiet period.
Main and split views own independent providers, SSE subscriptions, timers,
cursors, and retained windows.

## Consistency and failure handling

- Initial history and the SSE watermark remain coupled through `session/load`.
Expand All @@ -138,18 +165,11 @@ existing daemon notice path.

## Affected areas

| Layer | Change |
| ------------------------ | ------------------------------------------------------------ |
| Core transcript reader | Backward cursor and exclusive record boundary |
| ACP replay | Record UUID metadata and latest-suffix selection |
| ACP bridge / serve route | Paged-load metadata, validation, and `hasMore` propagation |
| TypeScript SDK | Restore option, backward page option, restored history state |
| WebUI provider | Isolated prepend, page state, stale-request protection |
| Web Shell | Opt-in page size and automatic top-loading behavior |

## Open questions and follow-ups

- A session created and kept live for its entire lifetime has no persisted
record UUID on its live EventBus frames. This change pages sessions restored
through the new load path; adding record identity to live emission is a
separate recording/emission coordination change.
| Layer | Change |
| ------------------------ | ---------------------------------------------------------- |
| Core transcript reader | Backward cursor and exclusive record boundary |
| ACP replay | Record UUID metadata and latest-suffix selection |
| ACP bridge / serve route | Paged-load metadata, validation, and `hasMore` propagation |
| TypeScript SDK | Restore page-size option and restored history state |
| WebUI provider | Isolated prepend, page state, stale-request protection |
| Web Shell | Opt-in page size and automatic top-loading behavior |
310 changes: 310 additions & 0 deletions packages/acp-bridge/src/bridge.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2919,6 +2919,316 @@ describe('createAcpSessionBridge', () => {
await bridge.shutdown();
});

it('refreshes an attached load from a bounded persisted page', async () => {
const handle = makeChannel({
loadSessionImpl: () => ({
_meta: {
'qwen.session.loadReplay': {
v: 1,
updates: [
{
sessionUpdate: 'user_message_chunk',
content: { type: 'text', text: 'initial prompt' },
},
],
},
},
}),
extMethodImpl: (method, params) => {
if (method !== SERVE_STATUS_EXT_METHODS.sessionTranscript) {
throw new Error(`unexpected extMethod ${method}`);
}
return {
v: 1,
sessionId: params['sessionId'],
events: [
{
v: 1,
type: 'session_update',
data: {
sessionUpdate: 'user_message_chunk',
content: { type: 'text', text: 'latest prompt' },
_meta: { 'qwen.session.recordId': 'record-latest' },
},
},
],
hasMore: true,
};
},
});
const bridge = makeBridge({ channelFactory: async () => handle.channel });
const loaded = await bridge.loadSession({
sessionId: 'persisted-live-refresh',
workspaceCwd: WS_A,
historyReplay: 'response',
historyPageSize: 100,
});

const refreshed = await bridge.loadSession({
sessionId: loaded.sessionId,
workspaceCwd: WS_A,
clientId: loaded.clientId,
historyReplay: 'response',
historyPageSize: 100,
});

expect(handle.agent.loadSessionCalls).toHaveLength(1);
expect(handle.agent.extMethodCalls).toContainEqual({
method: SERVE_STATUS_EXT_METHODS.sessionTranscript,
params: {
cwd: WS_A,
sessionId: loaded.sessionId,
direction: 'backward',
limit: 100,
},
});
expect(refreshed).toMatchObject({
attached: true,
historyHasMore: true,
lastEventId: loaded.lastEventId,
compactedReplay: [
{
type: 'session_update',
data: {
content: { type: 'text', text: 'latest prompt' },
},
},
],
liveJournal: [],
});

await bridge.shutdown();
});

it('propagates partial and replayError from a bounded refresh', async () => {
const handle = makeChannel({
loadSessionImpl: () => ({
_meta: {
'qwen.session.loadReplay': {
v: 1,
updates: [
{
sessionUpdate: 'user_message_chunk',
content: { type: 'text', text: 'initial prompt' },
},
],
},
},
}),
extMethodImpl: (method, params) => {
if (method !== SERVE_STATUS_EXT_METHODS.sessionTranscript) {
throw new Error(`unexpected extMethod ${method}`);
}
return {
v: 1,
sessionId: params['sessionId'],
events: [
{
v: 1,
type: 'session_update',
data: {
sessionUpdate: 'user_message_chunk',
content: { type: 'text', text: 'bounded page' },
_meta: { 'qwen.session.recordId': 'record-bounded' },
},
},
],
hasMore: true,
partial: true,
replayError: 'transcript read failed',
};
},
});
const bridge = makeBridge({ channelFactory: async () => handle.channel });
const loaded = await bridge.loadSession({
sessionId: 'persisted-live-refresh-metadata',
workspaceCwd: WS_A,
historyReplay: 'response',
historyPageSize: 100,
});

const refreshed = await bridge.loadSession({
sessionId: loaded.sessionId,
workspaceCwd: WS_A,
clientId: loaded.clientId,
historyReplay: 'response',
historyPageSize: 100,
});

expect(refreshed).toMatchObject({
attached: true,
partial: true,
replayError: 'transcript read failed',
historyHasMore: true,
});

await bridge.shutdown();
});

it('falls back to the live replay when a bounded refresh stays unstable', async () => {
let update = 0;
const handle = makeChannel({
loadSessionImpl: () => ({
_meta: {
'qwen.session.loadReplay': {
v: 1,
updates: [
{
sessionUpdate: 'user_message_chunk',
content: { type: 'text', text: 'initial prompt' },
},
],
},
},
}),
extMethodImpl: async (method, params) => {
if (method !== SERVE_STATUS_EXT_METHODS.sessionTranscript) {
throw new Error(`unexpected extMethod ${method}`);
}
update++;
await handle.agentConnection.sessionUpdate({
sessionId: params['sessionId'] as string,
update: {
sessionUpdate: 'agent_message_chunk',
content: { type: 'text', text: `live update ${update}` },
},
});
return {
v: 1,
sessionId: params['sessionId'],
events: [
{
v: 1,
type: 'session_update',
data: {
sessionUpdate: 'user_message_chunk',
content: { type: 'text', text: 'bounded page' },
},
},
],
hasMore: true,
};
},
});
const bridge = makeBridge({ channelFactory: async () => handle.channel });
const loaded = await bridge.loadSession({
sessionId: 'persisted-live-refresh-race',
workspaceCwd: WS_A,
historyReplay: 'response',
historyPageSize: 100,
});

const refreshed = await bridge.loadSession({
sessionId: loaded.sessionId,
workspaceCwd: WS_A,
clientId: loaded.clientId,
historyReplay: 'response',
historyPageSize: 100,
});

expect(handle.agent.extMethodCalls).toHaveLength(2);
expect(refreshed).not.toHaveProperty('historyHasMore');
expect(JSON.stringify(refreshed.compactedReplay)).not.toContain(
'bounded page',
);
expect(JSON.stringify(refreshed)).toContain('live update 2');

await bridge.shutdown();
});

it('falls back to the live replay when a bounded refresh read fails', async () => {
const handle = makeChannel({
loadSessionImpl: () => ({
_meta: {
'qwen.session.loadReplay': {
v: 1,
updates: [
{
sessionUpdate: 'user_message_chunk',
content: { type: 'text', text: 'initial prompt' },
},
],
},
},
}),
extMethodImpl: (method, _params) => {
if (method !== SERVE_STATUS_EXT_METHODS.sessionTranscript) {
throw new Error(`unexpected extMethod ${method}`);
}
throw new Error('transcript page read failed');
},
});
const bridge = makeBridge({ channelFactory: async () => handle.channel });
const loaded = await bridge.loadSession({
sessionId: 'persisted-live-refresh-read-error',
workspaceCwd: WS_A,
historyReplay: 'response',
historyPageSize: 100,
});

const refreshed = await bridge.loadSession({
sessionId: loaded.sessionId,
workspaceCwd: WS_A,
clientId: loaded.clientId,
historyReplay: 'response',
historyPageSize: 100,
});

expect(refreshed.attached).toBe(true);
expect(JSON.stringify(refreshed.compactedReplay)).toContain(
'initial prompt',
);

await bridge.shutdown();
});

it('rejects a bounded refresh when the session starts closing', async () => {
const transcriptPage = deferred<Record<string, unknown>>();
const closeResult = deferred<Record<string, unknown>>();
const handle = makeChannel({
extMethodImpl: (method, _params) => {
if (method === SERVE_STATUS_EXT_METHODS.sessionTranscript) {
return transcriptPage.promise;
}
if (method === SERVE_CONTROL_EXT_METHODS.sessionClose) {
return closeResult.promise;
}
throw new Error(`unexpected extMethod ${method}`);
},
});
const bridge = makeBridge({ channelFactory: async () => handle.channel });
const loaded = await bridge.loadSession({
sessionId: 'persisted-live-refresh-closing',
workspaceCwd: WS_A,
historyReplay: 'response',
historyPageSize: 100,
});
const refresh = bridge.loadSession({
sessionId: loaded.sessionId,
workspaceCwd: WS_A,
clientId: loaded.clientId,
historyReplay: 'response',
historyPageSize: 100,
});
await vi.waitFor(() => expect(handle.agent.extMethodCalls).toHaveLength(1));

const close = bridge.closeSession(loaded.sessionId, {
clientId: loaded.clientId,
});
await vi.waitFor(() => expect(handle.agent.extMethodCalls).toHaveLength(2));
transcriptPage.resolve({
v: 1,
sessionId: loaded.sessionId,
events: [],
hasMore: false,
});

await expect(refresh).rejects.toBeInstanceOf(SessionNotFoundError);
closeResult.resolve({});
await close;
await bridge.shutdown();
});

it('restores artifacts from response-mode load replay when no snapshot is available', async () => {
const handles: ChannelHandle[] = [];
const factory: ChannelFactory = async () => {
Expand Down
Loading
Loading