Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 38 additions & 5 deletions lib/message-ids.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,19 @@
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*)@$/
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 =
| {
Expand All @@ -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 {
Expand Down Expand Up @@ -124,10 +126,40 @@ export function formatMessageIdTag(
return `\n<${MESSAGE_ID_TAG_NAME}${serializedAttributes}>${ref}</${MESSAGE_ID_TAG_NAME}>`
}

// 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<string>()
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
Expand Down Expand Up @@ -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.`,
)
}
93 changes: 92 additions & 1 deletion tests/message-ids.test.ts
Original file line number Diff line number Diff line change
@@ -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) {
Expand Down Expand Up @@ -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)
})