Files
openchamber/packages/ui/src/lib/sessionBtwMetadata.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

135 lines
5.4 KiB
TypeScript

import type { Session } from '@opencode-ai/sdk/v2';
import { getSessionMetadata, type SessionMetadataRecord } from '@/lib/sessionReviewMetadata';
/**
* Session-metadata contract for the `/btw` flow, mirroring the review-session
* link in `sessionReviewMetadata`:
*
* - The parent (the session `/btw` was typed into) carries
* `openchamber.btwSessionID` pointing at its active btw fork. The panel is
* derived from this link, so it appears only in the parent session and
* survives reloads.
* - The fork itself is marked `openchamber.kind = 'btw'` with
* `originalSessionID` (its parent) and `btwBoundaryMessageID` — the id of
* the last message cloned from the parent. Messages with a greater id are
* the fork's own tail and are what the panel renders. Message ids are
* server-generated ascending identifiers, so the boundary is a plain string
* comparison and immune to client clock skew.
*/
type BtwMetadata = {
kind?: string;
originalSessionID?: string;
btwSessionID?: string;
btwBoundaryMessageID?: string;
btwPromoted?: boolean;
};
const getOpenChamberMetadata = (metadata: SessionMetadataRecord): BtwMetadata => {
const value = metadata.openchamber;
if (!value || typeof value !== 'object' || Array.isArray(value)) return {};
// SAFETY: session metadata is persisted, externally writable data; this is
// its parsing boundary. `BtwMetadata` only declares optional fields and
// every reader re-validates the field it consumes in `nonEmpty`.
return value as BtwMetadata;
};
const nonEmpty = (value: string | undefined): string | null =>
typeof value === 'string' && value.trim().length > 0 ? value : null;
/** The parent's link to its active btw fork, or 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));
/** The fork's back-pointer to the session `/btw` was typed into. */
export const getBtwOriginalSessionID = (session: Session | null | undefined): string | null => {
const openchamber = getOpenChamberMetadata(getSessionMetadata(session));
return openchamber.kind === 'btw' ? nonEmpty(openchamber.originalSessionID) : null;
};
/**
* The id of the last message the fork inherited from the parent. `null` means
* the fork inherited nothing (empty parent) and every message is its own.
*/
export const getBtwBoundaryMessageID = (session: Session | null | undefined): string | null => {
const openchamber = getOpenChamberMetadata(getSessionMetadata(session));
return openchamber.kind === 'btw' ? nonEmpty(openchamber.btwBoundaryMessageID) : null;
};
export const withBtwSessionLink = (
metadata: SessionMetadataRecord,
btwSessionID: string,
): SessionMetadataRecord => ({
...metadata,
openchamber: {
...getOpenChamberMetadata(metadata),
btwSessionID,
},
});
/**
* Mark the fork as a btw session. The fork clones the parent's metadata
* wholesale (including review links or a stale `btwSessionID`), so the
* inherited `openchamber` object is replaced, not merged.
*/
export const withBtwSessionMarker = (
metadata: SessionMetadataRecord,
originalSessionID: string,
boundaryMessageID: string | null,
): SessionMetadataRecord => {
const openchamber: BtwMetadata = { kind: 'btw', originalSessionID };
if (boundaryMessageID) openchamber.btwBoundaryMessageID = boundaryMessageID;
return { ...metadata, openchamber };
};
/**
* 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;
const rest: BtwMetadata = { ...openchamber };
delete rest.kind;
delete rest.originalSessionID;
delete rest.btwBoundaryMessageID;
rest.btwPromoted = true;
return { ...metadata, openchamber: rest };
};
/** Unlink the parent, but only if it still points at this fork. */
export const withoutBtwSessionLink = (
metadata: SessionMetadataRecord,
btwSessionID: string,
): SessionMetadataRecord => {
const openchamber = getOpenChamberMetadata(metadata);
if (openchamber.btwSessionID !== btwSessionID) return metadata;
const rest: BtwMetadata = { ...openchamber };
delete rest.btwSessionID;
const next: SessionMetadataRecord = { ...metadata };
if (Object.keys(rest).length > 0) {
next.openchamber = rest;
} else {
delete next.openchamber;
}
return next;
};