feat(btw): fork at the last completed turn, and never leak the inherited tail

Two related fixes to what a btw session inherits and shows.

**Fork point.** `/btw` is typically typed *while* the main thread is
working — that is the moment a side question comes up. Forking at HEAD
then clones a turn that is still streaming, so the fork inherits a
truncated assistant message and the user instruction that provoked it as
the newest, most salient thing in its context. Fork at the parent's last
completed assistant turn instead, read from the sync store with no extra
round-trip. A parent with no completed turn yet keeps the previous
fork-at-HEAD behavior.

**Boundary.** `filterBtwTailMessages` shows every inherited message when
the boundary is `null`, and the boundary is `null` whenever the
newest-cloned read comes back empty. That is correct for a fork of an
empty parent, but it also turns an empty read into "nothing was
inherited" for a fork that demonstrably did inherit history — the panel
then opens with the parent's whole transcript in it. Having picked a
fork point proves the parent had turns, so fall back to that id instead
of `null`. The fork's own messages are created later and still sort
after it, so its tail stays complete either way.
This commit is contained in:
dibanez
2026-08-27 09:37:43 +02:00
parent 92c3e0d5e5
commit b0083f6e0f
2 changed files with 92 additions and 5 deletions
+50 -1
View File
@@ -16,6 +16,7 @@ const upsertedSessions: unknown[] = [];
const childStoreSessions: Session[] = [];
const currentSessionSwitches: string[] = [];
const metadataPatches: Array<{ sessionId: string; result: Record<string, unknown> }> = [];
const parentSyncMessages: Message[] = [];
mock.module('@/lib/opencode/client', () => ({
opencodeClient: {
@@ -48,6 +49,7 @@ mock.module('@/stores/useGlobalSessionsStore', () => ({
}));
mock.module('@/sync/sync-refs', () => ({
registerSessionDirectory: (sessionId: string, directory: string) => { registeredDirectories.push(`${sessionId}:${directory}`); },
getSyncMessages: () => parentSyncMessages,
getSyncChildStores: () => ({
children: new Map([['/project', {
getState: () => ({ session: childStoreSessions }),
@@ -56,7 +58,7 @@ mock.module('@/sync/sync-refs', () => ({
}),
}));
const { btwSessionTitle, startBtwSession, destroyBtwSession, promoteBtwSession, filterBtwTailMessages, BTW_BOUNDARY_INSTRUCTION } =
const { btwSessionTitle, startBtwSession, destroyBtwSession, promoteBtwSession, filterBtwTailMessages, findLastCompletedAssistantMessageID, BTW_BOUNDARY_INSTRUCTION } =
await import('@/lib/btw');
const { useBtwStore } = await import('@/stores/useBtwStore');
@@ -74,6 +76,15 @@ const record = (id: string): { info: Message; parts: Part[] } => ({
parts: [],
});
// SAFETY: `findLastCompletedAssistantMessageID` reads only `id`, `role` and
// `time`, which are the fields spelled out here.
const assistantMessage = (id: string, completed?: number) =>
({ id, sessionID: 'parent-1', role: 'assistant', time: { created: 1, completed } }) as Message;
// SAFETY: same narrow read as `assistantMessage`.
const userMessage = (id: string) =>
({ id, sessionID: 'parent-1', role: 'user', time: { created: 1 } }) as Message;
const startInput = {
parentSessionId: 'parent-1',
question: 'wtf is kafka',
@@ -90,6 +101,7 @@ beforeEach(() => {
childStoreSessions.length = 0;
currentSessionSwitches.length = 0;
metadataPatches.length = 0;
parentSyncMessages.length = 0;
useBtwStore.setState({ byParent: {} });
forkSessionImpl = () => Promise.reject(new Error('no forkSession stub'));
getSessionMessagesImpl = () => Promise.resolve([record('msg-boundary')]);
@@ -121,6 +133,17 @@ describe('filterBtwTailMessages', () => {
});
});
describe('findLastCompletedAssistantMessageID', () => {
test('skips an assistant turn that is still streaming', () => {
const messages = [assistantMessage('msg-1', 10), userMessage('msg-2'), assistantMessage('msg-3')];
expect(findLastCompletedAssistantMessageID(messages)).toBe('msg-1');
});
test('a session with no completed assistant turn has no fork point', () => {
expect(findLastCompletedAssistantMessageID([userMessage('msg-1')])).toBe(null);
});
});
describe('startBtwSession', () => {
test('forks, marks the fork, links the parent, and routes the question to the fork', async () => {
forkSessionImpl = (sessionId, messageId, directory) => {
@@ -151,6 +174,32 @@ describe('startBtwSession', () => {
expect(useBtwStore.getState().byParent).toEqual({});
});
test('forks at the last completed assistant turn, not at the in-flight one', async () => {
parentSyncMessages.push(assistantMessage('msg-1', 10), userMessage('msg-2'), assistantMessage('msg-3'));
const forkPoints: Array<string | undefined> = [];
forkSessionImpl = (_sessionId, messageId) => {
forkPoints.push(messageId);
return Promise.resolve(makeSession('fork-1', '/project'));
};
await startBtwSession(startInput);
expect(forkPoints).toEqual(['msg-1']);
});
test('the boundary falls back to the fork point when the cloned tail reads empty', async () => {
parentSyncMessages.push(assistantMessage('msg-1', 10));
forkSessionImpl = () => Promise.resolve(makeSession('fork-1', '/project'));
getSessionMessagesImpl = () => Promise.resolve([]);
await startBtwSession(startInput);
// Not `null`: a null boundary would show the whole inherited transcript.
expect(metadataPatches[0]?.result).toEqual({
openchamber: { kind: 'btw', originalSessionID: 'parent-1', btwBoundaryMessageID: 'msg-1' },
});
});
test('the first question carries the boundary instruction as a synthetic part', async () => {
forkSessionImpl = () => Promise.resolve(makeSession('fork-1', '/project'));
const sentParts: unknown[] = [];