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) +})