Skip to content
Merged
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
22 changes: 13 additions & 9 deletions lib/compress/range-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,17 @@ import type {

const BLOCK_PLACEHOLDER_REGEX = /\(b(\d+)\)|\{block_(\d+)\}/gi

export function validateArgs(args: CompressRangeToolArgs): void {
const hasTopLevelTopic = typeof args.topic === "string" && args.topic.trim().length > 0
/** #301 follow-up: topic is optional — derive a short searchable topic from the summary. */
export function deriveFallbackTopic(summary: string): string {
const firstLine =
summary
.split("\n")
.map((line) => line.replace(/^#{1,6}\s*/, "").trim())
.find((line) => line.length > 0) ?? "compressed range"
return firstLine.length > 80 ? `${firstLine.slice(0, 77)}...` : firstLine
}

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")
}
Expand All @@ -35,12 +43,6 @@ export function validateArgs(args: CompressRangeToolArgs): void {
throw new Error(`${prefix}.summary is required and must be a non-empty string`)
}

const hasEntryTopic = typeof entry?.topic === "string" && entry.topic.trim().length > 0
if (!hasEntryTopic && !hasTopLevelTopic) {
throw new Error(
`${prefix} needs a topic — provide ${prefix}.topic or the top-level topic`,
)
}
}
}

Expand All @@ -55,7 +57,9 @@ export function resolveRanges(
topic:
typeof entry.topic === "string" && entry.topic.trim().length > 0
? entry.topic.trim()
: undefined,
: typeof args.topic === "string" && args.topic.trim().length > 0
? undefined
: deriveFallbackTopic(entry.summary),
startId: entry.startId.trim(),
endId: entry.endId.trim(),
summary: entry.summary,
Expand Down
2 changes: 1 addition & 1 deletion lib/compress/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ export interface CompressRangeEntry {
}

export interface CompressRangeToolArgs {
/** Fallback topic for entries without their own. Optional if every entry has one. */
/** Fallback topic for entries without their own. Fully optional — missing topics are derived from the summary. */
topic?: string
content: CompressRangeEntry[]
summaryMaxChars?: number
Expand Down
2 changes: 1 addition & 1 deletion lib/prompts/compress-range.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ Rules:
BATCHING
When multiple independent ranges are ready and their boundaries do not overlap, include all of them as separate entries in the \`content\` array of a single tool call. Each entry should have its own \`startId\`, \`endId\`, and \`summary\`.

When the ranges cover unrelated topics, give each entry its own \`topic\` for better summary quality — do not force unrelated content under a single shared topic. Omit the top-level \`topic\` when every entry has its own. Use the top-level \`topic\` only as a fallback when entries don't specify one.
When the ranges cover unrelated topics, give each entry its own \`topic\` for better summary quality — do not force unrelated content under a single shared topic. The top-level \`topic\` is an optional fallback; when no topic is provided at all, one is derived from the summary's first line automatically.

\`\`\`
compress({ content: [
Expand Down
2 changes: 1 addition & 1 deletion lib/prompts/extensions/tool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ THE FORMAT OF COMPRESS
]
}
\`\`\`
Each entry needs a topic — either its own or the top-level fallback.`
Each entry MAY have a topic. Missing topics fall back to the top-level topic, then to an automatic one derived from the summary.`

export const MESSAGE_FORMAT_EXTENSION = `
THE FORMAT OF COMPRESS
Expand Down
31 changes: 16 additions & 15 deletions tests/batch-compress.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { join } from "node:path"
import { tmpdir } from "node:os"
import { mkdirSync } from "node:fs"
import { createCompressRangeTool } from "../lib/compress/range"
import { validateArgs } from "../lib/compress/range-utils"
import { validateArgs, deriveFallbackTopic } 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 @@ -143,40 +143,41 @@ test("validateArgs: mixed — some entries have topic, others use fallback", ()
assert.doesNotThrow(() => validateArgs(args))
})

test("validateArgs: no topic at all — entry without topic and no fallback", () => {
test("validateArgs: no topic at all — valid, topic is derived from the summary (#301)", () => {
const args = {
content: [
{ startId: "m00001", endId: "m00003", summary: "..." },
],
}
assert.throws(
() => validateArgs(args as CompressRangeToolArgs),
/content\[0\] needs a topic/,
)
assert.doesNotThrow(() => validateArgs(args as CompressRangeToolArgs))
})

test("validateArgs: one entry without topic in a no-topical batch", () => {
test("validateArgs: one entry without topic in a no-topical batch — valid (#301)", () => {
const args = {
content: [
{ topic: "First", startId: "m00001", endId: "m00003", summary: "..." },
{ startId: "m00004", endId: "m00006", summary: "..." },
],
}
assert.throws(
() => validateArgs(args as CompressRangeToolArgs),
/content\[1\] needs a topic/,
)
assert.doesNotThrow(() => validateArgs(args as CompressRangeToolArgs))
})

test("validateArgs: empty top-level topic with entry lacking topic", () => {
test("validateArgs: empty top-level topic with entry lacking topic — valid (#301)", () => {
const args = {
topic: " ",
content: [{ startId: "m00001", endId: "m00003", summary: "..." }],
}
assert.throws(
() => validateArgs(args as CompressRangeToolArgs),
/content\[0\] needs a topic/,
assert.doesNotThrow(() => validateArgs(args as CompressRangeToolArgs))
})

test("deriveFallbackTopic: uses first meaningful line, strips markdown headings, caps length", () => {
assert.equal(
deriveFallbackTopic("## API Gateway 设计\n\n详细内容……"),
"API Gateway 设计",
)
const long = "A".repeat(120)
assert.equal(deriveFallbackTopic(long), `${"A".repeat(77)}...`)
assert.equal(deriveFallbackTopic(""), "compressed range")
})


Expand Down
Loading