From a0201e17b99ce59dfbd97f96109baeeb165e1ec2 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Sat, 7 Feb 2026 19:08:08 +0900 Subject: [PATCH 1/2] fix: use character limit instead of sentence split for skill description (#358) Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode) Co-authored-by: Sisyphus --- src/agents/atlas/utils.ts | 43 ++++----- src/agents/dynamic-agent-prompt-builder.ts | 19 ++-- src/shared/index.ts | 1 + src/shared/truncate-description.test.ts | 104 +++++++++++++++++++++ src/shared/truncate-description.ts | 19 ++++ src/tools/delegate-task/constants.ts | 20 ++-- 6 files changed, 166 insertions(+), 40 deletions(-) create mode 100644 src/shared/truncate-description.test.ts create mode 100644 src/shared/truncate-description.ts diff --git a/src/agents/atlas/utils.ts b/src/agents/atlas/utils.ts index b83055671..3bed073b9 100644 --- a/src/agents/atlas/utils.ts +++ b/src/agents/atlas/utils.ts @@ -8,21 +8,22 @@ import type { CategoryConfig } from "../../config/schema" import { formatCustomSkillsBlock, type AvailableAgent, type AvailableSkill } from "../dynamic-agent-prompt-builder" import { DEFAULT_CATEGORIES, CATEGORY_DESCRIPTIONS } from "../../tools/delegate-task/constants" +import { truncateDescription } from "../../shared/truncate-description" export const getCategoryDescription = (name: string, userCategories?: Record) => userCategories?.[name]?.description ?? CATEGORY_DESCRIPTIONS[name] ?? "General tasks" export function buildAgentSelectionSection(agents: AvailableAgent[]): string { - if (agents.length === 0) { - return `##### Option B: Use AGENT directly (for specialized experts) + if (agents.length === 0) { + return `##### Option B: Use AGENT directly (for specialized experts) -No agents available.` - } + No agents available.` + } - const rows = agents.map((a) => { - const shortDesc = a.description.split(".")[0] || a.description - return `| \`${a.name}\` | ${shortDesc} |` - }) + const rows = agents.map((a) => { + const shortDesc = truncateDescription(a.description) + return `| \`${a.name}\` | ${shortDesc} |` + }) return `##### Option B: Use AGENT directly (for specialized experts) @@ -59,16 +60,16 @@ export function buildSkillsSection(skills: AvailableSkill[]): string { const builtinSkills = skills.filter((s) => s.location === "plugin") const customSkills = skills.filter((s) => s.location !== "plugin") - const builtinRows = builtinSkills.map((s) => { - const shortDesc = s.description.split(".")[0] || s.description - return `| \`${s.name}\` | ${shortDesc} |` - }) + const builtinRows = builtinSkills.map((s) => { + const shortDesc = truncateDescription(s.description) + return `| \`${s.name}\` | ${shortDesc} |` + }) - const customRows = customSkills.map((s) => { - const shortDesc = s.description.split(".")[0] || s.description - const source = s.location === "project" ? "project" : "user" - return `| \`${s.name}\` | ${shortDesc} | ${source} |` - }) + const customRows = customSkills.map((s) => { + const shortDesc = truncateDescription(s.description) + const source = s.location === "project" ? "project" : "user" + return `| \`${s.name}\` | ${shortDesc} | ${source} |` + }) const customSkillBlock = formatCustomSkillsBlock(customRows, customSkills, "**") @@ -121,10 +122,10 @@ export function buildDecisionMatrix(agents: AvailableAgent[], userCategories?: R `| ${getCategoryDescription(name, userCategories)} | \`category="${name}", load_skills=[...]\` |` ) - const agentRows = agents.map((a) => { - const shortDesc = a.description.split(".")[0] || a.description - return `| ${shortDesc} | \`agent="${a.name}"\` |` - }) + const agentRows = agents.map((a) => { + const shortDesc = truncateDescription(a.description) + return `| ${shortDesc} | \`agent="${a.name}"\` |` + }) return `##### Decision Matrix diff --git a/src/agents/dynamic-agent-prompt-builder.ts b/src/agents/dynamic-agent-prompt-builder.ts index f6bf6e477..c70da062e 100644 --- a/src/agents/dynamic-agent-prompt-builder.ts +++ b/src/agents/dynamic-agent-prompt-builder.ts @@ -1,4 +1,5 @@ import type { AgentPromptMetadata, BuiltinAgentName } from "./types" +import { truncateDescription } from "../shared/truncate-description" export interface AvailableAgent { name: BuiltinAgentName @@ -205,16 +206,16 @@ export function buildCategorySkillsDelegationGuide(categories: AvailableCategory const builtinSkills = skills.filter((s) => s.location === "plugin") const customSkills = skills.filter((s) => s.location !== "plugin") - const builtinRows = builtinSkills.map((s) => { - const desc = s.description.split(".")[0] || s.description - return `| \`${s.name}\` | ${desc} |` - }) + const builtinRows = builtinSkills.map((s) => { + const desc = truncateDescription(s.description) + return `| \`${s.name}\` | ${desc} |` + }) - const customRows = customSkills.map((s) => { - const desc = s.description.split(".")[0] || s.description - const source = s.location === "project" ? "project" : "user" - return `| \`${s.name}\` | ${desc} | ${source} |` - }) + const customRows = customSkills.map((s) => { + const desc = truncateDescription(s.description) + const source = s.location === "project" ? "project" : "user" + return `| \`${s.name}\` | ${desc} | ${source} |` + }) const customSkillBlock = formatCustomSkillsBlock(customRows, customSkills) diff --git a/src/shared/index.ts b/src/shared/index.ts index 44d6b1f58..d42be5a75 100644 --- a/src/shared/index.ts +++ b/src/shared/index.ts @@ -42,3 +42,4 @@ export * from "./model-suggestion-retry" export * from "./opencode-server-auth" export * from "./port-utils" export * from "./safe-create-hook" +export * from "./truncate-description" diff --git a/src/shared/truncate-description.test.ts b/src/shared/truncate-description.test.ts new file mode 100644 index 000000000..f6e6039c6 --- /dev/null +++ b/src/shared/truncate-description.test.ts @@ -0,0 +1,104 @@ +import { describe, it, expect } from "bun:test" +import { truncateDescription } from "./truncate-description" + +describe("truncateDescription", () => { + it("returns description unchanged when under max length", () => { + // given + const description = "This is a short description" + + // when + const result = truncateDescription(description) + + // then + expect(result).toBe(description) + }) + + it("truncates to 120 characters by default and appends ellipsis", () => { + // given + const description = "This is a very long description that exceeds the default maximum length of 120 characters and should be truncated with an ellipsis at the end" + + // when + const result = truncateDescription(description) + + // then + expect(result.length).toBe(123) // 120 + "..." + expect(result).toEndWith("...") + expect(result).toBe(description.slice(0, 120) + "...") + }) + + it("respects custom max length parameter", () => { + // given + const description = "This is a description that is longer than fifty characters" + const maxLength = 50 + + // when + const result = truncateDescription(description, maxLength) + + // then + expect(result.length).toBe(53) // 50 + "..." + expect(result).toEndWith("...") + expect(result).toBe(description.slice(0, 50) + "...") + }) + + it("handles empty string", () => { + // given + const description = "" + + // when + const result = truncateDescription(description) + + // then + expect(result).toBe("") + }) + + it("handles exactly max length without truncation", () => { + // given + const description = "a".repeat(120) + + // when + const result = truncateDescription(description) + + // then + expect(result).toBe(description) + expect(result).not.toEndWith("...") + }) + + it("handles description with periods correctly", () => { + // given + const description = "First sentence. Second sentence. Third sentence that is very long and continues beyond the normal truncation point with even more text to ensure it exceeds 120 characters." + + // when + const result = truncateDescription(description) + + // then + expect(result.length).toBe(123) // 120 + "..." + expect(result).toContain("First sentence. Second sentence.") + expect(result).toEndWith("...") + }) + + it("handles description with URLs correctly", () => { + // given + const description = "Check out https://example.com/very/long/path/that/contains/many/segments for more information about this feature and its capabilities" + + // when + const result = truncateDescription(description) + + // then + expect(result.length).toBe(123) // 120 + "..." + expect(result).toStartWith("Check out https://example.com") + expect(result).toEndWith("...") + }) + + it("handles description with version numbers correctly", () => { + // given + const description = "Version 1.2.3 of the library includes many improvements and bug fixes that make it more stable and performant with additional enhancements" + + // when + const result = truncateDescription(description) + + // then + expect(result.length).toBe(123) // 120 + "..." + expect(result).toStartWith("Version 1.2.3") + expect(result).toEndWith("...") + }) +}) diff --git a/src/shared/truncate-description.ts b/src/shared/truncate-description.ts new file mode 100644 index 000000000..6fe3c4538 --- /dev/null +++ b/src/shared/truncate-description.ts @@ -0,0 +1,19 @@ +/** + * Truncates a description string to a maximum character length. + * If truncated, appends "..." to indicate continuation. + * + * @param description - The description string to truncate + * @param maxLength - Maximum character length (default: 120) + * @returns Truncated description with "..." appended if it was truncated + */ +export function truncateDescription(description: string, maxLength: number = 120): string { + if (!description) { + return description + } + + if (description.length <= maxLength) { + return description + } + + return description.slice(0, maxLength) + "..." +} diff --git a/src/tools/delegate-task/constants.ts b/src/tools/delegate-task/constants.ts index 603a63c67..2bc8f95fd 100644 --- a/src/tools/delegate-task/constants.ts +++ b/src/tools/delegate-task/constants.ts @@ -1,8 +1,9 @@ import type { CategoryConfig } from "../../config/schema" import type { - AvailableCategory, - AvailableSkill, -} from "../../agents/dynamic-agent-prompt-builder" + AvailableCategory, + AvailableSkill, + } from "../../agents/dynamic-agent-prompt-builder" +import { truncateDescription } from "../../shared/truncate-description" export const VISUAL_CATEGORY_PROMPT_APPEND = ` You are working on VISUAL/UI tasks. @@ -492,13 +493,12 @@ function renderPlanAgentCategoryRows(categories: AvailableCategory[]): string[] } function renderPlanAgentSkillRows(skills: AvailableSkill[]): string[] { - const sorted = [...skills].sort((a, b) => a.name.localeCompare(b.name)) - return sorted.map((skill) => { - const firstSentence = skill.description.split(".")[0] || skill.description - const domain = firstSentence.trim() || skill.name - return `| \`${skill.name}\` | ${domain} |` - }) -} + const sorted = [...skills].sort((a, b) => a.name.localeCompare(b.name)) + return sorted.map((skill) => { + const domain = truncateDescription(skill.description).trim() || skill.name + return `| \`${skill.name}\` | ${domain} |` + }) + } export function buildPlanAgentSkillsSection( categories: AvailableCategory[] = [], From f94ae2032cec4c4e9d3aa2cc69d3c5ddbd601d0a Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Sat, 7 Feb 2026 19:13:35 +0900 Subject: [PATCH 2/2] fix: ensure truncated result stays within maxLength limit --- src/shared/truncate-description.test.ts | 14 +++++++------- src/shared/truncate-description.ts | 10 +--------- 2 files changed, 8 insertions(+), 16 deletions(-) diff --git a/src/shared/truncate-description.test.ts b/src/shared/truncate-description.test.ts index f6e6039c6..7645de1cc 100644 --- a/src/shared/truncate-description.test.ts +++ b/src/shared/truncate-description.test.ts @@ -21,9 +21,9 @@ describe("truncateDescription", () => { const result = truncateDescription(description) // then - expect(result.length).toBe(123) // 120 + "..." + expect(result.length).toBe(120) // 117 chars + "..." expect(result).toEndWith("...") - expect(result).toBe(description.slice(0, 120) + "...") + expect(result).toBe(description.slice(0, 117) + "...") }) it("respects custom max length parameter", () => { @@ -35,9 +35,9 @@ describe("truncateDescription", () => { const result = truncateDescription(description, maxLength) // then - expect(result.length).toBe(53) // 50 + "..." + expect(result.length).toBe(50) // 47 chars + "..." expect(result).toEndWith("...") - expect(result).toBe(description.slice(0, 50) + "...") + expect(result).toBe(description.slice(0, 47) + "...") }) it("handles empty string", () => { @@ -71,7 +71,7 @@ describe("truncateDescription", () => { const result = truncateDescription(description) // then - expect(result.length).toBe(123) // 120 + "..." + expect(result.length).toBe(120) expect(result).toContain("First sentence. Second sentence.") expect(result).toEndWith("...") }) @@ -84,7 +84,7 @@ describe("truncateDescription", () => { const result = truncateDescription(description) // then - expect(result.length).toBe(123) // 120 + "..." + expect(result.length).toBe(120) expect(result).toStartWith("Check out https://example.com") expect(result).toEndWith("...") }) @@ -97,7 +97,7 @@ describe("truncateDescription", () => { const result = truncateDescription(description) // then - expect(result.length).toBe(123) // 120 + "..." + expect(result.length).toBe(120) expect(result).toStartWith("Version 1.2.3") expect(result).toEndWith("...") }) diff --git a/src/shared/truncate-description.ts b/src/shared/truncate-description.ts index 6fe3c4538..72fa3d08d 100644 --- a/src/shared/truncate-description.ts +++ b/src/shared/truncate-description.ts @@ -1,11 +1,3 @@ -/** - * Truncates a description string to a maximum character length. - * If truncated, appends "..." to indicate continuation. - * - * @param description - The description string to truncate - * @param maxLength - Maximum character length (default: 120) - * @returns Truncated description with "..." appended if it was truncated - */ export function truncateDescription(description: string, maxLength: number = 120): string { if (!description) { return description @@ -15,5 +7,5 @@ export function truncateDescription(description: string, maxLength: number = 120 return description } - return description.slice(0, maxLength) + "..." + return description.slice(0, maxLength - 3) + "..." }