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.
This commit is contained in:
dibanez
2026-08-27 13:54:29 +02:00
parent b0083f6e0f
commit 7a95efc4ac
5 changed files with 69 additions and 14 deletions
+22 -8
View File
@@ -21,6 +21,7 @@ type BtwMetadata = {
originalSessionID?: string;
btwSessionID?: string;
btwBoundaryMessageID?: string;
btwPromoted?: boolean;
};
const getOpenChamberMetadata = (metadata: SessionMetadataRecord): BtwMetadata => {
@@ -39,6 +40,18 @@ const nonEmpty = (value: string | undefined): string | null =>
export const getBtwSessionID = (session: Session | null | undefined): string | null =>
nonEmpty(getOpenChamberMetadata(getSessionMetadata(session)).btwSessionID);
/**
* The session was once a btw fork and was promoted to a normal session.
*
* Its transcript still contains the btw boundary instruction on every message
* sent while it was a side conversation, and there is no API to remove a
* message part after the fact. The flag lets the composer send a notice that
* those constraints have been lifted, so they cannot keep steering a session
* that is no longer a side conversation.
*/
export const wasPromotedBtwSession = (session: Session | null | undefined): boolean =>
getOpenChamberMetadata(getSessionMetadata(session)).btwPromoted === true;
export const isBtwSession = (session: Session | null | undefined): boolean =>
getOpenChamberMetadata(getSessionMetadata(session)).kind === 'btw'
&& Boolean(getBtwOriginalSessionID(session));
@@ -84,7 +97,13 @@ export const withBtwSessionMarker = (
return { ...metadata, openchamber };
};
/** Remove the btw marker so a promoted fork becomes a plain session. */
/**
* Remove the btw marker so a promoted fork becomes a plain session.
*
* `btwPromoted` replaces it rather than leaving nothing behind: the btw
* boundary instructions stay in the transcript forever, so the session has to
* remain distinguishable from one that was never a side conversation.
*/
export const withoutBtwSessionMarker = (metadata: SessionMetadataRecord): SessionMetadataRecord => {
const openchamber = getOpenChamberMetadata(metadata);
if (openchamber.kind !== 'btw') return metadata;
@@ -92,13 +111,8 @@ export const withoutBtwSessionMarker = (metadata: SessionMetadataRecord): Sessio
delete rest.kind;
delete rest.originalSessionID;
delete rest.btwBoundaryMessageID;
const next: SessionMetadataRecord = { ...metadata };
if (Object.keys(rest).length > 0) {
next.openchamber = rest;
} else {
delete next.openchamber;
}
return next;
rest.btwPromoted = true;
return { ...metadata, openchamber: rest };
};
/** Unlink the parent, but only if it still points at this fork. */