From 053987c028e9c27bd439e3c66bcabe228749d6bf Mon Sep 17 00:00:00 2001 From: ranxianglei Date: Fri, 14 Aug 2026 23:26:50 +0800 Subject: [PATCH] fix(compress): derive missing topics from the summary instead of erroring (#301) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 'content[0] needs a topic' hard-failed compress calls whose entries had no topic and no top-level fallback — another retry dead-end of the same class as the acknowledgeRisk error. Topics are now optional: an entry without its own topic falls back to the top-level topic, then to one derived from the summary's first line (markdown headings stripped, capped at 80 chars), so search_context still has something useful to match on. - lib/compress/range-utils.ts: drop the topic throw; add deriveFallbackTopic; resolveRanges fills the fallback - lib/compress/types.ts, prompts: topic documented as fully optional - tests: the three throw-cases now assert validity; deriveFallbackTopic unit coverage (heading strip, length cap, empty summary) --- lib/compress/range-utils.ts | 22 +++++++++++++--------- lib/compress/types.ts | 2 +- lib/prompts/compress-range.ts | 2 +- lib/prompts/extensions/tool.ts | 2 +- tests/batch-compress.test.ts | 31 ++++++++++++++++--------------- 5 files changed, 32 insertions(+), 27 deletions(-) diff --git a/lib/compress/range-utils.ts b/lib/compress/range-utils.ts index 3558298f..fa32f9b1 100644 --- a/lib/compress/range-utils.ts +++ b/lib/compress/range-utils.ts @@ -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") } @@ -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`, - ) - } } } @@ -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, diff --git a/lib/compress/types.ts b/lib/compress/types.ts index bddc711e..3e2d9c59 100644 --- a/lib/compress/types.ts +++ b/lib/compress/types.ts @@ -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 diff --git a/lib/prompts/compress-range.ts b/lib/prompts/compress-range.ts index 1a211df2..3183e771 100644 --- a/lib/prompts/compress-range.ts +++ b/lib/prompts/compress-range.ts @@ -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: [ diff --git a/lib/prompts/extensions/tool.ts b/lib/prompts/extensions/tool.ts index f7bf6950..0e98b32b 100644 --- a/lib/prompts/extensions/tool.ts +++ b/lib/prompts/extensions/tool.ts @@ -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 diff --git a/tests/batch-compress.test.ts b/tests/batch-compress.test.ts index 4609f332..470fc8e7 100644 --- a/tests/batch-compress.test.ts +++ b/tests/batch-compress.test.ts @@ -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" @@ -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") })