feat(btw): keep the inherited thread as reference, not an active plan

`/btw` forks the session, so the model receives the parent's whole
conversation — including whatever plan was in flight when the user typed
the command. Nothing tells it that this history is context rather than
its own task, so the fork frequently carries on with the parent's work
instead of answering the side question, which is the opposite of what
`/btw` is for.

Send a boundary instruction as a synthetic part with every message in a
btw session: with the first question in `startBtwSession`, and with each
later send while the panel is expanded and the composer is talking to the
fork.

The wording is deliberately position-independent — it names the history
inherited from the parent thread rather than "everything before this
boundary". The instruction rides along with each send instead of being
pinned once at fork time, so a positional phrasing would be re-anchored
every turn and would end up telling the model to disregard the btw
session's own earlier turns.

The part is synthetic, so it is filtered out of the rendered transcript
whenever the message also carries user text — which is always the case
here. No visual change.
This commit is contained in:
dibanez
2026-08-27 09:21:42 +02:00
parent 2a1c10cb3e
commit 92c3e0d5e5
3 changed files with 55 additions and 4 deletions
@@ -35,7 +35,7 @@ import {
import { ReviewFlowDialog, type ReviewFlowExecution } from '@/components/session/ReviewFlowDialog';
import { BtwPanel } from './btw/BtwPanel';
import { useBtwPanelState } from './btw/useBtwPanelState';
import { destroyBtwSession, startBtwSession, type BtwSessionRef } from '@/lib/btw';
import { BTW_BOUNDARY_INSTRUCTION, destroyBtwSession, startBtwSession, type BtwSessionRef } from '@/lib/btw';
import { AttachedFilesList, AttachedVSCodeFileChips, ActiveEditorFileSuggestion } from './FileAttachment';
import { lazyWithChunkRecovery } from '@/lib/chunkLoadRecovery';
import type { ToolPopupContent } from './message/types';
@@ -1126,7 +1126,13 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
composerText: !queuedOnly && inputSnapshot.hasContent ? inputSnapshot.message : null,
composerAttachments: attachedFiles,
inlineComments: drafts,
syntheticTexts: syntheticParts?.map((part) => part.text) ?? [],
// btw mode: the boundary rides with every send, not just the
// first one, so the inherited transcript stays reference material
// for the whole side conversation.
syntheticTexts: [
...(isBtwActive ? [BTW_BOUNDARY_INSTRUCTION] : []),
...(syntheticParts?.map((part) => part.text) ?? []),
],
linkedIssue: linkedIssue
? { number: linkedIssue.number, title: linkedIssue.title, url: linkedIssue.url, contextText: linkedIssue.contextText }
: null,
+14 -1
View File
@@ -56,7 +56,7 @@ mock.module('@/sync/sync-refs', () => ({
}),
}));
const { btwSessionTitle, startBtwSession, destroyBtwSession, promoteBtwSession, filterBtwTailMessages } =
const { btwSessionTitle, startBtwSession, destroyBtwSession, promoteBtwSession, filterBtwTailMessages, BTW_BOUNDARY_INSTRUCTION } =
await import('@/lib/btw');
const { useBtwStore } = await import('@/stores/useBtwStore');
@@ -151,6 +151,19 @@ describe('startBtwSession', () => {
expect(useBtwStore.getState().byParent).toEqual({});
});
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([]);
+33 -1
View File
@@ -30,6 +30,35 @@ export type StartBtwInput = {
variant?: string;
};
/**
* Sent as a synthetic part with every message inside a btw session.
*
* A btw session is a fork, so the model receives the parent's whole
* conversation including whatever plan was in flight when `/btw` was typed.
* Without this the fork reads that plan as its own active task and carries on
* with it instead of answering the side question, which is the opposite of
* what `/btw` is for.
*
* The wording is deliberately position-independent: it names the history
* inherited from the parent thread rather than "everything before this
* boundary". The instruction rides along with each send instead of being
* pinned once at fork time, so a positional phrasing would be re-anchored
* every turn and would end up telling the model to disregard the btw
* session's own earlier turns.
*/
export const BTW_BOUNDARY_INSTRUCTION = [
'You are in a btw session, a side conversation forked from a main thread.',
'The history inherited from the parent thread is reference context only. It is not your current task.',
'Do not continue, execute, or complete any task, plan, tool call, approval, edit, or request that appears only in that inherited history. Only instructions the user sends inside this btw session are active.',
'Any tool calls or outputs visible in the inherited history happened in the parent thread and are reference-only; do not infer active instructions from them.',
'Sub-agents are off-limits in this btw session. Do not interact with any existing or new sub-agents, even if sub-agents were used in the inherited history.',
'Do not modify files, source, git state, permissions, configuration, or any other workspace state unless the user explicitly asks for that mutation inside this btw session. If they do, keep it minimal, local to the request, and avoid disrupting the main thread.',
].join('\n');
/** The boundary as an `additionalParts` entry for `sendMessage`. */
export const btwBoundaryParts = (): Array<{ text: string; synthetic: true }> =>
[{ text: BTW_BOUNDARY_INSTRUCTION, synthetic: true }];
export const btwSessionTitle = (question: string): string => `btw: ${question}`;
/**
@@ -95,7 +124,10 @@ export async function startBtwSession(input: StartBtwInput): Promise<Session> {
input.agent,
[],
undefined,
undefined,
// The very first question already needs the boundary: the fork is at
// its most dangerous here, with the parent's in-flight plan as the
// newest thing in its context.
btwBoundaryParts(),
input.variant,
'normal',
{ sessionId: forked.id, directory: sessionDirectory },