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:
@@ -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[] = [];
|
||||
|
||||
Reference in New Issue
Block a user