Files
openchamber/packages/ui/src/lib/btw.test.ts
T
dibanez 7a95efc4ac fix(btw): stop the boundary from outliving the session it applies to
Review catch: `promoteBtwSession` removes the btw metadata, but the
boundary instruction is persisted on every message the session sent while
it was a side conversation, and there is no API to delete a message part
after the fact. A promoted session therefore keeps reading "no sub-agents,
do not touch the workspace" out of its own history, in a session that is
no longer a side conversation.

Promotion cannot delete those lines, so it answers them instead:
`withoutBtwSessionMarker` now leaves `openchamber.btwPromoted`, and the
composer sends `BTW_PROMOTION_NOTICE` with every message in a session
carrying it — stating that the session is now the main thread and the
usual tool, sub-agent and workspace permissions are in force.

It rides with every send for the same reason the boundary does: the
instructions it revokes are re-read on every turn, so a one-shot notice
would lose its position relative to them as the conversation grows.

`btwPromoted` is additive and optional. A session that never went through
`/btw` never has it, and one promoted before this change simply keeps the
old behavior.
2026-08-27 13:54:29 +02:00

307 lines
13 KiB
TypeScript

import { beforeEach, describe, expect, mock, test } from 'bun:test';
import type { Message, Part, Session } from '@opencode-ai/sdk/v2';
let forkSessionImpl: (sessionId: string, messageId?: string, directory?: string | null) => Promise<Session>;
let getSessionMessagesImpl: (id: string, limit?: number, directory?: string | null) => Promise<Array<{ info: Message; parts: Part[] }>>;
let sendMessageImpl: (...args: unknown[]) => Promise<unknown>;
let deleteSessionImpl: (sessionId: string) => Promise<boolean>;
let updateSessionTitleImpl: (sessionId: string, title: string) => Promise<void>;
let patchSessionMetadataImpl: (
sessionId: string,
directory: string | null | undefined,
updater: (metadata: Record<string, unknown>) => Record<string, unknown>,
) => Promise<Session>;
const registeredDirectories: string[] = [];
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: {
forkSession: (sessionId: string, messageId?: string, directory?: string | null) =>
forkSessionImpl(sessionId, messageId, directory),
getSessionMessages: (id: string, limit?: number, directory?: string | null) =>
getSessionMessagesImpl(id, limit, directory),
},
}));
mock.module('@/sync/session-actions', () => ({
waitForConnectionOrThrow: () => Promise.resolve(),
deleteSession: (sessionId: string) => deleteSessionImpl(sessionId),
updateSessionTitle: (sessionId: string, title: string) => updateSessionTitleImpl(sessionId, title),
patchSessionMetadata: (
sessionId: string,
directory: string | null | undefined,
updater: (metadata: Record<string, unknown>) => Record<string, unknown>,
) => patchSessionMetadataImpl(sessionId, directory, updater),
}));
mock.module('@/sync/session-ui-store', () => ({
useSessionUIStore: {
getState: () => ({
sendMessage: (...args: unknown[]) => sendMessageImpl(...args),
setCurrentSession: (sessionId: string) => { currentSessionSwitches.push(sessionId); },
}),
},
}));
mock.module('@/stores/useGlobalSessionsStore', () => ({
useGlobalSessionsStore: { getState: () => ({ upsertSession: (session: unknown) => { upsertedSessions.push(session); } }) },
}));
mock.module('@/sync/sync-refs', () => ({
registerSessionDirectory: (sessionId: string, directory: string) => { registeredDirectories.push(`${sessionId}:${directory}`); },
getSyncMessages: () => parentSyncMessages,
getSyncChildStores: () => ({
children: new Map([['/project', {
getState: () => ({ session: childStoreSessions }),
setState: (patch: { session: Session[] }) => { childStoreSessions.length = 0; childStoreSessions.push(...patch.session); },
}]]),
}),
}));
const { btwSessionTitle, startBtwSession, destroyBtwSession, promoteBtwSession, filterBtwTailMessages, findLastCompletedAssistantMessageID, BTW_BOUNDARY_INSTRUCTION } =
await import('@/lib/btw');
const { useBtwStore } = await import('@/stores/useBtwStore');
const makeSession = (id: string, directory?: string): Session => ({
id,
directory,
title: 'btw: q',
time: { created: Date.now(), updated: Date.now() },
parentID: undefined,
version: 1,
}) as unknown as Session;
const record = (id: string): { info: Message; parts: Part[] } => ({
info: { id, role: 'user', time: { created: 1 } } as unknown as Message,
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',
directory: '/project',
providerID: 'provider',
modelID: 'model',
agent: 'build',
variant: 'v',
};
beforeEach(() => {
registeredDirectories.length = 0;
upsertedSessions.length = 0;
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')]);
sendMessageImpl = () => Promise.resolve();
deleteSessionImpl = () => Promise.resolve(true);
updateSessionTitleImpl = () => Promise.resolve();
patchSessionMetadataImpl = (sessionId, _directory, updater) => {
const result = updater({});
metadataPatches.push({ sessionId, result });
return Promise.resolve(makeSession(sessionId));
};
});
describe('btwSessionTitle', () => {
test('prefixes the question', () => {
expect(btwSessionTitle('wtf is kafka')).toBe('btw: wtf is kafka');
});
});
describe('filterBtwTailMessages', () => {
test('keeps only messages after the boundary id', () => {
const records = [record('msg-1'), record('msg-2'), record('msg-3')];
expect(filterBtwTailMessages(records, 'msg-2').map((r) => r.info.id)).toEqual(['msg-3']);
});
test('a null boundary keeps everything (fork of an empty parent)', () => {
const records = [record('msg-1'), record('msg-2')];
expect(filterBtwTailMessages(records, null)).toBe(records);
});
});
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) => {
expect(sessionId).toBe('parent-1');
expect(messageId).toBe(undefined);
return Promise.resolve(makeSession('fork-1', directory ?? '/project'));
};
let sentText: unknown = null;
let sentOptions: unknown = null;
sendMessageImpl = (...args) => {
sentText = args[0];
sentOptions = args[9];
return Promise.resolve();
};
const session = await startBtwSession(startInput);
expect(session.id).toBe('fork-1');
expect(registeredDirectories).toEqual(['fork-1:/project']);
expect(childStoreSessions.map((s) => s.id)).toEqual(['fork-1']);
expect(sentText).toBe('wtf is kafka');
expect(sentOptions).toEqual({ sessionId: 'fork-1', directory: '/project' });
expect(metadataPatches).toEqual([
{ sessionId: 'fork-1', result: { openchamber: { kind: 'btw', originalSessionID: 'parent-1', btwBoundaryMessageID: 'msg-boundary' } } },
{ sessionId: 'parent-1', result: { openchamber: { btwSessionID: 'fork-1' } } },
]);
// Transient creating flag is cleared once the flow settles.
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[] = [];
sendMessageImpl = (...args) => {
sentParts.push(args[6]);
return Promise.resolve();
};
await startBtwSession(startInput);
expect(sentParts).toEqual([[{ text: BTW_BOUNDARY_INSTRUCTION, synthetic: true }]]);
});
test('an empty parent produces a marker without a boundary', async () => {
forkSessionImpl = () => Promise.resolve(makeSession('fork-1', '/project'));
getSessionMessagesImpl = () => Promise.resolve([]);
await startBtwSession(startInput);
expect(metadataPatches[0]?.result).toEqual({ openchamber: { kind: 'btw', originalSessionID: 'parent-1' } });
});
test('a failed first send unlinks the parent and deletes the fork', async () => {
forkSessionImpl = () => Promise.resolve(makeSession('fork-1', '/project'));
sendMessageImpl = () => Promise.reject(new Error('send failed'));
const deleted: string[] = [];
deleteSessionImpl = (sessionId) => { deleted.push(sessionId); return Promise.resolve(true); };
await expect(startBtwSession(startInput)).rejects.toThrow('send failed');
expect(deleted).toEqual(['fork-1']);
// marker, link, then unlink rollback
expect(metadataPatches.map((p) => p.sessionId)).toEqual(['fork-1', 'parent-1', 'parent-1']);
expect(metadataPatches[2]?.result).toEqual({});
expect(useBtwStore.getState().byParent).toEqual({});
});
test('a failed boundary fetch deletes the fork', async () => {
forkSessionImpl = () => Promise.resolve(makeSession('fork-1', '/project'));
getSessionMessagesImpl = () => Promise.reject(new Error('messages failed'));
const deleted: string[] = [];
deleteSessionImpl = (sessionId) => { deleted.push(sessionId); return Promise.resolve(true); };
await expect(startBtwSession(startInput)).rejects.toThrow('messages failed');
expect(deleted).toEqual(['fork-1']);
expect(metadataPatches).toEqual([]);
});
});
describe('destroyBtwSession', () => {
const ref = { parentSessionId: 'parent-1', btwSessionId: 'fork-1', directory: '/project' };
test('unlinks the parent and deletes the fork', async () => {
const deleted: string[] = [];
deleteSessionImpl = (sessionId) => { deleted.push(sessionId); return Promise.resolve(true); };
expect(await destroyBtwSession(ref)).toBe(true);
expect(metadataPatches).toEqual([{ sessionId: 'parent-1', result: {} }]);
expect(deleted).toEqual(['fork-1']);
expect(useBtwStore.getState().byParent).toEqual({});
});
test('reports an unconfirmed delete and still cleans UI state', async () => {
deleteSessionImpl = () => Promise.resolve(false);
expect(await destroyBtwSession(ref)).toBe(false);
expect(useBtwStore.getState().byParent).toEqual({});
});
test('a failed unlink still attempts the delete', async () => {
patchSessionMetadataImpl = () => Promise.reject(new Error('patch failed'));
const deleted: string[] = [];
deleteSessionImpl = (sessionId) => { deleted.push(sessionId); return Promise.resolve(true); };
expect(await destroyBtwSession(ref)).toBe(true);
expect(deleted).toEqual(['fork-1']);
});
});
describe('promoteBtwSession', () => {
const ref = { parentSessionId: 'parent-1', btwSessionId: 'fork-1', directory: '/project' };
test('unlinks the parent, strips the marker, and navigates to the fork', async () => {
patchSessionMetadataImpl = (sessionId, _directory, updater) => {
const base = sessionId === 'fork-1'
? { openchamber: { kind: 'btw', originalSessionID: 'parent-1', btwBoundaryMessageID: 'msg-1' } }
: { openchamber: { btwSessionID: 'fork-1' } };
const result = updater(base);
metadataPatches.push({ sessionId, result });
return Promise.resolve(makeSession(sessionId));
};
await promoteBtwSession(ref);
expect(metadataPatches).toEqual([
{ sessionId: 'parent-1', result: {} },
// The fork stops being a btw session but stays marked as promoted: its
// transcript still carries the boundary instructions.
{ sessionId: 'fork-1', result: { openchamber: { btwPromoted: true } } },
]);
expect(currentSessionSwitches).toEqual(['fork-1']);
});
test('a failed unlink aborts the promote without navigating', async () => {
patchSessionMetadataImpl = () => Promise.reject(new Error('patch failed'));
await expect(promoteBtwSession(ref)).rejects.toThrow('patch failed');
expect(currentSessionSwitches).toEqual([]);
});
});