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
12 changes: 7 additions & 5 deletions docs/design/2026-08-08-selective-session-restore.md
Original file line number Diff line number Diff line change
Expand Up @@ -475,11 +475,13 @@ segments once:
malformed-context, turn-reentry, and truncation decisions without retaining
evidence content. Add only the selected evidence UUIDs to the union, then feed
their materialized records to the shared accumulator and retain the resulting
window in the projection. This two-stage selection must preserve both the
existing production helper's result and its fail-closed errors; it must not
select every active record, perform a second scan, or copy Goal precedence.
Deferred Goal activation consumes that window instead of reading the
transcript again.
window in the projection. This two-stage selection must preserve the existing
production helper's valid result. When its evidence source is unavailable or
invalid, omit the projected window so deferred Goal activation falls back to
the existing runtime path and its established degradation behavior instead of
rejecting the whole session restore. It must not select every active record,
perform a second scan, or copy Goal precedence. Deferred Goal activation
consumes a valid projected window instead of reading the transcript again.
5. **File history.** Read every active `file_history_snapshot` record in
chronological order and feed each batch through the existing whole-batch
deserializer. This preserves today's behavior where one malformed item skips
Expand Down
2 changes: 2 additions & 0 deletions packages/acp-bridge/src/bridge.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4234,6 +4234,7 @@ describe('createAcpSessionBridge', () => {
keep: 'state',
'qwen.session.loadReplay': {
v: 1,
anchorRecordId: 'record-anchor',
hasMore: true,
partial: true,
replayError: 'replay boom',
Expand Down Expand Up @@ -4278,6 +4279,7 @@ describe('createAcpSessionBridge', () => {
expect(loaded.partial).toBe(true);
expect(loaded.replayError).toBe('replay boom');
expect(loaded.historyHasMore).toBe(true);
expect(loaded.historyAnchorRecordId).toBe('record-anchor');
expect(loaded.lastEventId).toBe(2);
expect(loaded.compactedReplay).toHaveLength(2);
expect(loaded.liveJournal).toEqual([]);
Expand Down
30 changes: 27 additions & 3 deletions packages/acp-bridge/src/bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,7 @@ import {
DAEMON_PROMPT_DISPLAY_TEXT_META_KEY,
LOAD_REPLAY_BULK_MODE,
LOAD_REPLAY_HIDE_INHERITED_META_KEY,
LOAD_REPLAY_MAX_UPDATES,
LOAD_REPLAY_META_KEY,
LOAD_REPLAY_MODE_META_KEY,
LOAD_REPLAY_PAGE_SIZE_META_KEY,
Expand Down Expand Up @@ -248,7 +249,6 @@ const KNOWN_SESSION_UPDATE_TYPES = new Set([
'session_info_update',
'usage_update',
]);
const MAX_BULK_REPLAY_UPDATES = 10_000;

function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
Expand Down Expand Up @@ -657,6 +657,7 @@ function describeLoadReplayValue(value: unknown): string {
function extractLoadReplayResponse(state: BridgeSessionState): {
state: BridgeSessionState;
updates: SessionUpdate[];
anchorRecordId?: string;
partial?: true;
replayError?: string;
hasMore?: boolean;
Expand All @@ -678,10 +679,10 @@ function extractLoadReplayResponse(state: BridgeSessionState): {
`(version=${LOAD_REPLAY_VERSION}, count=not-array)`,
);
}
if (rawUpdates.length > MAX_BULK_REPLAY_UPDATES) {
if (rawUpdates.length > LOAD_REPLAY_MAX_UPDATES) {
throw new Error(
`qwen.session.loadReplay updates exceed limit ` +
`(${rawUpdates.length} > ${MAX_BULK_REPLAY_UPDATES})`,
`(${rawUpdates.length} > ${LOAD_REPLAY_MAX_UPDATES})`,
);
}
const partial = replay['partial'];
Expand All @@ -705,6 +706,13 @@ function extractLoadReplayResponse(state: BridgeSessionState): {
`(version=${LOAD_REPLAY_VERSION}, hasMore=${describeLoadReplayValue(hasMore)})`,
);
}
const anchorRecordId = replay['anchorRecordId'];
if (anchorRecordId !== undefined && typeof anchorRecordId !== 'string') {
throw new Error(
`Invalid qwen.session.loadReplay anchorRecordId ` +
`(version=${LOAD_REPLAY_VERSION}, anchorRecordId=${describeLoadReplayValue(anchorRecordId)})`,
);
}
const invalidUpdateIndex = rawUpdates.findIndex(
(update) => !isBulkReplayUpdate(update),
);
Expand All @@ -731,6 +739,7 @@ function extractLoadReplayResponse(state: BridgeSessionState): {
return {
state: cleanState,
updates: rawUpdates,
...(typeof anchorRecordId === 'string' ? { anchorRecordId } : {}),
...(partial === true ? { partial: true as const } : {}),
...(typeof replayError === 'string' ? { replayError } : {}),
...(hasMore === true ? { hasMore: true } : {}),
Expand Down Expand Up @@ -1126,6 +1135,7 @@ interface SessionEntry {
restoreReplayPartial?: true;
restoreReplayError?: string;
restoreHistoryHasMore?: true;
restoreHistoryAnchorRecordId?: string;
/**
* Most recent heartbeat across any client on this session (Date.now()
* epoch ms). Set on every `recordHeartbeat` call regardless of whether
Expand Down Expand Up @@ -5284,6 +5294,7 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
| 'restoreReplayPartial'
| 'restoreReplayError'
| 'restoreHistoryHasMore'
| 'restoreHistoryAnchorRecordId'
| 'activePromptId'
>,
action: 'load' | 'resume',
Expand All @@ -5297,6 +5308,7 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
| 'partial'
| 'replayError'
| 'historyHasMore'
| 'historyAnchorRecordId'
> => {
const replayStatus =
action === 'load' && entry.restoreReplayPartial === true
Expand All @@ -5317,6 +5329,10 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
lastEventId: entry.events.lastEventId,
eventEpoch,
...replayStatus,
...(action === 'load' &&
entry.restoreHistoryAnchorRecordId !== undefined
? { historyAnchorRecordId: entry.restoreHistoryAnchorRecordId }
: {}),
};
}
if (action === 'load') {
Expand All @@ -5342,6 +5358,9 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
...(entry.restoreHistoryHasMore === true
? { historyHasMore: true }
: {}),
...(entry.restoreHistoryAnchorRecordId !== undefined
? { historyAnchorRecordId: entry.restoreHistoryAnchorRecordId }
: {}),
};
}
return { lastEventId: snapshot.lastEventId, eventEpoch, ...replayStatus };
Expand Down Expand Up @@ -6013,6 +6032,7 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
let replayPartial: true | undefined;
let replayError: string | undefined;
let replayHasMore: true | undefined;
let replayAnchorRecordId: string | undefined;
try {
const rawRestore = telemetry.withSpan(
'session.restore',
Expand Down Expand Up @@ -6137,6 +6157,7 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
replayPartial = extracted.partial;
replayError = extracted.replayError;
replayHasMore = extracted.hasMore === true ? true : undefined;
replayAnchorRecordId = extracted.anchorRecordId;
}
} catch (err) {
if (err instanceof SessionRestoreTimeoutError) throw err;
Expand Down Expand Up @@ -6258,6 +6279,9 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
if (replayHasMore === true) {
entry.restoreHistoryHasMore = true;
}
if (replayAnchorRecordId !== undefined) {
entry.restoreHistoryAnchorRecordId = replayAnchorRecordId;
}
seedSnapshotCaches(entry, publicState);
const artifactRestoreWarnings = await entry.artifacts.restore(
restoredArtifactSnapshot,
Expand Down
3 changes: 3 additions & 0 deletions packages/acp-bridge/src/bridgeTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,8 @@ export const LOAD_REPLAY_HIDE_INHERITED_META_KEY =
'qwen.session.loadReplayHideInherited';
export const LOAD_REPLAY_BULK_MODE = 'bulk';
export const LOAD_REPLAY_VERSION = 1 as const;
export const LOAD_REPLAY_MAX_BYTES = 32 * 1024 * 1024;
export const LOAD_REPLAY_MAX_UPDATES = 10_000;

export const REQUESTED_SESSION_ID_META_KEY = 'qwen-code/sessionId';

Expand Down Expand Up @@ -338,6 +340,7 @@ export interface ChannelStartupProfileV1 {
export interface BridgeLoadReplayEnvelope {
v: typeof LOAD_REPLAY_VERSION;
updates: SessionUpdate[];
anchorRecordId?: string;
hasMore?: boolean;
partial?: true;
replayError?: string;
Expand Down
29 changes: 29 additions & 0 deletions packages/channels/base/src/AcpBridge.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,7 @@ type TestableAcpBridge = AcpBridge & {
extMethod: ReturnType<typeof vi.fn>;
newSession?: ReturnType<typeof vi.fn>;
loadSession?: ReturnType<typeof vi.fn>;
unstable_resumeSession?: ReturnType<typeof vi.fn>;
prompt?: ReturnType<typeof vi.fn>;
};
knownSessionIds: Set<string>;
Expand Down Expand Up @@ -418,6 +419,34 @@ describe('AcpBridge', () => {
expect(extMethod).toHaveBeenCalledOnce();
});

it('restores channel sessions through resume without replaying history', async () => {
const bridge = new AcpBridge({
cliEntryPath: '/tmp/qwen',
cwd: '/tmp',
}) as unknown as TestableAcpBridge;
const resumeSession = vi.fn().mockResolvedValue({});
bridge.child = { killed: false, exitCode: null };
bridge.connection = {
extMethod: vi.fn(),
unstable_resumeSession: resumeSession,
} as TestableAcpBridge['connection'];
const bindingToken = {};

await expect(
bridge.loadSession('restored-session', '/tmp', undefined, bindingToken),
).resolves.toBe('restored-session');

expect(resumeSession).toHaveBeenCalledWith({
sessionId: 'restored-session',
cwd: '/tmp',
mcpServers: [],
});
expect(bridge.knownSessionIds.has('restored-session')).toBe(true);
expect(bridge.sessionBindingTokens.get('restored-session')).toBe(
bindingToken,
);
});

it('returns only the final turn text after tool calls', async () => {
const bridge = new AcpBridge({
cliEntryPath: '/tmp/qwen',
Expand Down
2 changes: 1 addition & 1 deletion packages/channels/base/src/AcpBridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -247,7 +247,7 @@ export class AcpBridge extends EventEmitter implements ChannelAgentBridge {
): Promise<string> {
const conn = this.ensureConnection();
await this.registerChannelLoopMcpServer();
await conn.loadSession({
await conn.unstable_resumeSession({
sessionId,
cwd,
mcpServers: [],
Expand Down
Loading
Loading