From d054cc4de77d8ff72bf3dfab55434343bdb21ccd Mon Sep 17 00:00:00 2001 From: bettil Date: Wed, 22 Apr 2026 22:11:15 +0200 Subject: [PATCH 1/5] feat: improve compacted tool output placeholder message Replace the opaque '[Old tool result content cleared]' placeholder with '[Tool output compacted locally; original in session storage.]' to make it clear that compaction is a local optimization, not a provider failure. This reduces confusion when users see compacted output in their sessions. --- lib/token-utils.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/token-utils.ts b/lib/token-utils.ts index bba07fa1..f1f1460b 100644 --- a/lib/token-utils.ts +++ b/lib/token-utils.ts @@ -80,7 +80,7 @@ export function estimateTokensBatch(texts: string[]): number { return countTokens(texts.join(" ")) } -export const COMPACTED_TOOL_OUTPUT_PLACEHOLDER = "[Old tool result content cleared]" +export const COMPACTED_TOOL_OUTPUT_PLACEHOLDER = "[Tool output compacted locally; original in session storage.]" function stringifyToolContent(value: unknown): string { return typeof value === "string" ? value : JSON.stringify(value) From 8894095299f2b9f77d489870a3c6b826cad62be6 Mon Sep 17 00:00:00 2001 From: bettil Date: Wed, 22 Apr 2026 22:14:01 +0200 Subject: [PATCH 2/5] fix: harden DCP against transform failures and alias exhaustion Apply three defensive patches from 2026-04-16 session hardening: 1. hooks.ts: Wrap createChatMessageTransformHandler body in try/catch so any DCP transform failure degrades safely instead of aborting the session. 2. message-ids.ts: Replace hard-throw on alias exhaustion with graceful degradation (return empty string). This prevents session crashes when message volume exceeds the referenceable range. 3. token-utils.ts: (already committed) Improve compacted tool output placeholder message for clarity. These are fail-open defensive changes that reduce ghost/hard-stop behavior when DCP encounters edge cases. --- lib/hooks.ts | 91 +++++++++++++++++++++++++--------------------- lib/message-ids.ts | 8 ++-- 2 files changed, 54 insertions(+), 45 deletions(-) diff --git a/lib/hooks.ts b/lib/hooks.ts index 92cee31d..57bda815 100644 --- a/lib/hooks.ts +++ b/lib/hooks.ts @@ -104,53 +104,60 @@ export function createChatMessageTransformHandler( hostPermissions: HostPermissionSnapshot, ) { return async (input: {}, output: { messages: WithParts[] }) => { - const receivedMessages = Array.isArray(output.messages) ? output.messages.length : 0 - const messages = filterMessagesInPlace(output.messages) - if (messages.length !== receivedMessages) { - logger.warn("Skipping messages with unexpected shape during chat transform", { - received: receivedMessages, - usable: messages.length, - }) - } + // Fail-open: catch transform failures so DCP bugs do not abort the session. + try { + const receivedMessages = Array.isArray(output.messages) ? output.messages.length : 0 + const messages = filterMessagesInPlace(output.messages) + if (messages.length !== receivedMessages) { + logger.warn("Skipping messages with unexpected shape during chat transform", { + received: receivedMessages, + usable: messages.length, + }) + } - await checkSession(client, state, logger, output.messages, config.manualMode.enabled) + await checkSession(client, state, logger, output.messages, config.manualMode.enabled) - syncCompressPermissionState(state, config, hostPermissions, output.messages) + syncCompressPermissionState(state, config, hostPermissions, output.messages) - if (state.isSubAgent && !config.experimental.allowSubAgents) { - return - } + if (state.isSubAgent && !config.experimental.allowSubAgents) { + return + } - stripHallucinations(output.messages) - cacheSystemPromptTokens(state, output.messages) - assignMessageRefs(state, output.messages) - syncCompressionBlocks(state, logger, output.messages) - syncToolCache(state, config, logger, output.messages) - buildToolIdList(state, output.messages) - prune(state, logger, config, output.messages) - await injectExtendedSubAgentResults( - client, - state, - logger, - output.messages, - config.experimental.allowSubAgents, - ) - const compressionPriorities = buildPriorityMap(config, state, output.messages) - prompts.reload() - injectCompressNudges( - state, - config, - logger, - output.messages, - prompts.getRuntimePrompts(), - compressionPriorities, - ) - injectMessageIds(state, config, output.messages, compressionPriorities) - applyPendingManualTrigger(state, output.messages, logger) - stripStaleMetadata(output.messages) + stripHallucinations(output.messages) + cacheSystemPromptTokens(state, output.messages) + assignMessageRefs(state, output.messages) + syncCompressionBlocks(state, logger, output.messages) + syncToolCache(state, config, logger, output.messages) + buildToolIdList(state, output.messages) + prune(state, logger, config, output.messages) + await injectExtendedSubAgentResults( + client, + state, + logger, + output.messages, + config.experimental.allowSubAgents, + ) + const compressionPriorities = buildPriorityMap(config, state, output.messages) + prompts.reload() + injectCompressNudges( + state, + config, + logger, + output.messages, + prompts.getRuntimePrompts(), + compressionPriorities, + ) + injectMessageIds(state, config, output.messages, compressionPriorities) + applyPendingManualTrigger(state, output.messages, logger) + stripStaleMetadata(output.messages) - if (state.sessionId) { - await logger.saveContext(state.sessionId, output.messages) + if (state.sessionId) { + await logger.saveContext(state.sessionId, output.messages) + } + } catch (err) { + logger.error("DCP chat transform failed; continuing without mutations", { + error: err instanceof Error ? err.message : String(err), + }) } } } diff --git a/lib/message-ids.ts b/lib/message-ids.ts index da003999..13249840 100644 --- a/lib/message-ids.ts +++ b/lib/message-ids.ts @@ -166,7 +166,9 @@ function allocateNextMessageRef(state: SessionState): string { candidate++ } - throw new Error( - `Message ID alias capacity exceeded. Cannot allocate more than ${formatMessageRef(MESSAGE_REF_MAX_INDEX)} aliases in this session.`, - ) + // Graceful degradation: stop allocating aliases instead of hard-throwing. + // This prevents DCP from crashing the session when message volume exceeds + // the referenceable range; downstream features that depend on message refs + // will simply see unassigned messages. + return "" } From f6c9eb5aa663737059fadbd52015dd451fd6befb Mon Sep 17 00:00:00 2001 From: bettil Date: Thu, 7 May 2026 05:17:12 +0200 Subject: [PATCH 3/5] fix: stop invalid message ref assignment on exhaustion --- lib/message-ids.ts | 5 ++++- tests/message-ids.test.ts | 39 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 43 insertions(+), 1 deletion(-) diff --git a/lib/message-ids.ts b/lib/message-ids.ts index 13249840..be2697fc 100644 --- a/lib/message-ids.ts +++ b/lib/message-ids.ts @@ -136,7 +136,7 @@ export function assignMessageRefs(state: SessionState, messages: WithParts[]): n } const existingRef = state.messageIds.byRawId.get(rawMessageId) - if (existingRef) { + if (existingRef !== undefined) { if (state.messageIds.byRef.get(existingRef) !== rawMessageId) { state.messageIds.byRef.set(existingRef, rawMessageId) } @@ -144,6 +144,9 @@ export function assignMessageRefs(state: SessionState, messages: WithParts[]): n } const ref = allocateNextMessageRef(state) + if (!ref) { + break + } state.messageIds.byRawId.set(rawMessageId, ref) state.messageIds.byRef.set(ref, rawMessageId) assigned++ diff --git a/tests/message-ids.test.ts b/tests/message-ids.test.ts index f128b766..05874a5a 100644 --- a/tests/message-ids.test.ts +++ b/tests/message-ids.test.ts @@ -87,3 +87,42 @@ 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) }) + +test("assignMessageRefs stops cleanly when alias capacity is exhausted", () => { + const sessionID = `ses_message_ids_exhausted_${Date.now()}` + const state = createSessionState() + state.messageIds.nextRef = 10000 + + const messages: WithParts[] = [ + { + info: { + id: "msg-a", + role: "assistant", + sessionID, + agent: "assistant", + time: { created: 1 }, + } as WithParts["info"], + parts: [textPart("msg-a", sessionID, "msg-a-part", "A")], + }, + { + info: { + id: "msg-b", + role: "assistant", + sessionID, + agent: "assistant", + time: { created: 2 }, + } as WithParts["info"], + parts: [textPart("msg-b", sessionID, "msg-b-part", "B")], + }, + ] + + const firstAssigned = assignMessageRefs(state, messages) + const secondAssigned = assignMessageRefs(state, messages) + + assert.equal(firstAssigned, 0) + assert.equal(secondAssigned, 0) + assert.equal(state.messageIds.byRawId.size, 0) + assert.equal(state.messageIds.byRef.size, 0) + assert.equal(state.messageIds.byRawId.get("msg-a"), undefined) + assert.equal(state.messageIds.byRawId.get("msg-b"), undefined) +}) From ee340ca93b9baf8511e51a3ca53f6b2025a2327c Mon Sep 17 00:00:00 2001 From: bettil Date: Tue, 12 May 2026 16:41:06 +0200 Subject: [PATCH 4/5] fix: format token utils placeholder --- lib/token-utils.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/token-utils.ts b/lib/token-utils.ts index f1f1460b..9f483964 100644 --- a/lib/token-utils.ts +++ b/lib/token-utils.ts @@ -80,7 +80,8 @@ export function estimateTokensBatch(texts: string[]): number { return countTokens(texts.join(" ")) } -export const COMPACTED_TOOL_OUTPUT_PLACEHOLDER = "[Tool output compacted locally; original in session storage.]" +export const COMPACTED_TOOL_OUTPUT_PLACEHOLDER = + "[Tool output compacted locally; original in session storage.]" function stringifyToolContent(value: unknown): string { return typeof value === "string" ? value : JSON.stringify(value) From 21eff6765d7eb396c868a52141c47c264190dbd2 Mon Sep 17 00:00:00 2001 From: bettil Date: Tue, 12 May 2026 23:36:55 +0200 Subject: [PATCH 5/5] fix: address maintainer transform feedback --- lib/hooks.ts | 52 +++++++++----- lib/message-ids.ts | 11 +-- lib/token-utils.ts | 3 +- tests/hooks-permission.test.ts | 121 +++++++++++++++++++++++++++++++++ tests/message-ids.test.ts | 17 ++--- 5 files changed, 168 insertions(+), 36 deletions(-) diff --git a/lib/hooks.ts b/lib/hooks.ts index 626d39df..e08345ec 100644 --- a/lib/hooks.ts +++ b/lib/hooks.ts @@ -46,6 +46,14 @@ const INTERNAL_AGENT_SIGNATURES = [ "Summarize what was done in this conversation", ] +function cloneMessages(messages: WithParts[]): WithParts[] { + return structuredClone(messages) +} + +function commitMessages(target: WithParts[], source: WithParts[]): void { + target.splice(0, target.length, ...source) +} + export function createSystemPromptHandler( state: SessionState, logger: Logger, @@ -107,8 +115,13 @@ export function createChatMessageTransformHandler( return async (input: {}, output: { messages: WithParts[] }) => { // Fail-open: catch transform failures so DCP bugs do not abort the session. try { - const receivedMessages = Array.isArray(output.messages) ? output.messages.length : 0 - const messages = filterMessagesInPlace(output.messages) + if (!Array.isArray(output.messages)) { + throw new Error("Chat transform output.messages is not an array") + } + + const workingMessages = cloneMessages(output.messages) + const receivedMessages = workingMessages.length + const messages = filterMessagesInPlace(workingMessages) if (messages.length !== receivedMessages) { logger.warn("Skipping messages with unexpected shape during chat transform", { received: receivedMessages, @@ -116,45 +129,48 @@ export function createChatMessageTransformHandler( }) } - await checkSession(client, state, logger, output.messages, config.manualMode.enabled) + await checkSession(client, state, logger, workingMessages, config.manualMode.enabled) - syncCompressPermissionState(state, config, hostPermissions, output.messages) + syncCompressPermissionState(state, config, hostPermissions, workingMessages) if (state.isSubAgent && !config.experimental.allowSubAgents) { + commitMessages(output.messages, workingMessages) return } - stripHallucinations(output.messages) - cacheSystemPromptTokens(state, output.messages) - assignMessageRefs(state, output.messages) - syncCompressionBlocks(state, logger, output.messages) - syncToolCache(state, config, logger, output.messages) - buildToolIdList(state, output.messages) - prune(state, logger, config, output.messages) + stripHallucinations(workingMessages) + cacheSystemPromptTokens(state, workingMessages) + assignMessageRefs(state, workingMessages) + syncCompressionBlocks(state, logger, workingMessages) + syncToolCache(state, config, logger, workingMessages) + buildToolIdList(state, workingMessages) + prune(state, logger, config, workingMessages) await injectExtendedSubAgentResults( client, state, logger, - output.messages, + workingMessages, config.experimental.allowSubAgents, ) - const compressionPriorities = buildPriorityMap(config, state, output.messages) + const compressionPriorities = buildPriorityMap(config, state, workingMessages) prompts.reload() injectCompressNudges( state, config, logger, - output.messages, + workingMessages, prompts.getRuntimePrompts(), compressionPriorities, ) - injectMessageIds(state, config, output.messages, compressionPriorities) - applyPendingManualTrigger(state, output.messages, logger) - stripStaleMetadata(output.messages) + injectMessageIds(state, config, workingMessages, compressionPriorities) + applyPendingManualTrigger(state, workingMessages, logger) + stripStaleMetadata(workingMessages) if (state.sessionId) { - await logger.saveContext(state.sessionId, output.messages) + await logger.saveContext(state.sessionId, workingMessages) } + + commitMessages(output.messages, workingMessages) } catch (err) { logger.error("DCP chat transform failed; continuing without mutations", { error: err instanceof Error ? err.message : String(err), diff --git a/lib/message-ids.ts b/lib/message-ids.ts index be2697fc..2e48f2db 100644 --- a/lib/message-ids.ts +++ b/lib/message-ids.ts @@ -144,9 +144,6 @@ export function assignMessageRefs(state: SessionState, messages: WithParts[]): n } const ref = allocateNextMessageRef(state) - if (!ref) { - break - } state.messageIds.byRawId.set(rawMessageId, ref) state.messageIds.byRef.set(ref, rawMessageId) assigned++ @@ -169,9 +166,7 @@ function allocateNextMessageRef(state: SessionState): string { candidate++ } - // Graceful degradation: stop allocating aliases instead of hard-throwing. - // This prevents DCP from crashing the session when message volume exceeds - // the referenceable range; downstream features that depend on message refs - // will simply see unassigned messages. - return "" + throw new Error( + `Message ID alias capacity exceeded. Cannot allocate more than ${formatMessageRef(MESSAGE_REF_MAX_INDEX)} aliases in this session.`, + ) } diff --git a/lib/token-utils.ts b/lib/token-utils.ts index 9f483964..bba07fa1 100644 --- a/lib/token-utils.ts +++ b/lib/token-utils.ts @@ -80,8 +80,7 @@ export function estimateTokensBatch(texts: string[]): number { return countTokens(texts.join(" ")) } -export const COMPACTED_TOOL_OUTPUT_PLACEHOLDER = - "[Tool output compacted locally; original in session storage.]" +export const COMPACTED_TOOL_OUTPUT_PLACEHOLDER = "[Old tool result content cleared]" function stringifyToolContent(value: unknown): string { return typeof value === "string" ? value : JSON.stringify(value) diff --git a/tests/hooks-permission.test.ts b/tests/hooks-permission.test.ts index 491584c2..a49800ac 100644 --- a/tests/hooks-permission.test.ts +++ b/tests/hooks-permission.test.ts @@ -87,6 +87,10 @@ function buildMessage(id: string, role: "user" | "assistant", text: string): Wit } } +function dcpMessageIdTag(ref = "m0001"): string { + return `<${"dcp-message-id"}>${ref}` +} + test("system prompt handler caches full model context for percentage thresholds", async () => { const state = createSessionState() const handler = createSystemPromptHandler(state, new Logger(false), buildConfig("deny"), { @@ -177,6 +181,123 @@ test("chat message transform drops messages without info instead of crashing", a assert.equal(output.messages.length, 0) }) +test("chat message transform leaves original messages untouched when a late transform fails", async () => { + const state = createSessionState() + const logger = new Logger(false) + let loggedError = "" + logger.error = ((message: string) => { + loggedError = message + return Promise.resolve() + }) as Logger["error"] + const config = buildConfig("allow") + const originalText = `alpha ${dcpMessageIdTag()} omega` + const output = { + messages: [buildMessage("assistant-1", "assistant", originalText)], + } + const originalMessages = output.messages + const handler = createChatMessageTransformHandler( + { session: { get: async () => ({}) } } as any, + state, + logger, + config, + { + reload() { + throw new Error("reload failed") + }, + getRuntimePrompts() { + return {} as any + }, + } as any, + { global: undefined, agents: {} }, + ) + + await handler({}, output) + + assert.equal(output.messages, originalMessages) + assert.equal((output.messages[0]?.parts[0] as any).text, originalText) + assert.equal(loggedError, "DCP chat transform failed; continuing without mutations") +}) + +test("chat message transform leaves original messages untouched when cloning fails", async () => { + const originalStructuredClone = globalThis.structuredClone + const state = createSessionState() + const logger = new Logger(false) + let loggedError = "" + logger.error = ((message: string) => { + loggedError = message + return Promise.resolve() + }) as Logger["error"] + const output = { + messages: [buildMessage("assistant-1", "assistant", "alpha")], + } + const originalMessages = output.messages + + globalThis.structuredClone = (() => { + throw new Error("clone failed") + }) as typeof structuredClone + + try { + const handler = createChatMessageTransformHandler( + { session: { get: async () => ({}) } } as any, + state, + logger, + buildConfig("allow"), + { + reload() {}, + getRuntimePrompts() { + return {} as any + }, + } as any, + { global: undefined, agents: {} }, + ) + + await handler({}, output) + } finally { + globalThis.structuredClone = originalStructuredClone + } + + assert.equal(output.messages, originalMessages) + assert.equal((output.messages[0]?.parts[0] as any).text, "alpha") + assert.equal(loggedError, "DCP chat transform failed; continuing without mutations") +}) + +test("chat message transform commits pre-skip filtering for disabled subagents", async () => { + const state = createSessionState() + state.sessionId = "session-1" + state.isSubAgent = true + const config = buildConfig("allow") + const originalText = `alpha ${dcpMessageIdTag()} omega` + const output = { + messages: [ + { role: "assistant", parts: [] } as any, + buildMessage("assistant-1", "assistant", originalText), + ], + } + const originalMessages = output.messages + const handler = createChatMessageTransformHandler( + { session: { get: async () => ({}) } } as any, + state, + new Logger(false), + config, + { + reload() { + throw new Error("later transforms should not run") + }, + getRuntimePrompts() { + return {} as any + }, + } as any, + { global: undefined, agents: {} }, + ) + + await handler({}, output as any) + + assert.equal(output.messages, originalMessages) + assert.equal(output.messages.length, 1) + assert.equal((output.messages[0]?.parts[0] as any).text, originalText) + assert.equal(state.messageIds.byRawId.size, 0) +}) + test("command execute exits after effective permission resolves to deny", async () => { let sessionMessagesCalls = 0 const output = { parts: [] as any[] } diff --git a/tests/message-ids.test.ts b/tests/message-ids.test.ts index 05874a5a..cefb6e55 100644 --- a/tests/message-ids.test.ts +++ b/tests/message-ids.test.ts @@ -1,7 +1,7 @@ import assert from "node:assert/strict" import test from "node:test" import { Logger } from "../lib/logger" -import { assignMessageRefs } from "../lib/message-ids" +import { assignMessageRefs, MESSAGE_REF_MAX_INDEX, formatMessageRef } from "../lib/message-ids" import { checkSession, createSessionState, type WithParts } from "../lib/state" function textPart(messageID: string, sessionID: string, id: string, text: string) { @@ -88,10 +88,10 @@ test("checkSession resets message id aliases after native compaction", async () assert.equal(state.messageIds.nextRef, 3) }) -test("assignMessageRefs stops cleanly when alias capacity is exhausted", () => { +test("assignMessageRefs throws when alias capacity is exhausted", () => { const sessionID = `ses_message_ids_exhausted_${Date.now()}` const state = createSessionState() - state.messageIds.nextRef = 10000 + state.messageIds.nextRef = MESSAGE_REF_MAX_INDEX + 1 const messages: WithParts[] = [ { @@ -116,11 +116,12 @@ test("assignMessageRefs stops cleanly when alias capacity is exhausted", () => { }, ] - const firstAssigned = assignMessageRefs(state, messages) - const secondAssigned = assignMessageRefs(state, messages) - - assert.equal(firstAssigned, 0) - assert.equal(secondAssigned, 0) + assert.throws( + () => assignMessageRefs(state, messages), + new RegExp( + `Message ID alias capacity exceeded. Cannot allocate more than ${formatMessageRef(MESSAGE_REF_MAX_INDEX)} aliases in this session.`, + ), + ) assert.equal(state.messageIds.byRawId.size, 0) assert.equal(state.messageIds.byRef.size, 0) assert.equal(state.messageIds.byRawId.get("msg-a"), undefined)