Skip to content
Closed
Show file tree
Hide file tree
Changes from 17 commits
Commits
Show all changes
45 commits
Select commit Hold shift + click to select a range
0a4603d
feat(web-shell): branch from completed assistant responses
Jul 30, 2026
bd86733
fix(session): preserve historical branch checkpoints
Aug 1, 2026
870af37
fix(web-shell): harden response session branching
Aug 1, 2026
f901538
Merge branch 'main' into feat/fork-from-one-conversation
water-in-stone Aug 1, 2026
4685801
chore: remove PR comment evaluation artifact
Aug 1, 2026
ed2e419
fix(web-shell): guard historical branch mutations
Aug 1, 2026
dc987bc
Merge branch 'main' into feat/fork-from-one-conversation
wenshao Aug 1, 2026
d7f583e
perf(core): index historical branch points during transcript scan
Aug 1, 2026
d56f473
Merge branch 'main' into feat/fork-from-one-conversation
yiliang114 Aug 1, 2026
e6babb7
fix(core): address review findings — dead code, boundary remap, promp…
qwen-code-ci-bot Aug 2, 2026
613719c
fix(core): address review findings — dead code, boundary remap, promp…
qwen-code-ci-bot Aug 2, 2026
a83b386
fix(core): address review findings — archived GC, subtype registratio…
Aug 2, 2026
ebe1a96
Merge branch 'main' into feat/fork-from-one-conversation
wenshao Aug 2, 2026
bc46b21
test: strengthen branch-point and fork coverage from review (#8274)
qwen-code-ci-bot Aug 2, 2026
1a44cff
fix: address round-4 review feedback for session branching (#8274)
qwen-code-ci-bot Aug 2, 2026
ea2ac9f
fix: address round-5 review feedback for session branching (#8274)
qwen-code-ci-bot Aug 2, 2026
f1fa51c
fix: address round-6 review feedback for session branching (#8274)
qwen-code-ci-bot Aug 3, 2026
b1bf5ff
fix: address round-7 review feedback for session branching (#8274)
qwen-code-ci-bot Aug 3, 2026
e5ae883
Merge branch 'main' into feat/fork-from-one-conversation
qwen-code-dev-bot Aug 3, 2026
9d01e89
Merge branch 'main' into feat/fork-from-one-conversation
wenshao Aug 3, 2026
f05a71d
Merge branch 'main' into feat/fork-from-one-conversation
wenshao Aug 4, 2026
54b7aee
Merge remote-tracking branch 'origin/main' into feat/fork-from-one-co…
qwen-code-ci-bot Aug 4, 2026
759dc6a
Merge branch 'main' into feat/fork-from-one-conversation
qwen-code-dev-bot Aug 4, 2026
5bb933b
fix(core): harden branch-point resolution against malformed transcrip…
qwen-code-dev-bot Aug 5, 2026
d9d3464
Merge remote-tracking branch 'origin/main' into feat/fork-from-one-co…
qwen-code-dev-bot Aug 5, 2026
9caf930
Merge remote-tracking branch 'origin/main' into feat/fork-from-one-co…
qwen-code-dev-bot Aug 5, 2026
8b6390c
fix: address round-8 review feedback for session branching (#8274)
qwen-code-ci-bot Aug 5, 2026
44fee68
fix: address round-9 review feedback for session branching (#8274)
qwen-code-ci-bot Aug 6, 2026
ad24121
test(core): pin branch GC isolation from throwing warning callbacks (…
qwen-code-ci-bot Aug 6, 2026
7c18941
Merge branch 'main' into feat/fork-from-one-conversation
water-in-stone Aug 6, 2026
3909065
Merge branch 'main' into feat/fork-from-one-conversation
wenshao Aug 6, 2026
8f7fe40
fix(acp-bridge): reject rewind at admission while a prompt is active …
qwen-code-ci-bot Aug 6, 2026
0259297
fix(web-shell): harden session branch publication
Aug 7, 2026
0929b21
Merge remote-tracking branch 'upstream/main' into feat/fork-from-one-…
Aug 7, 2026
1524703
Merge branch 'main' into feat/fork-from-one-conversation
qwen-code-dev-bot Aug 7, 2026
d5562bd
fix(web-shell): harden historical response branching
Aug 7, 2026
f5b7b43
Merge remote-tracking branch 'origin/feat/fork-from-one-conversation'…
Aug 7, 2026
2a0e1dc
fix: address round-11 review feedback for session branching (#8274)
qwen-code-ci-bot Aug 7, 2026
32617db
Merge remote-tracking branch 'origin/main' into feat/fork-from-one-co…
qwen-code-ci-bot Aug 7, 2026
20bf878
fix: address round-12 review feedback for session branching (#8274)
qwen-code-ci-bot Aug 7, 2026
40c50aa
Merge origin/main into feat/fork-from-one-conversation
qwen-code-ci-bot Aug 8, 2026
26b96c7
Merge branch 'main' into feat/fork-from-one-conversation
qwen-code-dev-bot Aug 8, 2026
e3e8469
refactor(branching): remove branch-specific overdesign
Aug 9, 2026
1ddb7a3
Merge branch 'main' into feat/fork-from-one-conversation
qwen-code-dev-bot Aug 9, 2026
60494e3
Merge remote-tracking branch 'upstream/main' into feat/fork-from-one-…
Aug 9, 2026
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
829 changes: 829 additions & 0 deletions docs/design/web-shell/assistant-response-session-branching.md

Large diffs are not rendered by default.

272 changes: 272 additions & 0 deletions packages/acp-bridge/src/bridge.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6967,6 +6967,127 @@ describe('createAcpSessionBridge', () => {
await bridge.shutdown();
});

it('serializes rewind behind an admitted branch', async () => {
const calls: string[] = [];
let branchParams: unknown;
const branchGate = deferred<void>();
const handle = makeChannel({
extMethodImpl: async (method, params) => {
if (method === 'qwen/control/session/branch') {
calls.push('branch');
branchParams = params;
await branchGate.promise;
return { newSessionId: 'branch-before-rewind', title: 'Branch' };
}
if (method === 'qwen/control/session/rewind') {
calls.push('rewind');
return { targetTurnIndex: 0, filesChanged: [], filesFailed: [] };
}
return {};
},
resumeSessionImpl: () => ({}),
});
const bridge = makeBridge({ channelFactory: async () => handle.channel });
const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A });

const branch = bridge.branchSession(session.sessionId, {
atRecordId: '11111111-1111-4111-8111-111111111111',
});
const rewind = bridge.rewindSession(session.sessionId, {
promptId: 'prompt-1',
});
await vi.waitFor(() => expect(calls).toEqual(['branch']));

branchGate.resolve(undefined);
await branch;
await rewind;
expect(calls).toEqual(['branch', 'rewind']);
expect(branchParams).toMatchObject({
atRecordId: '11111111-1111-4111-8111-111111111111',
});
await bridge.shutdown();
});

it('serializes branch behind an admitted rewind', async () => {
const calls: string[] = [];
const rewindGate = deferred<void>();
const handle = makeChannel({
extMethodImpl: async (method) => {
if (method === 'qwen/control/session/rewind') {
calls.push('rewind');
await rewindGate.promise;
return { targetTurnIndex: 0, filesChanged: [], filesFailed: [] };
}
if (method === 'qwen/control/session/branch') {
calls.push('branch');
return { newSessionId: 'branch-after-rewind', title: 'Branch' };
}
return {};
},
resumeSessionImpl: () => ({}),
});
const bridge = makeBridge({ channelFactory: async () => handle.channel });
const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A });

const rewind = bridge.rewindSession(session.sessionId, {
promptId: 'prompt-1',
});
const branch = bridge.branchSession(session.sessionId, {
atRecordId: '11111111-1111-4111-8111-111111111111',
});
await vi.waitFor(() => expect(calls).toEqual(['rewind']));

rewindGate.resolve(undefined);
await rewind;
await branch;
expect(calls).toEqual(['rewind', 'branch']);
await bridge.shutdown();
});

it('rejects a queued branch when the source starts closing', async () => {
const promptStarted = deferred<void>();
const promptGate = deferred<void>();
const closeGate = deferred<Record<string, unknown>>();
const handle = makeChannel({
promptImpl: async () => {
promptStarted.resolve();
await promptGate.promise;
return { stopReason: 'end_turn' };
},
extMethodImpl: (method) => {
if (method === SERVE_CONTROL_EXT_METHODS.sessionClose) {
return closeGate.promise;
}
if (method === SERVE_CONTROL_EXT_METHODS.sessionBranch) {
throw new Error('branch must not reach the closing source');
}
return {};
},
});
const bridge = makeBridge({ channelFactory: async () => handle.channel });
const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A });
const prompt = bridge.sendPrompt(session.sessionId, {
sessionId: session.sessionId,
prompt: [{ type: 'text', text: 'active' }],
});
await promptStarted.promise;
const branch = bridge.branchSession(session.sessionId, {});
const close = bridge.closeSession(session.sessionId);

promptGate.resolve();
await prompt;
await expect(branch).rejects.toBeInstanceOf(SessionNotFoundError);
expect(handle.agent.extMethodCalls).not.toContainEqual(
expect.objectContaining({
method: SERVE_CONTROL_EXT_METHODS.sessionBranch,
}),
);

closeGate.resolve({});
await close;
await bridge.shutdown();
});

it('publishes session_branched only on the new session stream', async () => {
const factory: ChannelFactory = async () =>
makeChannel({
Expand Down Expand Up @@ -7117,6 +7238,157 @@ describe('createAcpSessionBridge', () => {
});

describe('pendingPromptList', () => {
it('publishes a validated completed-turn branch point', async () => {
const events: BridgeEvent[] = [];
const handle = makeChannel({
promptImpl: () =>
({
stopReason: 'end_turn',
_meta: {
'qwen.branchPoint': {
assistantRecordUuid: '11111111-1111-4111-8111-111111111111',
checkpointUuid: '22222222-2222-4222-8222-222222222222',
},
},
}) as PromptResponse,
});
const bridge = makeBridge({ channelFactory: async () => handle.channel });
const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A });
const sub = (async () => {
for await (const event of bridge.subscribeEvents(session.sessionId)) {
events.push(event);
}
})();
sub.catch(() => {});

await bridge.sendPrompt(session.sessionId, {
sessionId: session.sessionId,
prompt: [{ type: 'text', text: 'branchable' }],
});

await vi.waitFor(() =>
expect(
events.find((event) => event.type === 'turn_complete'),
).toMatchObject({
data: {
branchPoint: {
assistantRecordUuid: '11111111-1111-4111-8111-111111111111',
checkpointUuid: '22222222-2222-4222-8222-222222222222',
},
},
}),
);
await bridge.shutdown();
});

it('drops malformed completed-turn branch metadata', async () => {
const events: BridgeEvent[] = [];
const handle = makeChannel({
promptImpl: () =>
({
stopReason: 'end_turn',
_meta: {
'qwen.branchPoint': {
assistantRecordUuid: 'not-a-uuid',
checkpointUuid: '22222222-2222-4222-8222-222222222222',
},
},
}) as PromptResponse,
});
const bridge = makeBridge({ channelFactory: async () => handle.channel });
const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A });
const sub = (async () => {
for await (const event of bridge.subscribeEvents(session.sessionId)) {
events.push(event);
}
})();
sub.catch(() => {});

await bridge.sendPrompt(session.sessionId, {
sessionId: session.sessionId,
prompt: [{ type: 'text', text: 'not branchable' }],
});

await vi.waitFor(() => {
const complete = events.find((event) => event.type === 'turn_complete');
expect(complete).toBeDefined();
expect(complete?.data).not.toHaveProperty('branchPoint');
});
await bridge.shutdown();
});

it('drops a branch point with a malformed checkpoint uuid', async () => {
const events: BridgeEvent[] = [];
const handle = makeChannel({
promptImpl: () =>
({
stopReason: 'end_turn',
_meta: {
'qwen.branchPoint': {
assistantRecordUuid: '11111111-1111-4111-8111-111111111111',
checkpointUuid: 'not-a-uuid',
},
},
}) as PromptResponse,
});
const bridge = makeBridge({ channelFactory: async () => handle.channel });
const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A });
const sub = (async () => {
for await (const event of bridge.subscribeEvents(session.sessionId)) {
events.push(event);
}
})();
sub.catch(() => {});

await bridge.sendPrompt(session.sessionId, {
sessionId: session.sessionId,
prompt: [{ type: 'text', text: 'bad checkpoint' }],
});

await vi.waitFor(() => {
const complete = events.find((event) => event.type === 'turn_complete');
expect(complete).toBeDefined();
expect(complete?.data).not.toHaveProperty('branchPoint');
});
await bridge.shutdown();
});

it('drops a branch point when the turn does not end cleanly', async () => {
const events: BridgeEvent[] = [];
const handle = makeChannel({
promptImpl: () =>
({
stopReason: 'max_tokens',
_meta: {
'qwen.branchPoint': {
assistantRecordUuid: '11111111-1111-4111-8111-111111111111',
checkpointUuid: '22222222-2222-4222-8222-222222222222',
},
},
}) as PromptResponse,
});
const bridge = makeBridge({ channelFactory: async () => handle.channel });
const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A });
const sub = (async () => {
for await (const event of bridge.subscribeEvents(session.sessionId)) {
events.push(event);
}
})();
sub.catch(() => {});

await bridge.sendPrompt(session.sessionId, {
sessionId: session.sessionId,
prompt: [{ type: 'text', text: 'not end turn' }],
});

await vi.waitFor(() => {
const complete = events.find((event) => event.type === 'turn_complete');
expect(complete).toBeDefined();
expect(complete?.data).not.toHaveProperty('branchPoint');
});
await bridge.shutdown();
});

it('tracks a single prompt without publishing a pending_prompt_added event', async () => {
const events: BridgeEvent[] = [];
const handle = makeChannel({
Expand Down
Loading
Loading