From f1e7751144978cdb0be7b1dc48ba35fd44a60623 Mon Sep 17 00:00:00 2001 From: Anton Date: Mon, 28 Sep 2026 01:51:31 +0300 Subject: [PATCH] fix(message-ids): widen the alias pool and reclaim dead aliases Closes #549 A session that reached 9,999 messages became permanently unwritable: Message ID alias capacity exceeded. Cannot allocate more than m9999 aliases in this session. Two changes: - The alias pool now spans m0001..m99999. Four digit aliases keep their exact current rendering (m0001, m9999) and only widen past that point, so tags already present in a live session are not rewritten and the provider prompt cache survives the upgrade. - assignMessageRefs now drops aliases whose message is no longer in the conversation. opencode shortens the message list on its own compaction and on /fork and /undo, so the pool previously leaked for the whole session and could be exhausted even with few messages left. Reclaimed aliases are reused. Refs are resolved live through messageIds.byRef and compressed blocks store raw message ids, so reclaiming an alias cannot orphan a block. The exhaustion error now names the workaround instead of only stating the cap. Tests: a 10,028 message conversation, rendering stability across the 9999 boundary, reclamation after the message list shrinks, and reuse afterwards. All four fail on the previous implementation. --- lib/message-ids.ts | 43 +++++++++++++++--- tests/message-ids.test.ts | 93 ++++++++++++++++++++++++++++++++++++++- 2 files changed, 130 insertions(+), 6 deletions(-) diff --git a/lib/message-ids.ts b/lib/message-ids.ts index ad4a220c..9ff31839 100644 --- a/lib/message-ids.ts +++ b/lib/message-ids.ts @@ -1,7 +1,7 @@ import type { SessionState, WithParts } from "./state" import { isIgnoredUserMessage } from "./messages/query" -const MESSAGE_REF_REGEX = /^m(\d{4})$/ +const MESSAGE_REF_REGEX = /^m(\d{4,5})$/ const BLOCK_REF_REGEX = /^b([1-9]\d*)$/ const COMPACT_MESSAGE_REGEX = /^@([1-9]\d*)@$/ const COMPACT_BLOCK_REGEX = /^@b([1-9]\d*)@$/ @@ -9,9 +9,11 @@ const MESSAGE_ID_TAG_NAME = "dcp-message-id" export type IdFormat = "xml" | "compact" -const MESSAGE_REF_WIDTH = 4 +// Aliases render as m0001 and only widen to a fifth digit once the four digit +// space is exhausted, so existing sessions keep byte-identical tags. +const MESSAGE_REF_MIN_WIDTH = 4 const MESSAGE_REF_MIN_INDEX = 1 -export const MESSAGE_REF_MAX_INDEX = 9999 +export const MESSAGE_REF_MAX_INDEX = 99999 export type ParsedBoundaryId = | { @@ -32,7 +34,7 @@ export function formatMessageRef(index: number, format: IdFormat = "xml"): strin } return format === "compact" ? `@${index}@` - : `m${index.toString().padStart(MESSAGE_REF_WIDTH, "0")}` + : `m${index.toString().padStart(Math.max(MESSAGE_REF_MIN_WIDTH, `${index}`.length), "0")}` } export function formatBlockRef(blockId: number, format: IdFormat = "xml"): string { @@ -124,10 +126,40 @@ export function formatMessageIdTag( return `\n<${MESSAGE_ID_TAG_NAME}${serializedAttributes}>${ref}` } +// Aliases are a fixed size resource, but the conversation can shrink: opencode +// drops old messages on its own compaction, and /fork plus /undo rewrite the +// message list. Without reclaiming them the pool leaks for the lifetime of a +// session and can be exhausted even when few messages remain. +function reclaimStaleMessageRefs(state: SessionState, messages: WithParts[]): void { + if (state.messageIds.byRawId.size === 0) { + return + } + + const liveIds = new Set() + for (const message of messages) { + const id = message.info?.id + if (typeof id === "string" && id.length > 0) { + liveIds.add(id) + } + } + + for (const [rawId, ref] of state.messageIds.byRawId) { + if (liveIds.has(rawId)) { + continue + } + if (state.messageIds.byRef.get(ref) === rawId) { + state.messageIds.byRef.delete(ref) + } + state.messageIds.byRawId.delete(rawId) + } +} + export function assignMessageRefs(state: SessionState, messages: WithParts[]): number { let assigned = 0 let skippedSubAgentPrompt = false + reclaimStaleMessageRefs(state, messages) + for (const message of messages) { if (isIgnoredUserMessage(message)) { continue @@ -176,6 +208,7 @@ function allocateNextMessageRef(state: SessionState): string { } throw new Error( - `Message ID alias capacity exceeded. Cannot allocate more than ${formatMessageRef(max, state.idFormat)} aliases in this session.`, + `Message ID alias capacity exceeded. Cannot allocate more than ${formatMessageRef(max, state.idFormat)} aliases in this session. ` + + `Start a new session to continue; removing the plugin from opencode.json also unblocks the current one.`, ) } diff --git a/tests/message-ids.test.ts b/tests/message-ids.test.ts index f128b766..aa83f62d 100644 --- a/tests/message-ids.test.ts +++ b/tests/message-ids.test.ts @@ -1,7 +1,12 @@ import assert from "node:assert/strict" import test from "node:test" import { Logger } from "../lib/logger" -import { assignMessageRefs } from "../lib/message-ids" +import { + assignMessageRefs, + formatMessageRef, + parseMessageRef, + MESSAGE_REF_MAX_INDEX, +} from "../lib/message-ids" import { checkSession, createSessionState, type WithParts } from "../lib/state" function textPart(messageID: string, sessionID: string, id: string, text: string) { @@ -87,3 +92,89 @@ test("checkSession resets message id aliases after native compaction", async () assert.equal(state.messageIds.byRef.get("m0002"), "msg-user-follow-up") assert.equal(state.messageIds.nextRef, 3) }) + +function conversation(sessionID: string, count: number): WithParts[] { + return Array.from({ length: count }, (_, index) => ({ + info: { + id: `msg_${index + 1}`, + role: index % 2 === 0 ? "user" : "assistant", + sessionID, + agent: "build", + model: { providerID: "lab", modelID: "test" }, + time: { created: index + 1 }, + }, + parts: [{ type: "text", text: `body ${index}` }], + })) as WithParts[] +} + +test("a conversation longer than 9999 messages still receives aliases", () => { + const sessionID = `ses_long_${Date.now()}` + const state = createSessionState() + const messages = conversation(sessionID, 10028) + + assert.doesNotThrow(() => assignMessageRefs(state, messages)) + + assert.equal(state.messageIds.byRawId.size, 10028) + assert.equal(state.messageIds.byRawId.get("msg_9999"), "m9999") + assert.equal(state.messageIds.byRawId.get("msg_10000"), "m10000") + assert.equal(state.messageIds.byRawId.get("msg_10028"), "m10028") + assert.equal(state.messageIds.byRef.get("m10028"), "msg_10028") +}) + +test("four digit aliases keep their exact rendering, five digits only appear past 9999", () => { + // Widening the pool must not rewrite tags that already exist in a live + // session, because every rewritten mid-conversation tag breaks the + // provider prompt cache. + assert.equal(formatMessageRef(1), "m0001") + assert.equal(formatMessageRef(9999), "m9999") + assert.equal(formatMessageRef(10000), "m10000") + assert.equal(formatMessageRef(MESSAGE_REF_MAX_INDEX), "m99999") + + for (const ref of ["m0001", "m9999", "m10000", "m99999"]) { + assert.equal(parseMessageRef(ref), Number(ref.slice(1)), ref) + } + for (const invalid of ["m100000", "m000", "mabc", "1000"]) { + assert.equal(parseMessageRef(invalid), null, invalid) + } +}) + +test("aliases for messages that left the conversation are reclaimed", () => { + const sessionID = `ses_reclaim_${Date.now()}` + const state = createSessionState() + const full = conversation(sessionID, 40) + assignMessageRefs(state, full) + assert.equal(state.messageIds.byRawId.size, 40) + + // opencode compaction, /fork and /undo all shorten the message list. + const shortened = full.slice(20) + assignMessageRefs(state, shortened) + + assert.equal(state.messageIds.byRawId.size, 20) + assert.equal(state.messageIds.byRef.size, 20) + assert.equal(state.messageIds.byRef.get("m0001"), undefined) + assert.equal(state.messageIds.byRawId.get("msg_1"), undefined) + assert.equal(state.messageIds.byRef.get("m0021"), "msg_21") + assert.equal(state.messageIds.byRef.get("m0040"), "msg_40") +}) + +test("reclaimed aliases are reused instead of exhausting the pool", () => { + const sessionID = `ses_reuse_${Date.now()}` + const state = createSessionState() + const full = conversation(sessionID, 10028) + assignMessageRefs(state, full) + assert.equal(state.messageIds.nextRef, 10029) + + // The session keeps living past the point where the pool used to be empty. + const live = full.slice(10020) + for (let i = 0; i < 40; i++) { + const next = [ + ...live, + ...conversation(sessionID, 1).map((m) => ({ + ...m, + info: { ...m.info, id: `msg_new_${i}` }, + })), + ] + assert.doesNotThrow(() => assignMessageRefs(state, next)) + } + assert.equal(state.messageIds.byRef.size, state.messageIds.byRawId.size) +})