From 9cc77fff29f97d802f85f1dc54f44a0aec728fe5 Mon Sep 17 00:00:00 2001 From: Fedor Suchkov Date: Sat, 12 Sep 2026 14:55:44 +0300 Subject: [PATCH 1/5] fix: coerce mis-wrapped compress content args and teach format on rejection Some models emit compress 'content' as a plain summary string instead of the required array of entry objects (no array wrapper, no startId/endId). validateArgs then failed with the bare 'content is required and must be a non-empty array' error, so the model had to spend a full extra turn reformatting the call (10 such failures observed in recent opencode sessions, every one this shape). Root cause: model-side arg mis-wrap, not a validation bug. The compressed range is genuinely unknowable from a plain string, so it cannot be defaulted. The fix: - coerce unambiguous mis-wraps at runtime (a single entry object, or a JSON string encoding an entry / entry array) before validation - for the rest (plain strings), return a guiding error that shows the exact expected shape and where to find boundary IDs (mNNNN / bN, from tags in context) Applies to both the range and message compress tools. Regression tests replay the captured failing payload plus the coercion cases. --- lib/compress/args.ts | 43 ++++++++++++++++ lib/compress/message-utils.ts | 33 ++++++++++++ lib/compress/message.ts | 11 ++-- lib/compress/range-utils.ts | 34 +++++++++++++ lib/compress/range.ts | 3 +- tests/compress-message.test.ts | 59 +++++++++++++++++++++ tests/compress-range.test.ts | 93 ++++++++++++++++++++++++++++++++++ 7 files changed, 272 insertions(+), 4 deletions(-) create mode 100644 lib/compress/args.ts diff --git a/lib/compress/args.ts b/lib/compress/args.ts new file mode 100644 index 00000000..606a4723 --- /dev/null +++ b/lib/compress/args.ts @@ -0,0 +1,43 @@ +/** + * Normalizes model-emitted `content` arguments for the compress tools. + * + * Some models emit `content` as a single entry object or a JSON-encoded string + * instead of the required array of entry objects. When the intent is + * unambiguous we coerce it into the array form; when the payload is a plain + * string (a summary with no range boundaries) we throw a guiding error that + * tells the model exactly how to re-send the call. + */ + +export function coerceContentArray( + raw: unknown, + isEntry: (value: unknown) => value is T, + guidance: string, +): T[] { + if (Array.isArray(raw)) { + return raw as T[] + } + + if (typeof raw === "string") { + const trimmed = raw.trim() + if (trimmed.startsWith("[") || trimmed.startsWith("{")) { + try { + const parsed: unknown = JSON.parse(trimmed) + if (Array.isArray(parsed) && parsed.length > 0) { + return parsed as T[] + } + if (isEntry(parsed)) { + return [parsed] + } + } catch { + // Not JSON: fall through to the string guidance error. + } + } + throw new Error(`content must be a JSON array, not a plain string. ${guidance}`) + } + + if (raw !== null && typeof raw === "object" && isEntry(raw)) { + return [raw as T] + } + + throw new Error("content is required and must be a non-empty array") +} diff --git a/lib/compress/message-utils.ts b/lib/compress/message-utils.ts index 1664e424..c7e9a2dd 100644 --- a/lib/compress/message-utils.ts +++ b/lib/compress/message-utils.ts @@ -2,6 +2,7 @@ import type { PluginConfig } from "../config" import type { SessionState } from "../state" import { parseBoundaryId } from "../message-ids" import { isIgnoredUserMessage, isProtectedUserMessage } from "../messages/query" +import { coerceContentArray } from "./args" import { resolveAnchorMessageId, resolveBoundaryIds, resolveSelection } from "./search" import { COMPRESSED_BLOCK_HEADER } from "./state" import type { @@ -12,6 +13,38 @@ import type { SearchContext, } from "./types" +export function isMessageEntry(value: unknown): value is CompressMessageEntry { + if (value === null || typeof value !== "object" || Array.isArray(value)) { + return false + } + const entry = value as Record + return ( + typeof entry.messageId === "string" && + typeof entry.topic === "string" && + typeof entry.summary === "string" + ) +} + +export function normalizeMessageArgs(args: unknown): CompressMessageToolArgs { + if (args === null || typeof args !== "object" || Array.isArray(args)) { + throw new Error( + 'compress takes a JSON object with "topic" (string) and "content" (array of messages). ' + + 'Re-send as: { "topic": "...", "content": [{ "messageId": "m0001", "topic": "...", "summary": "..." }] }', + ) + } + const { topic, content } = args as Record + return { + topic: topic as string, + content: coerceContentArray( + content, + isMessageEntry, + 're-send with content as an array of message objects: [{ "messageId": "m0001", "topic": "...", "summary": "..." }]. ' + + "messageId must be the message ID (mNNNN), visible as a tag in context, that your summary covers. " + + "A summary string alone does not say which message to replace.", + ), + } +} + interface SkippedIssue { kind: string messageId: string diff --git a/lib/compress/message.ts b/lib/compress/message.ts index d6bf8874..cc19b2ae 100644 --- a/lib/compress/message.ts +++ b/lib/compress/message.ts @@ -2,7 +2,13 @@ import { tool } from "@opencode-ai/plugin" import type { ToolContext } from "./types" import { countTokens } from "../token-utils" import { MESSAGE_FORMAT_EXTENSION } from "../prompts/extensions/tool" -import { formatIssues, formatResult, resolveMessages, validateArgs } from "./message-utils" +import { + formatIssues, + formatResult, + normalizeMessageArgs, + resolveMessages, + validateArgs, +} from "./message-utils" import { finalizeSession, prepareSession, type NotificationEntry } from "./pipeline" import { appendProtectedPromptInfo, appendProtectedTools } from "./protected-content" import { @@ -11,7 +17,6 @@ import { applyCompressionState, wrapCompressedSummary, } from "./state" -import type { CompressMessageToolArgs } from "./types" function buildSchema() { return { @@ -46,7 +51,7 @@ export function createCompressMessageTool(ctx: ToolContext): ReturnType + return ( + typeof entry.startId === "string" && + typeof entry.endId === "string" && + typeof entry.summary === "string" + ) +} + +export function normalizeRangeArgs(args: unknown): CompressRangeToolArgs { + if (args === null || typeof args !== "object" || Array.isArray(args)) { + throw new Error( + 'compress takes a JSON object with "topic" (string) and "content" (array of ranges). ' + + 'Re-send as: { "topic": "...", "content": [{ "startId": "m0001", "endId": "m0031", "summary": "..." }] }', + ) + } + const { topic, content } = args as Record + return { + topic: topic as string, + content: coerceContentArray( + content, + isRangeEntry, + 're-send with content as an array of range objects: [{ "startId": "m0001", "endId": "m0031", "summary": "..." }]. ' + + "startId and endId must be the message (mNNNN) or compressed-block (bN) IDs, visible as tags in context, " + + "that bound the range your summary covers. A summary string alone does not say which messages to replace.", + ), + } +} + export function validateArgs(args: CompressRangeToolArgs): void { if (typeof args.topic !== "string" || args.topic.trim().length === 0) { throw new Error("topic is required and must be a non-empty string") diff --git a/lib/compress/range.ts b/lib/compress/range.ts index d320be89..7378aa2c 100644 --- a/lib/compress/range.ts +++ b/lib/compress/range.ts @@ -11,6 +11,7 @@ import { import { appendMissingBlockSummaries, injectBlockPlaceholders, + normalizeRangeArgs, parseBlockPlaceholders, resolveRanges, validateArgs, @@ -61,7 +62,7 @@ export function createCompressRangeTool(ctx: ToolContext): ReturnType { + const input = normalizeMessageArgs({ + topic: "Message fix", + content: { messageId: "m0001", topic: "Label", summary: "Summary text." }, + }) + assert.deepEqual(input.content, [ + { messageId: "m0001", topic: "Label", summary: "Summary text." }, + ]) + assert.doesNotThrow(() => validateArgs(input)) +}) + +test("compress message rejects plain-string content with re-send guidance", () => { + assert.throws( + () => + normalizeMessageArgs({ + topic: "Message fix", + content: "A plain summary without a message id.", + }), + (err: Error) => err.message.includes("JSON array") && err.message.includes("messageId"), + ) +}) + +test("compress message still rejects empty content arrays", () => { + const input = normalizeMessageArgs({ topic: "Message fix", content: [] }) + assert.throws(() => validateArgs(input), /content is required and must be a non-empty array/) +}) + +test("compress message execute rejects the captured string-content payload with guidance", async () => { + const tool = createCompressMessageTool({ + client: {}, + state: createSessionState(), + logger: new Logger(false), + config: buildConfig(), + prompts: { + reload() {}, + getRuntimePrompts() { + return { compressMessage: "", compressRange: "" } + }, + }, + } as any) + + await assert.rejects( + tool.execute( + { + topic: "Closed research notes", + content: "Summary of the research session with no message id.", + }, + { + ask: async () => {}, + metadata: () => {}, + sessionID: "ses_message_string_content_replay", + messageID: "msg-compress-message-string", + }, + ), + (err: Error) => err.message.includes("JSON array") && err.message.includes("messageId"), + ) +}) diff --git a/tests/compress-range.test.ts b/tests/compress-range.test.ts index ff9c7161..a2411ddc 100644 --- a/tests/compress-range.test.ts +++ b/tests/compress-range.test.ts @@ -4,6 +4,7 @@ import { join } from "node:path" import { tmpdir } from "node:os" import { mkdirSync } from "node:fs" import { createCompressRangeTool } from "../lib/compress/range" +import { normalizeRangeArgs, validateArgs } from "../lib/compress/range-utils" import { createSessionState, type WithParts } from "../lib/state" import type { PluginConfig } from "../lib/config" import { Logger } from "../lib/logger" @@ -383,3 +384,95 @@ test("compress range mode rejects overlapping batched ranges", async () => { assert.equal(state.prune.messages.blocksById.size, 0) }) +test("compress range normalizes single-object content into an array", () => { + const input = normalizeRangeArgs({ + topic: "Range fix", + content: { startId: "m0001", endId: "m0002", summary: "Summary text." }, + }) + assert.deepEqual(input.content, [ + { startId: "m0001", endId: "m0002", summary: "Summary text." }, + ]) + assert.doesNotThrow(() => validateArgs(input)) +}) + +test("compress range normalizes JSON-string content into an array", () => { + const arrayInput = normalizeRangeArgs({ + topic: "Range fix", + content: JSON.stringify([{ startId: "m0001", endId: "m0002", summary: "Summary text." }]), + }) + assert.equal(arrayInput.content.length, 1) + assert.equal(arrayInput.content[0].startId, "m0001") + assert.doesNotThrow(() => validateArgs(arrayInput)) + + const objectInput = normalizeRangeArgs({ + topic: "Range fix", + content: JSON.stringify({ startId: "m0003", endId: "m0004", summary: "Another." }), + }) + assert.equal(objectInput.content.length, 1) + assert.equal(objectInput.content[0].endId, "m0004") + assert.doesNotThrow(() => validateArgs(objectInput)) +}) + +test("compress range rejects plain-string content with re-send guidance", () => { + assert.throws( + () => + normalizeRangeArgs({ + topic: "Range fix", + content: "A plain summary without range boundaries.", + }), + (err: Error) => + err.message.includes("JSON array") && + err.message.includes("startId") && + err.message.includes("endId"), + ) +}) + +test("compress range still rejects empty content arrays", () => { + const input = normalizeRangeArgs({ topic: "Range fix", content: [] }) + assert.throws(() => validateArgs(input), /content is required and must be a non-empty array/) +}) + +test("compress range rejects a whole-args string with re-send guidance", () => { + assert.throws( + () => normalizeRangeArgs("Just a summary string."), + (err: Error) => err.message.includes('"topic"') && err.message.includes('"content"'), + ) +}) + +test("compress range execute rejects the captured string-content payload with guidance", async () => { + // Replay of a real-world failure captured from opencode sessions: the model + // sent the summary as a plain `content` string and the tool errored with + // "content is required and must be a non-empty array", forcing a retry. + const tool = createCompressRangeTool({ + client: {}, + state: createSessionState(), + logger: new Logger(false), + config: buildConfig(), + prompts: { + reload() {}, + getRuntimePrompts() { + return { compressRange: "", compressMessage: "" } + }, + }, + } as any) + + await assert.rejects( + tool.execute( + { + topic: "XKBNotFound bug diagnosis (Phases 1-4)", + content: + "User bug report (verbatim intent): `just run` fails — xkbcommon-dl fails to dlopen libxkbcommon.so.0.", + }, + { + ask: async () => {}, + metadata: () => {}, + sessionID: "ses_string_content_replay", + messageID: "msg-compress-string-content", + }, + ), + (err: Error) => + err.message.includes("JSON array") && + err.message.includes("startId") && + err.message.includes("endId"), + ) +}) From f57a0d9049f22ab6eb57dea64d764e34c6dbd1c5 Mon Sep 17 00:00:00 2001 From: Fedor Suchkov Date: Sat, 12 Sep 2026 15:05:25 +0300 Subject: [PATCH 2/5] fix: give a JSON-encoded empty compress content array the right error MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit coerceContentArray treated a "[]" string like an unparseable or plain string and threw "content must be a JSON array, not a plain string" — but the input IS a JSON array, just empty. A model reading that diagnosis gets no signal about the real problem (nothing to compress). An empty parsed array now throws the same "content is required and must be a non-empty array" error a raw [] gets, in both the range and message compress tools. --- lib/compress/args.ts | 20 ++++++++++++-------- tests/compress-message.test.ts | 7 +++++++ tests/compress-range.test.ts | 7 +++++++ 3 files changed, 26 insertions(+), 8 deletions(-) diff --git a/lib/compress/args.ts b/lib/compress/args.ts index 606a4723..3414831d 100644 --- a/lib/compress/args.ts +++ b/lib/compress/args.ts @@ -20,16 +20,20 @@ export function coerceContentArray( if (typeof raw === "string") { const trimmed = raw.trim() if (trimmed.startsWith("[") || trimmed.startsWith("{")) { + let parsed: unknown try { - const parsed: unknown = JSON.parse(trimmed) - if (Array.isArray(parsed) && parsed.length > 0) { - return parsed as T[] - } - if (isEntry(parsed)) { - return [parsed] - } + parsed = JSON.parse(trimmed) } catch { - // Not JSON: fall through to the string guidance error. + parsed = undefined + } + if (Array.isArray(parsed)) { + if (parsed.length === 0) { + throw new Error("content is required and must be a non-empty array") + } + return parsed as T[] + } + if (isEntry(parsed)) { + return [parsed] } } throw new Error(`content must be a JSON array, not a plain string. ${guidance}`) diff --git a/tests/compress-message.test.ts b/tests/compress-message.test.ts index ce94f890..45b234f4 100644 --- a/tests/compress-message.test.ts +++ b/tests/compress-message.test.ts @@ -917,6 +917,13 @@ test("compress message still rejects empty content arrays", () => { assert.throws(() => validateArgs(input), /content is required and must be a non-empty array/) }) +test("compress message rejects a JSON-encoded empty content array with the non-empty error", () => { + assert.throws( + () => normalizeMessageArgs({ topic: "Message fix", content: "[]" }), + /content is required and must be a non-empty array/, + ) +}) + test("compress message execute rejects the captured string-content payload with guidance", async () => { const tool = createCompressMessageTool({ client: {}, diff --git a/tests/compress-range.test.ts b/tests/compress-range.test.ts index a2411ddc..bbcf6e11 100644 --- a/tests/compress-range.test.ts +++ b/tests/compress-range.test.ts @@ -432,6 +432,13 @@ test("compress range still rejects empty content arrays", () => { assert.throws(() => validateArgs(input), /content is required and must be a non-empty array/) }) +test("compress range rejects a JSON-encoded empty content array with the non-empty error", () => { + assert.throws( + () => normalizeRangeArgs({ topic: "Range fix", content: "[]" }), + /content is required and must be a non-empty array/, + ) +}) + test("compress range rejects a whole-args string with re-send guidance", () => { assert.throws( () => normalizeRangeArgs("Just a summary string."), From 1061efae819a061ed1c9f8fc3e617e18e1766941 Mon Sep 17 00:00:00 2001 From: Fedor Suchkov Date: Sat, 12 Sep 2026 15:06:59 +0300 Subject: [PATCH 3/5] refactor: dedupe compress arg guards and normalization into args.ts The range and message tools carried two copies of the same logic: the whole-args shape guard with a re-send example (normalizeRangeArgs / normalizeMessageArgs) and the per-field entry guards (isRangeEntry / isMessageEntry). Extract the shared shape into args.ts, which already owns the content coercion: - isStringFields: plain-object guard with named string fields, used by both entry guards (each now states its field list once) - normalizeCompressArgs: the whole-args guard plus content coercion, parameterized by entry guard, content noun, shape example, and the per-mode re-send guidance Behavior is unchanged; the per-mode guidance strings are preserved verbatim. Also document in args.ts that coerceContentArray leaves element-shape checks to each tool's validateArgs. --- lib/compress/args.ts | 35 ++++++++++++++++++++++++++++ lib/compress/message-utils.ts | 43 +++++++++++++---------------------- lib/compress/range-utils.ts | 43 +++++++++++++---------------------- 3 files changed, 67 insertions(+), 54 deletions(-) diff --git a/lib/compress/args.ts b/lib/compress/args.ts index 3414831d..8136c097 100644 --- a/lib/compress/args.ts +++ b/lib/compress/args.ts @@ -6,8 +6,19 @@ * unambiguous we coerce it into the array form; when the payload is a plain * string (a summary with no range boundaries) we throw a guiding error that * tells the model exactly how to re-send the call. + * + * `coerceContentArray` does not check the shape of array elements; call sites + * chain the tool's `validateArgs`, which reports the specific missing field. */ +export function isStringFields(value: unknown, keys: readonly string[]): boolean { + if (value === null || typeof value !== "object" || Array.isArray(value)) { + return false + } + const record = value as Record + return keys.every((key) => typeof record[key] === "string") +} + export function coerceContentArray( raw: unknown, isEntry: (value: unknown) => value is T, @@ -45,3 +56,27 @@ export function coerceContentArray( throw new Error("content is required and must be a non-empty array") } + +export interface CompressArgsSpec { + isEntry: (value: unknown) => value is TEntry + contentNoun: string + shapeExample: string + contentGuidance: string +} + +export function normalizeCompressArgs( + args: unknown, + spec: CompressArgsSpec, +): { topic: string; content: TEntry[] } { + if (args === null || typeof args !== "object" || Array.isArray(args)) { + throw new Error( + `compress takes a JSON object with "topic" (string) and "content" (array of ${spec.contentNoun}). ` + + `Re-send as: ${spec.shapeExample}`, + ) + } + const { topic, content } = args as Record + return { + topic: topic as string, + content: coerceContentArray(content, spec.isEntry, spec.contentGuidance), + } +} diff --git a/lib/compress/message-utils.ts b/lib/compress/message-utils.ts index c7e9a2dd..1edd34c4 100644 --- a/lib/compress/message-utils.ts +++ b/lib/compress/message-utils.ts @@ -2,7 +2,7 @@ import type { PluginConfig } from "../config" import type { SessionState } from "../state" import { parseBoundaryId } from "../message-ids" import { isIgnoredUserMessage, isProtectedUserMessage } from "../messages/query" -import { coerceContentArray } from "./args" +import { isStringFields, normalizeCompressArgs } from "./args" import { resolveAnchorMessageId, resolveBoundaryIds, resolveSelection } from "./search" import { COMPRESSED_BLOCK_HEADER } from "./state" import type { @@ -13,36 +13,25 @@ import type { SearchContext, } from "./types" +const MESSAGE_ENTRY_KEYS = ["messageId", "topic", "summary"] as const + export function isMessageEntry(value: unknown): value is CompressMessageEntry { - if (value === null || typeof value !== "object" || Array.isArray(value)) { - return false - } - const entry = value as Record - return ( - typeof entry.messageId === "string" && - typeof entry.topic === "string" && - typeof entry.summary === "string" - ) + return isStringFields(value, MESSAGE_ENTRY_KEYS) } +const MESSAGE_CONTENT_GUIDANCE = + 're-send with content as an array of message objects: [{ "messageId": "m0001", "topic": "...", "summary": "..." }]. ' + + "messageId must be the message ID (mNNNN), visible as a tag in context, that your summary covers. " + + "A summary string alone does not say which message to replace." + export function normalizeMessageArgs(args: unknown): CompressMessageToolArgs { - if (args === null || typeof args !== "object" || Array.isArray(args)) { - throw new Error( - 'compress takes a JSON object with "topic" (string) and "content" (array of messages). ' + - 'Re-send as: { "topic": "...", "content": [{ "messageId": "m0001", "topic": "...", "summary": "..." }] }', - ) - } - const { topic, content } = args as Record - return { - topic: topic as string, - content: coerceContentArray( - content, - isMessageEntry, - 're-send with content as an array of message objects: [{ "messageId": "m0001", "topic": "...", "summary": "..." }]. ' + - "messageId must be the message ID (mNNNN), visible as a tag in context, that your summary covers. " + - "A summary string alone does not say which message to replace.", - ), - } + return normalizeCompressArgs(args, { + isEntry: isMessageEntry, + contentNoun: "messages", + shapeExample: + '{ "topic": "...", "content": [{ "messageId": "m0001", "topic": "...", "summary": "..." }] }', + contentGuidance: MESSAGE_CONTENT_GUIDANCE, + }) } interface SkippedIssue { diff --git a/lib/compress/range-utils.ts b/lib/compress/range-utils.ts index 9e0cfb30..4039d1ca 100644 --- a/lib/compress/range-utils.ts +++ b/lib/compress/range-utils.ts @@ -1,5 +1,5 @@ import type { CompressionBlock, SessionState } from "../state" -import { coerceContentArray } from "./args" +import { isStringFields, normalizeCompressArgs } from "./args" import { resolveAnchorMessageId, resolveBoundaryIds, resolveSelection } from "./search" import type { BoundaryReference, @@ -13,36 +13,25 @@ import type { const BLOCK_PLACEHOLDER_REGEX = /\(b(\d+)\)|\{block_(\d+)\}/gi +const RANGE_ENTRY_KEYS = ["startId", "endId", "summary"] as const + export function isRangeEntry(value: unknown): value is CompressRangeEntry { - if (value === null || typeof value !== "object" || Array.isArray(value)) { - return false - } - const entry = value as Record - return ( - typeof entry.startId === "string" && - typeof entry.endId === "string" && - typeof entry.summary === "string" - ) + return isStringFields(value, RANGE_ENTRY_KEYS) } +const RANGE_CONTENT_GUIDANCE = + 're-send with content as an array of range objects: [{ "startId": "m0001", "endId": "m0031", "summary": "..." }]. ' + + "startId and endId must be the message (mNNNN) or compressed-block (bN) IDs, visible as tags in context, " + + "that bound the range your summary covers. A summary string alone does not say which messages to replace." + export function normalizeRangeArgs(args: unknown): CompressRangeToolArgs { - if (args === null || typeof args !== "object" || Array.isArray(args)) { - throw new Error( - 'compress takes a JSON object with "topic" (string) and "content" (array of ranges). ' + - 'Re-send as: { "topic": "...", "content": [{ "startId": "m0001", "endId": "m0031", "summary": "..." }] }', - ) - } - const { topic, content } = args as Record - return { - topic: topic as string, - content: coerceContentArray( - content, - isRangeEntry, - 're-send with content as an array of range objects: [{ "startId": "m0001", "endId": "m0031", "summary": "..." }]. ' + - "startId and endId must be the message (mNNNN) or compressed-block (bN) IDs, visible as tags in context, " + - "that bound the range your summary covers. A summary string alone does not say which messages to replace.", - ), - } + return normalizeCompressArgs(args, { + isEntry: isRangeEntry, + contentNoun: "ranges", + shapeExample: + '{ "topic": "...", "content": [{ "startId": "m0001", "endId": "m0031", "summary": "..." }] }', + contentGuidance: RANGE_CONTENT_GUIDANCE, + }) } export function validateArgs(args: CompressRangeToolArgs): void { From b58bbadba7495e64d348d7721da338bcf354f3bc Mon Sep 17 00:00:00 2001 From: Fedor Suchkov Date: Sat, 12 Sep 2026 15:13:47 +0300 Subject: [PATCH 4/5] refactor: hoist the non-empty-array error message to a shared constant The literal 'content is required and must be a non-empty array' lived in four places: both throw sites in coerceContentArray (args.ts) and both validateArgs copies (range-utils.ts, message-utils.ts). It is a single model-facing message, so it now has one source of truth (NON_EMPTY_ARRAY_ERROR_MESSAGE in args.ts); wording changes are a one-place edit. Behaviour is byte-identical - the wording-pinning tests assert the literal and still pass. --- lib/compress/args.ts | 6 ++++-- lib/compress/message-utils.ts | 4 ++-- lib/compress/range-utils.ts | 4 ++-- 3 files changed, 8 insertions(+), 6 deletions(-) diff --git a/lib/compress/args.ts b/lib/compress/args.ts index 8136c097..16df9abb 100644 --- a/lib/compress/args.ts +++ b/lib/compress/args.ts @@ -11,6 +11,8 @@ * chain the tool's `validateArgs`, which reports the specific missing field. */ +export const NON_EMPTY_ARRAY_ERROR_MESSAGE = "content is required and must be a non-empty array" + export function isStringFields(value: unknown, keys: readonly string[]): boolean { if (value === null || typeof value !== "object" || Array.isArray(value)) { return false @@ -39,7 +41,7 @@ export function coerceContentArray( } if (Array.isArray(parsed)) { if (parsed.length === 0) { - throw new Error("content is required and must be a non-empty array") + throw new Error(NON_EMPTY_ARRAY_ERROR_MESSAGE) } return parsed as T[] } @@ -54,7 +56,7 @@ export function coerceContentArray( return [raw as T] } - throw new Error("content is required and must be a non-empty array") + throw new Error(NON_EMPTY_ARRAY_ERROR_MESSAGE) } export interface CompressArgsSpec { diff --git a/lib/compress/message-utils.ts b/lib/compress/message-utils.ts index 1edd34c4..18c5edd5 100644 --- a/lib/compress/message-utils.ts +++ b/lib/compress/message-utils.ts @@ -2,7 +2,7 @@ import type { PluginConfig } from "../config" import type { SessionState } from "../state" import { parseBoundaryId } from "../message-ids" import { isIgnoredUserMessage, isProtectedUserMessage } from "../messages/query" -import { isStringFields, normalizeCompressArgs } from "./args" +import { NON_EMPTY_ARRAY_ERROR_MESSAGE, isStringFields, normalizeCompressArgs } from "./args" import { resolveAnchorMessageId, resolveBoundaryIds, resolveSelection } from "./search" import { COMPRESSED_BLOCK_HEADER } from "./state" import type { @@ -55,7 +55,7 @@ export function validateArgs(args: CompressMessageToolArgs): void { } if (!Array.isArray(args.content) || args.content.length === 0) { - throw new Error("content is required and must be a non-empty array") + throw new Error(NON_EMPTY_ARRAY_ERROR_MESSAGE) } for (let index = 0; index < args.content.length; index++) { diff --git a/lib/compress/range-utils.ts b/lib/compress/range-utils.ts index 4039d1ca..12202907 100644 --- a/lib/compress/range-utils.ts +++ b/lib/compress/range-utils.ts @@ -1,5 +1,5 @@ import type { CompressionBlock, SessionState } from "../state" -import { isStringFields, normalizeCompressArgs } from "./args" +import { NON_EMPTY_ARRAY_ERROR_MESSAGE, isStringFields, normalizeCompressArgs } from "./args" import { resolveAnchorMessageId, resolveBoundaryIds, resolveSelection } from "./search" import type { BoundaryReference, @@ -40,7 +40,7 @@ export function validateArgs(args: CompressRangeToolArgs): void { } if (!Array.isArray(args.content) || args.content.length === 0) { - throw new Error("content is required and must be a non-empty array") + throw new Error(NON_EMPTY_ARRAY_ERROR_MESSAGE) } for (let index = 0; index < args.content.length; index++) { From d52e5f47b3dd2b366afea4e15337297423c168e6 Mon Sep 17 00:00:00 2001 From: Fedor Suchkov Date: Sat, 12 Sep 2026 15:20:52 +0300 Subject: [PATCH 5/5] test: drop the gold-plated message-mode execute replay test The KISS-lens review of the branch judged the message-mode execute replay test unnecessary: execute() calls normalizeMessageArgs on its first line, so the test verifies call wiring, not behavior, and the plain-string rejection it replays is already pinned by the unit test directly above. The range-mode replay is kept because it embeds the real captured payload from opencode sessions. Behavior unchanged: 114/114 tests, typecheck, prettier, and build all pass. --- tests/compress-message.test.ts | 31 ------------------------------- 1 file changed, 31 deletions(-) diff --git a/tests/compress-message.test.ts b/tests/compress-message.test.ts index 45b234f4..e4529704 100644 --- a/tests/compress-message.test.ts +++ b/tests/compress-message.test.ts @@ -923,34 +923,3 @@ test("compress message rejects a JSON-encoded empty content array with the non-e /content is required and must be a non-empty array/, ) }) - -test("compress message execute rejects the captured string-content payload with guidance", async () => { - const tool = createCompressMessageTool({ - client: {}, - state: createSessionState(), - logger: new Logger(false), - config: buildConfig(), - prompts: { - reload() {}, - getRuntimePrompts() { - return { compressMessage: "", compressRange: "" } - }, - }, - } as any) - - await assert.rejects( - tool.execute( - { - topic: "Closed research notes", - content: "Summary of the research session with no message id.", - }, - { - ask: async () => {}, - metadata: () => {}, - sessionID: "ses_message_string_content_replay", - messageID: "msg-compress-message-string", - }, - ), - (err: Error) => err.message.includes("JSON array") && err.message.includes("messageId"), - ) -})