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
84 changes: 84 additions & 0 deletions lib/compress/args.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
/**
* 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.
*
* `coerceContentArray` does not check the shape of array elements; call sites
* 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
}
const record = value as Record<string, unknown>
return keys.every((key) => typeof record[key] === "string")
}

export function coerceContentArray<T>(
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("{")) {
let parsed: unknown
try {
parsed = JSON.parse(trimmed)
} catch {
parsed = undefined
}
if (Array.isArray(parsed)) {
if (parsed.length === 0) {
throw new Error(NON_EMPTY_ARRAY_ERROR_MESSAGE)
}
return parsed as T[]
}
if (isEntry(parsed)) {
return [parsed]
}
}
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(NON_EMPTY_ARRAY_ERROR_MESSAGE)
}

export interface CompressArgsSpec<TEntry> {
isEntry: (value: unknown) => value is TEntry
contentNoun: string
shapeExample: string
contentGuidance: string
}

export function normalizeCompressArgs<TEntry>(
args: unknown,
spec: CompressArgsSpec<TEntry>,
): { 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<string, unknown>
return {
topic: topic as string,
content: coerceContentArray(content, spec.isEntry, spec.contentGuidance),
}
}
24 changes: 23 additions & 1 deletion lib/compress/message-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 { NON_EMPTY_ARRAY_ERROR_MESSAGE, isStringFields, normalizeCompressArgs } from "./args"
import { resolveAnchorMessageId, resolveBoundaryIds, resolveSelection } from "./search"
import { COMPRESSED_BLOCK_HEADER } from "./state"
import type {
Expand All @@ -12,6 +13,27 @@ import type {
SearchContext,
} from "./types"

const MESSAGE_ENTRY_KEYS = ["messageId", "topic", "summary"] as const

export function isMessageEntry(value: unknown): value is CompressMessageEntry {
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 <dcp-message-id> tag in context, that your summary covers. " +
"A summary string alone does not say which message to replace."

export function normalizeMessageArgs(args: unknown): CompressMessageToolArgs {
return normalizeCompressArgs(args, {
isEntry: isMessageEntry,
contentNoun: "messages",
shapeExample:
'{ "topic": "...", "content": [{ "messageId": "m0001", "topic": "...", "summary": "..." }] }',
contentGuidance: MESSAGE_CONTENT_GUIDANCE,
})
}

interface SkippedIssue {
kind: string
messageId: string
Expand All @@ -33,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++) {
Expand Down
11 changes: 8 additions & 3 deletions lib/compress/message.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -11,7 +17,6 @@ import {
applyCompressionState,
wrapCompressedSummary,
} from "./state"
import type { CompressMessageToolArgs } from "./types"

function buildSchema() {
return {
Expand Down Expand Up @@ -46,7 +51,7 @@ export function createCompressMessageTool(ctx: ToolContext): ReturnType<typeof t
description: runtimePrompts.compressMessage + MESSAGE_FORMAT_EXTENSION,
args: buildSchema(),
async execute(args, toolCtx) {
const input = args as CompressMessageToolArgs
const input = normalizeMessageArgs(args)
validateArgs(input)
const callId =
typeof (toolCtx as unknown as { callID?: unknown }).callID === "string"
Expand Down
25 changes: 24 additions & 1 deletion lib/compress/range-utils.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import type { CompressionBlock, SessionState } from "../state"
import { NON_EMPTY_ARRAY_ERROR_MESSAGE, isStringFields, normalizeCompressArgs } from "./args"
import { resolveAnchorMessageId, resolveBoundaryIds, resolveSelection } from "./search"
import type {
BoundaryReference,
CompressRangeEntry,
CompressRangeToolArgs,
InjectedSummaryResult,
ParsedBlockPlaceholder,
Expand All @@ -11,13 +13,34 @@ 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 {
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 <dcp-message-id> 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 {
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 {
if (typeof args.topic !== "string" || args.topic.trim().length === 0) {
throw new Error("topic is required and must be a non-empty string")
}

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++) {
Expand Down
3 changes: 2 additions & 1 deletion lib/compress/range.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
import {
appendMissingBlockSummaries,
injectBlockPlaceholders,
normalizeRangeArgs,
parseBlockPlaceholders,
resolveRanges,
validateArgs,
Expand Down Expand Up @@ -61,7 +62,7 @@ export function createCompressRangeTool(ctx: ToolContext): ReturnType<typeof too
description: runtimePrompts.compressRange + RANGE_FORMAT_EXTENSION,
args: buildSchema(),
async execute(args, toolCtx) {
const input = args as CompressRangeToolArgs
const input = normalizeRangeArgs(args)
validateArgs(input)
const callId =
typeof (toolCtx as unknown as { callID?: unknown }).callID === "string"
Expand Down
35 changes: 35 additions & 0 deletions tests/compress-message.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { join } from "node:path"
import { tmpdir } from "node:os"
import { mkdirSync } from "node:fs"
import { createCompressMessageTool } from "../lib/compress/message"
import { normalizeMessageArgs, validateArgs } from "../lib/compress/message-utils"
import { createSessionState, type WithParts } from "../lib/state"
import type { PluginConfig } from "../lib/config"
import { Logger } from "../lib/logger"
Expand Down Expand Up @@ -888,3 +889,37 @@ test("compress message mode reports issues when every batch entry is skipped", a

assert.equal(state.prune.messages.blocksById.size, 0)
})

test("compress message normalizes single-object content into an array", () => {
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 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/,
)
})
100 changes: 100 additions & 0 deletions tests/compress-range.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -383,3 +384,102 @@ 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 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."),
(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"),
)
})