|
|
@@ -23,9 +23,10 @@ import {
|
|
|
appendTaggedSyntheticPart,
|
|
|
appendTrailingVolatileMessage,
|
|
|
createTaggedSyntheticPart,
|
|
|
+ hasTaggedPart,
|
|
|
isTaggedPart,
|
|
|
+ isVolatileTaggedMessage,
|
|
|
stripTaggedContent,
|
|
|
- stripTailBoardContent,
|
|
|
} from '../cache-safe-injection';
|
|
|
import type { MessagePart, MessageWithParts } from '../types';
|
|
|
import { isMessageWithParts, isUserMessageWithParts } from '../types';
|
|
|
@@ -59,6 +60,21 @@ export type RetainedBoardSnapshotState = {
|
|
|
firstRealMessageAnchorKey?: string;
|
|
|
};
|
|
|
|
|
|
+/**
|
|
|
+ * A board the `latest` strategy has already placed (and therefore already sent
|
|
|
+ * to the provider) on a specific anchor message. Replayed byte-identically on
|
|
|
+ * every later request once that anchor is no longer the tail, so a board that
|
|
|
+ * was sent on a message the provider has cached never disappears. `anchorRole`
|
|
|
+ * records how it was placed: a `user` anchor carried the board as a trailing
|
|
|
+ * PART; an `assistant` anchor was followed by a separate synthetic board
|
|
|
+ * message.
|
|
|
+ */
|
|
|
+type RetainedTailBoard = {
|
|
|
+ anchorId: string;
|
|
|
+ anchorRole: string;
|
|
|
+ text: string;
|
|
|
+};
|
|
|
+
|
|
|
// ── State shape ────────────────────────────────────────────────────────
|
|
|
|
|
|
export interface InjectionState {
|
|
|
@@ -77,6 +93,15 @@ export interface InjectionState {
|
|
|
prune(board: { taskIDs(): Set<string> }): void;
|
|
|
};
|
|
|
retainedBoardSnapshots: Map<string, RetainedBoardSnapshotState>;
|
|
|
+ /**
|
|
|
+ * Per-session log of boards the `latest` strategy has placed on real anchor
|
|
|
+ * messages, keyed by anchor message id. Once an anchor is no longer the tail
|
|
|
+ * its board is replayed byte-identically every later request, so a board sent
|
|
|
+ * on a now-mid-history message is never stripped (which would rewrite already
|
|
|
+ * cached bytes and bust the provider prompt-cache prefix from that message
|
|
|
+ * onward - the field bust in dumps 000086->000087).
|
|
|
+ */
|
|
|
+ retainedTailBoards: Map<string, Map<string, RetainedTailBoard>>;
|
|
|
}
|
|
|
|
|
|
// ── Helpers ────────────────────────────────────────────────────────────
|
|
|
@@ -317,53 +342,72 @@ export async function injectBackgroundJobBoard(
|
|
|
): Promise<void> {
|
|
|
const messages = Array.isArray(output.messages) ? output.messages : [];
|
|
|
|
|
|
- if (state.strategy === 'latest') {
|
|
|
- // Strip only the previous request's TAIL board (a trailing tagged part on
|
|
|
- // the last real message, plus any whole synthetic trailing board
|
|
|
- // messages). A tagged board found genuinely mid-history is left untouched:
|
|
|
- // removing it would rewrite already-sent bytes and bust the whole tail.
|
|
|
- stripTailBoardContent(messages, state.metadataKey);
|
|
|
- }
|
|
|
-
|
|
|
if (state.strategy === 'checkpoint-compatible') {
|
|
|
injectCheckpointBoard(state, messages);
|
|
|
return;
|
|
|
}
|
|
|
|
|
|
- // Find the anchor: the last real (non-fully-tagged) message. It decides
|
|
|
- // orchestrator/session eligibility and is where the board attaches so it
|
|
|
- // stays strictly at the tail.
|
|
|
- let anchor: MessageWithParts | undefined;
|
|
|
- for (let i = messages.length - 1; i >= 0; i -= 1) {
|
|
|
- const message = messages[i];
|
|
|
- if (!isMessageWithParts(message)) continue;
|
|
|
- if (
|
|
|
- message.parts.length > 0 &&
|
|
|
- message.parts.every((part) => isTaggedPart(part, state.metadataKey))
|
|
|
- ) {
|
|
|
- continue;
|
|
|
- }
|
|
|
- anchor = message;
|
|
|
- break;
|
|
|
- }
|
|
|
- if (!anchor) return;
|
|
|
+ injectLatestBoard(state, messages);
|
|
|
+}
|
|
|
+
|
|
|
+/**
|
|
|
+ * `latest` strategy: keep exactly one FRESH board on the current tail while
|
|
|
+ * every board already sent on an earlier (now mid-history) message stays put,
|
|
|
+ * byte-identical.
|
|
|
+ *
|
|
|
+ * The board is never persisted, so opencode rebuilds real history board-free
|
|
|
+ * each request. That means the plugin — not storage — must reproduce every
|
|
|
+ * board it previously placed. The prior implementation instead STRIPPED the
|
|
|
+ * old tail's board and re-appended a fresh board on the new tail
|
|
|
+ * (`stripTailBoardContent` + append). Because the old tail was already sent to
|
|
|
+ * the provider WITH its board, dropping it rewrote an already-cached message
|
|
|
+ * and invalidated the provider prompt-cache prefix from that message onward —
|
|
|
+ * the whole tail re-cached every turn (field bust: ses_11145863 dumps
|
|
|
+ * 000086→000087, old-tail user message 1376B→652B as its board vanished).
|
|
|
+ *
|
|
|
+ * Fix (append-only w.r.t. already-sent messages):
|
|
|
+ * 1. Strip the board ONLY from the current tail zone (the tail message's
|
|
|
+ * trailing board part + whole synthetic board messages trailing it). That
|
|
|
+ * zone re-caches every turn, so rewriting it is byte-safe.
|
|
|
+ * 2. Replay every FROZEN board (one placed on a message that is no longer the
|
|
|
+ * tail) byte-identically on its original anchor, so an already-sent board
|
|
|
+ * never disappears.
|
|
|
+ * 3. Add ONE fresh board to the current tail, preserving the #889 placement
|
|
|
+ * (trailing PART on a user tail; separate trailing message on an assistant
|
|
|
+ * tail) so the tail breakpoint still lands on stable content, and record
|
|
|
+ * it so the NEXT request can freeze/replay it once the tail advances.
|
|
|
+ * A board on any earlier message is never mutated or stripped.
|
|
|
+ */
|
|
|
+function injectLatestBoard(state: InjectionState, messages: unknown[]): void {
|
|
|
+ // The current tail anchor: the last real (non-fully-tagged) message. It is
|
|
|
+ // the ONLY message whose board is volatile — the tail re-caches anyway, so
|
|
|
+ // freshening its board is free. Every earlier message was already sent, so
|
|
|
+ // its board must never change.
|
|
|
+ const anchor = findBoardAnchor(messages, state.metadataKey);
|
|
|
+ const anchorId = anchor ? boardAnchorId(anchor) : undefined;
|
|
|
+
|
|
|
+ // Strip the board from the current tail zone only (byte-safe volatile zone).
|
|
|
+ stripCurrentTailBoard(messages, state.metadataKey, anchor);
|
|
|
|
|
|
// Eligibility is driven by the most recent orchestrator user message (the
|
|
|
- // triggering turn), which also guards against injecting on specialist
|
|
|
- // sessions or internal-initiator turns.
|
|
|
+ // triggering turn), which also guards against specialist/internal turns.
|
|
|
const trigger = findTriggeringUserMessage(messages, state.metadataKey);
|
|
|
+ const sessionID = trigger?.info.sessionID;
|
|
|
+
|
|
|
+ // Replay frozen boards even when this turn is ineligible for a fresh board
|
|
|
+ // (internal-initiator turn, empty board): an already-sent board must stay put
|
|
|
+ // regardless of the current turn. The current tail anchor is skipped — its
|
|
|
+ // board is (re)placed fresh below.
|
|
|
+ if (sessionID !== undefined) {
|
|
|
+ replayRetainedTailBoards(state, sessionID, messages, anchorId);
|
|
|
+ }
|
|
|
+
|
|
|
if (!trigger) return;
|
|
|
if (trigger.info.agent && trigger.info.agent !== 'orchestrator') return;
|
|
|
- if (
|
|
|
- !trigger.info.sessionID ||
|
|
|
- !state.shouldManageSession(trigger.info.sessionID)
|
|
|
- ) {
|
|
|
- return;
|
|
|
- }
|
|
|
+ if (!sessionID || !state.shouldManageSession(sessionID)) return;
|
|
|
+ if (!anchor) return;
|
|
|
|
|
|
- const reminder = state.backgroundJobBoard.formatForPrompt(
|
|
|
- trigger.info.sessionID,
|
|
|
- );
|
|
|
+ const reminder = state.backgroundJobBoard.formatForPrompt(sessionID);
|
|
|
if (!reminder) return;
|
|
|
|
|
|
const textPart = trigger.parts.find(
|
|
|
@@ -371,7 +415,7 @@ export async function injectBackgroundJobBoard(
|
|
|
);
|
|
|
if (!textPart || isInternalInitiatorPart(textPart)) return;
|
|
|
|
|
|
- rememberInjectedTerminalJobs(state, trigger.info.sessionID);
|
|
|
+ rememberInjectedTerminalJobs(state, sessionID);
|
|
|
|
|
|
// Placement rules (prompt-cache safety):
|
|
|
//
|
|
|
@@ -391,13 +435,20 @@ export async function injectBackgroundJobBoard(
|
|
|
// message does NOT merge (different role), so the assistant message keeps
|
|
|
// its own readable breakpoint.
|
|
|
//
|
|
|
- // Either way the board never invalidates already-sent bytes: it is never
|
|
|
- // persisted, so the next request rebuilds real history board-free.
|
|
|
+ // Recording the placement under the tail's anchor id lets the NEXT request
|
|
|
+ // (once the tail advances) replay this exact board on this exact message, so
|
|
|
+ // the bytes the provider just cached for this message never change.
|
|
|
+ const recordId = anchorId ?? boardAnchorFallbackId(anchor);
|
|
|
if (anchor.info.role === 'user') {
|
|
|
appendTaggedSyntheticPart(anchor, {
|
|
|
text: reminder,
|
|
|
metadataKey: state.metadataKey,
|
|
|
});
|
|
|
+ rememberTailBoard(state, sessionID, {
|
|
|
+ anchorId: recordId,
|
|
|
+ anchorRole: 'user',
|
|
|
+ text: reminder,
|
|
|
+ });
|
|
|
} else {
|
|
|
appendTrailingVolatileMessage(
|
|
|
messages,
|
|
|
@@ -410,6 +461,167 @@ export async function injectBackgroundJobBoard(
|
|
|
metadataKey: state.metadataKey,
|
|
|
},
|
|
|
);
|
|
|
+ rememberTailBoard(state, sessionID, {
|
|
|
+ anchorId: recordId,
|
|
|
+ anchorRole: 'assistant',
|
|
|
+ text: reminder,
|
|
|
+ });
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+/** The last real (non-fully-tagged) message — the current tail anchor. */
|
|
|
+function findBoardAnchor(
|
|
|
+ messages: unknown[],
|
|
|
+ metadataKey: string,
|
|
|
+): MessageWithParts | undefined {
|
|
|
+ for (let i = messages.length - 1; i >= 0; i -= 1) {
|
|
|
+ const message = messages[i];
|
|
|
+ if (!isMessageWithParts(message)) continue;
|
|
|
+ if (
|
|
|
+ message.parts.length > 0 &&
|
|
|
+ message.parts.every((part) => isTaggedPart(part, metadataKey))
|
|
|
+ ) {
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ return message;
|
|
|
+ }
|
|
|
+ return undefined;
|
|
|
+}
|
|
|
+
|
|
|
+/**
|
|
|
+ * Strip the board ONLY from the current tail zone: whole synthetic board
|
|
|
+ * messages trailing the payload, plus a trailing board part on the current
|
|
|
+ * tail anchor. This is the volatile zone (the tail re-caches every turn), so
|
|
|
+ * rewriting it is byte-safe. A board on any earlier message is untouched —
|
|
|
+ * removing it would rewrite already-sent, already-cached bytes.
|
|
|
+ */
|
|
|
+function stripCurrentTailBoard(
|
|
|
+ messages: unknown[],
|
|
|
+ metadataKey: string,
|
|
|
+ anchor: MessageWithParts | undefined,
|
|
|
+): void {
|
|
|
+ // Drop whole synthetic board messages trailing the payload.
|
|
|
+ let i = messages.length - 1;
|
|
|
+ while (i >= 0) {
|
|
|
+ const message = messages[i];
|
|
|
+ if (!isVolatileTaggedMessage(message, metadataKey)) break;
|
|
|
+ messages.splice(i, 1);
|
|
|
+ i -= 1;
|
|
|
+ }
|
|
|
+
|
|
|
+ // Strip a trailing board part from the current tail anchor only.
|
|
|
+ if (anchor) {
|
|
|
+ anchor.parts = anchor.parts.filter(
|
|
|
+ (part) => !isTaggedPart(part, metadataKey),
|
|
|
+ );
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+/**
|
|
|
+ * A stable id for an anchor message that lacks an `info.id` (test fixtures,
|
|
|
+ * legacy shapes). Derived from role + concatenated REAL text (tagged board
|
|
|
+ * parts excluded) so the id is identical whether or not a board currently
|
|
|
+ * rides on the message — otherwise appending a board would change the id and
|
|
|
+ * defeat cross-request anchor matching.
|
|
|
+ */
|
|
|
+function boardAnchorFallbackId(message: MessageWithParts): string {
|
|
|
+ const text = message.parts
|
|
|
+ .filter(
|
|
|
+ (part) =>
|
|
|
+ !isTaggedPart(part, BACKGROUND_JOB_BOARD_METADATA_KEY) &&
|
|
|
+ part.type === 'text' &&
|
|
|
+ typeof part.text === 'string',
|
|
|
+ )
|
|
|
+ .map((part) => part.text)
|
|
|
+ .join('\u0000');
|
|
|
+ return `anon:${djb2Hash(`${message.info.role}:${text}`)}`;
|
|
|
+}
|
|
|
+
|
|
|
+/** The id used to key a message in the retained-tail-board log. */
|
|
|
+function boardAnchorId(message: MessageWithParts): string {
|
|
|
+ return message.info.id ?? boardAnchorFallbackId(message);
|
|
|
+}
|
|
|
+
|
|
|
+/** Record (or refresh) a board placed on an anchor for later replay. */
|
|
|
+function rememberTailBoard(
|
|
|
+ state: InjectionState,
|
|
|
+ sessionID: string,
|
|
|
+ board: RetainedTailBoard,
|
|
|
+): void {
|
|
|
+ const perSession =
|
|
|
+ state.retainedTailBoards.get(sessionID) ??
|
|
|
+ new Map<string, RetainedTailBoard>();
|
|
|
+ perSession.set(board.anchorId, board);
|
|
|
+ state.retainedTailBoards.set(sessionID, perSession);
|
|
|
+}
|
|
|
+
|
|
|
+/**
|
|
|
+ * Re-append every FROZEN retained board onto its original anchor message,
|
|
|
+ * exactly as first sent, so a board that was sent on a message which is no
|
|
|
+ * longer the tail never disappears (its bytes are already in the provider's
|
|
|
+ * cached prefix).
|
|
|
+ *
|
|
|
+ * The current tail anchor (`currentAnchorId`) is skipped: its board is volatile
|
|
|
+ * and is (re)placed fresh by the caller. Anchors no longer present in history
|
|
|
+ * (compaction, revert) are pruned — their bytes are gone from the provider's
|
|
|
+ * view too. Replay is skipped when the anchor already carries a board, keeping
|
|
|
+ * the operation idempotent under repeated transforms on a shared array.
|
|
|
+ */
|
|
|
+function replayRetainedTailBoards(
|
|
|
+ state: InjectionState,
|
|
|
+ sessionID: string,
|
|
|
+ messages: unknown[],
|
|
|
+ currentAnchorId: string | undefined,
|
|
|
+): void {
|
|
|
+ const perSession = state.retainedTailBoards.get(sessionID);
|
|
|
+ if (!perSession || perSession.size === 0) return;
|
|
|
+
|
|
|
+ const anchorById = new Map<string, MessageWithParts>();
|
|
|
+ for (const message of messages) {
|
|
|
+ if (!isMessageWithParts(message)) continue;
|
|
|
+ if (
|
|
|
+ message.parts.length > 0 &&
|
|
|
+ message.parts.every((part) => isTaggedPart(part, state.metadataKey))
|
|
|
+ ) {
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ anchorById.set(boardAnchorId(message), message);
|
|
|
+ }
|
|
|
+
|
|
|
+ for (const [anchorId, board] of [...perSession.entries()]) {
|
|
|
+ // The current tail's board is volatile — the caller strips and re-appends
|
|
|
+ // it. Never freeze/replay it here.
|
|
|
+ if (anchorId === currentAnchorId) continue;
|
|
|
+
|
|
|
+ const anchor = anchorById.get(anchorId);
|
|
|
+ if (!anchor) {
|
|
|
+ // Anchor gone from history (compaction/revert): its bytes are no longer
|
|
|
+ // in the provider's view, so stop tracking it.
|
|
|
+ perSession.delete(anchorId);
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ if (hasTaggedPart(anchor, state.metadataKey)) continue;
|
|
|
+ if (board.anchorRole === 'user') {
|
|
|
+ appendTaggedSyntheticPart(anchor, {
|
|
|
+ text: board.text,
|
|
|
+ metadataKey: state.metadataKey,
|
|
|
+ });
|
|
|
+ } else {
|
|
|
+ // Assistant anchor: the board was a separate synthetic message that
|
|
|
+ // immediately followed the anchor. Reinsert it right after the anchor so
|
|
|
+ // its position (and therefore the cached byte offset) is reproduced.
|
|
|
+ const index = messages.indexOf(anchor);
|
|
|
+ const boardMessage: MessageWithParts = {
|
|
|
+ info: { ...anchor.info, id: `${anchorId}-background-job-board` },
|
|
|
+ parts: [
|
|
|
+ createTaggedSyntheticPart({
|
|
|
+ text: board.text,
|
|
|
+ metadataKey: state.metadataKey,
|
|
|
+ }),
|
|
|
+ ],
|
|
|
+ };
|
|
|
+ messages.splice(index + 1, 0, boardMessage);
|
|
|
+ }
|
|
|
}
|
|
|
}
|
|
|
|