@@ -64,6 +64,11 @@ function requireIncludedCounts(prompt: string): [included: number, total: number
6464 return [ Number ( match [ 1 ] ) , Number ( match [ 2 ] ) ] ;
6565}
6666
67+ const COMPACT_OMITTED_NOTICE =
68+ "⚠️ Skills catalog using compact format (descriptions omitted). Run `openclaw skills check` to audit." ;
69+ const COMPACT_SHORTENED_NOTICE =
70+ "⚠️ Skills catalog using compact format (descriptions shortened). Run `openclaw skills check` to audit." ;
71+
6772describe ( "formatSkillsCompact" , ( ) => {
6873 it ( "keeps the full-format XML output aligned with the upstream formatter for visible skills" , ( ) => {
6974 const skills = [
@@ -84,17 +89,33 @@ describe("formatSkillsCompact", () => {
8489 expect ( formatSkillsCompact ( [ ] ) ) . toBe ( "" ) ;
8590 } ) ;
8691
87- it ( "omits description, keeps name and location " , ( ) => {
92+ it ( "keeps compact descriptions with name, location, and version " , ( ) => {
8893 const out = formatSkillsCompact ( [
8994 { ...makeSkill ( "weather" , "Get weather data" ) , promptVersion : "sha256:abc123" } ,
9095 ] ) ;
9196 expect ( out ) . toContain ( "<name>weather</name>" ) ;
97+ expect ( out ) . toContain ( "<description>Get weather data</description>" ) ;
9298 expect ( out ) . toContain ( "<location>/skills/weather/SKILL.md</location>" ) ;
9399 expect ( out ) . toContain ( "<version>sha256:abc123</version>" ) ;
94- expect ( out ) . not . toContain ( "Get weather data" ) ;
100+ } ) ;
101+
102+ it ( "omits descriptions when their compact budget is zero" , ( ) => {
103+ const out = formatSkillsCompact ( [ makeSkill ( "weather" , "Get weather data" ) ] , {
104+ descriptionMaxChars : 0 ,
105+ } ) ;
106+ expect ( out ) . toContain ( "<name>weather</name>" ) ;
95107 expect ( out ) . not . toContain ( "<description>" ) ;
96108 } ) ;
97109
110+ it ( "truncates descriptions without splitting emoji surrogate pairs" , ( ) => {
111+ const out = formatSkillsCompact ( [ makeSkill ( "emoji" , `${ "A" . repeat ( 16 ) } 😀 trailing` ) ] , {
112+ descriptionMaxChars : 20 ,
113+ } ) ;
114+
115+ expect ( out ) . toContain ( `<description>${ "A" . repeat ( 16 ) } ...</description>` ) ;
116+ expect ( out ) . not . toMatch ( / [ \uD800 - \uDFFF ] / u) ;
117+ } ) ;
118+
98119 it ( "renders all passed skills without reapplying visibility policy" , ( ) => {
99120 const hidden : Skill = { ...makeSkill ( "hidden" ) , disableModelInvocation : true } ;
100121 const out = formatSkillsCompact ( [ makeSkill ( "visible" ) , hidden ] ) ;
@@ -108,11 +129,9 @@ describe("formatSkillsCompact", () => {
108129 } ) ;
109130
110131 it ( "is significantly smaller than full format" , ( ) => {
111- const skills = Array . from ( { length : 50 } , ( _ , i ) =>
112- makeSkill ( `skill-${ i } ` , "A moderately long description that takes up space in the prompt" ) ,
113- ) ;
132+ const skills = Array . from ( { length : 50 } , ( _ , i ) => makeSkill ( `skill-${ i } ` , "A" . repeat ( 800 ) ) ) ;
114133 const compact = formatSkillsCompact ( skills ) ;
115- expect ( compact . length ) . toBeLessThan ( 6000 ) ;
134+ expect ( compact . length ) . toBeLessThan ( formatSkillsForPrompt ( skills ) . length / 2 ) ;
116135 } ) ;
117136} ) ;
118137
@@ -157,17 +176,15 @@ describe("applySkillsPromptLimits (via buildWorkspaceSkillsPrompt)", () => {
157176 } ) ;
158177
159178 it ( "tier 2: compact when full exceeds budget but compact fits" , ( ) => {
160- const skills = Array . from ( { length : 20 } , ( _ , i ) => makeSkill ( `skill-${ i } ` , "A" . repeat ( 200 ) ) ) ;
179+ const skills = Array . from ( { length : 20 } , ( _ , i ) => makeSkill ( `skill-${ i } ` , "A" . repeat ( 800 ) ) ) ;
161180 const fullLen = formatSkillsForPrompt ( skills ) . length ;
162181 const compactLen = formatSkillsCompact ( skills ) . length ;
163- const budget = Math . floor ( ( fullLen + compactLen ) / 2 ) ;
164- // Verify preconditions: full exceeds budget, compact fits within overhead-adjusted budget
182+ const budget = `${ COMPACT_SHORTENED_NOTICE } \n${ formatSkillsCompact ( skills ) } ` . length ;
165183 expect ( fullLen ) . toBeGreaterThan ( budget ) ;
166- expect ( compactLen + 150 ) . toBeLessThan ( budget ) ;
184+ expect ( compactLen ) . toBeLessThan ( budget ) ;
167185 const prompt = buildPrompt ( skills , { maxChars : budget } ) ;
168- expect ( prompt ) . not . toContain ( "<description>" ) ;
169- // All skills preserved — distinct message, no "included X of Y"
170- expect ( prompt ) . toContain ( "compact format (descriptions omitted)" ) ;
186+ expect ( prompt ) . toContain ( "<description>" ) ;
187+ expect ( prompt ) . toContain ( "compact format (descriptions shortened)" ) ;
171188 expect ( prompt ) . not . toContain ( "included" ) ;
172189 expect ( prompt ) . toContain ( "skill-0" ) ;
173190 expect ( prompt ) . toContain ( "skill-19" ) ;
@@ -176,43 +193,59 @@ describe("applySkillsPromptLimits (via buildWorkspaceSkillsPrompt)", () => {
176193 it ( "tier 3: compact + binary search when compact also exceeds budget" , ( ) => {
177194 const skills = Array . from ( { length : 100 } , ( _ , i ) => makeSkill ( `skill-${ i } ` , "description" ) ) ;
178195 const prompt = buildPrompt ( skills , { maxChars : 2000 } ) ;
179- expect ( prompt ) . toContain ( "compact format, descriptions omitted" ) ;
180- expect ( prompt ) . not . toContain ( "<description>" ) ;
196+ expect ( prompt ) . toContain ( "compact format" ) ;
181197 expect ( prompt ) . toContain ( "skill-0" ) ;
182198 const [ included , total ] = requireIncludedCounts ( prompt ) ;
183199 expect ( included ) . toBeLessThan ( total ) ;
184200 expect ( total ) . toBe ( skills . length ) ;
185201 expect ( prompt . match ( / < s k i l l > / g) ?. length ?? 0 ) . toBe ( included ) ;
186202 } ) ;
187203
188- it ( "compact preserves all skills where full format would drop some" , ( ) => {
189- const skills = Array . from ( { length : 50 } , ( _ , i ) => makeSkill ( `skill-${ i } ` , "A" . repeat ( 200 ) ) ) ;
190- const compactLen = formatSkillsCompact ( skills ) . length ;
191- const budget = compactLen + 250 ;
192- // Verify precondition: full format must not fit so tier 2 is actually exercised
204+ it ( "preserves every identity before allocating description budget" , ( ) => {
205+ const skills = Array . from ( { length : 50 } , ( _ , i ) => makeSkill ( `skill-${ i } ` , "A" . repeat ( 800 ) ) ) ;
206+ const identityCatalog = formatSkillsCompact ( skills , { descriptionMaxChars : 0 } ) ;
207+ const budget = `${ COMPACT_OMITTED_NOTICE } \n${ identityCatalog } ` . length ;
193208 expect ( formatSkillsForPrompt ( skills ) . length ) . toBeGreaterThan ( budget ) ;
209+
194210 const prompt = buildPrompt ( skills , { maxChars : budget } ) ;
195- // All 50 fit in compact — no truncation, just compact notice
196- expect ( prompt ) . toContain ( "compact format" ) ;
211+
212+ expect ( prompt . length ) . toBeLessThanOrEqual ( budget ) ;
213+ expect ( prompt ) . toContain ( COMPACT_OMITTED_NOTICE ) ;
214+ expect ( prompt ) . not . toContain ( "<description>" ) ;
197215 expect ( prompt ) . not . toContain ( "included" ) ;
198216 expect ( prompt ) . toContain ( "skill-0" ) ;
199217 expect ( prompt ) . toContain ( "skill-49" ) ;
200218 } ) ;
201219
220+ it ( "uses leftover compact budget for descriptions without dropping identities" , ( ) => {
221+ const skills = Array . from ( { length : 8 } , ( _ , i ) => makeSkill ( `skill-${ i } ` , "A" . repeat ( 800 ) ) ) ;
222+ const identityCatalog = formatSkillsCompact ( skills , { descriptionMaxChars : 0 } ) ;
223+ const budget = `${ COMPACT_OMITTED_NOTICE } \n${ identityCatalog } ` . length + 500 ;
224+
225+ const prompt = buildPrompt ( skills , { maxChars : budget } ) ;
226+
227+ expect ( prompt . length ) . toBeLessThanOrEqual ( budget ) ;
228+ expect ( prompt ) . toContain ( COMPACT_SHORTENED_NOTICE ) ;
229+ expect ( prompt ) . toContain ( "<description>" ) ;
230+ expect ( prompt ) . not . toContain ( "included" ) ;
231+ expect ( prompt . match ( / < s k i l l > / g) ) . toHaveLength ( skills . length ) ;
232+ } ) ;
233+
202234 it ( "count truncation + compact: shows included X of Y with compact note" , ( ) => {
203235 // 30 skills but maxCount=10, and full format of 10 exceeds budget
204- const skills = Array . from ( { length : 30 } , ( _ , i ) => makeSkill ( `skill-${ i } ` , "A" . repeat ( 200 ) ) ) ;
236+ const skills = Array . from ( { length : 30 } , ( _ , i ) => makeSkill ( `skill-${ i } ` , "A" . repeat ( 800 ) ) ) ;
205237 const tenSkills = skills . slice ( 0 , 10 ) ;
206238 const fullLen = formatSkillsForPrompt ( tenSkills ) . length ;
207- const compactLen = formatSkillsCompact ( tenSkills ) . length ;
208- const budget = compactLen + 200 ;
239+ const truncatedNotice =
240+ "⚠️ Skills truncated: included 10 of 30 (compact format, descriptions shortened). Run `openclaw skills check` to audit." ;
241+ const budget = `${ truncatedNotice } \n${ formatSkillsCompact ( tenSkills ) } ` . length ;
209242 // Verify precondition: full format of 10 skills exceeds budget
210243 expect ( fullLen ) . toBeGreaterThan ( budget ) ;
211244 const prompt = buildPrompt ( skills , { maxChars : budget , maxCount : 10 } ) ;
212245 // Count-truncated (30→10) AND compact (full format of 10 exceeds budget)
213246 expect ( prompt ) . toContain ( "included 10 of 30" ) ;
214- expect ( prompt ) . toContain ( "compact format, descriptions omitted " ) ;
215- expect ( prompt ) . not . toContain ( "<description>" ) ;
247+ expect ( prompt ) . toContain ( "compact format, descriptions shortened " ) ;
248+ expect ( prompt ) . toContain ( "<description>" ) ;
216249 } ) ;
217250
218251 it ( "extreme budget: even a single compact skill overflows" , ( ) => {
@@ -254,20 +287,6 @@ describe("applySkillsPromptLimits (via buildWorkspaceSkillsPrompt)", () => {
254287 expect ( prompt ) . toContain ( "<description>" ) ;
255288 } ) ;
256289
257- it ( "compact budget reserves space for the warning line" , ( ) => {
258- // Build skills whose compact output exactly equals the char budget.
259- // Without overhead reservation the compact block would fit, but the
260- // warning line prepended by the caller would push the total over budget.
261- const skills = Array . from ( { length : 50 } , ( _ , i ) => makeSkill ( `s-${ i } ` , "A" . repeat ( 200 ) ) ) ;
262- const compactLen = formatSkillsCompact ( skills ) . length ;
263- // Set budget = compactLen + 50 — less than the 150-char overhead reserve.
264- // The function should NOT choose compact-only because the warning wouldn't fit.
265- const prompt = buildPrompt ( skills , { maxChars : compactLen + 50 } ) ;
266- // Should fall through to compact + binary search (some skills dropped)
267- expect ( prompt ) . toContain ( "included" ) ;
268- expect ( prompt ) . not . toContain ( "<description>" ) ;
269- } ) ;
270-
271290 it ( "budget check uses compacted home-dir paths, not canonical paths" , ( ) => {
272291 // Skills with home-dir prefix get compacted (e.g. /home/user/... → ~/...).
273292 // Budget check must use the compacted length, not the longer canonical path.
@@ -277,7 +296,7 @@ describe("applySkillsPromptLimits (via buildWorkspaceSkillsPrompt)", () => {
277296 const skills = Array . from ( { length : 30 } , ( _ , i ) =>
278297 makeSkill (
279298 `skill-${ i } ` ,
280- "A" . repeat ( 200 ) ,
299+ "A" . repeat ( 800 ) ,
281300 `${ home } /.openclaw/workspace/skills/skill-${ i } /SKILL.md` ,
282301 ) ,
283302 ) ;
@@ -286,13 +305,18 @@ describe("applySkillsPromptLimits (via buildWorkspaceSkillsPrompt)", () => {
286305 ...s ,
287306 filePath : s . filePath . replace ( home , "~" ) ,
288307 } ) ) ;
289- const compactedCompactLen = formatSkillsCompact ( compactedSkills ) . length ;
290- const canonicalCompactLen = formatSkillsCompact ( skills ) . length ;
308+ const compactedCompactLen = formatSkillsCompact ( compactedSkills , {
309+ descriptionMaxChars : 0 ,
310+ } ) . length ;
311+ const canonicalCompactLen = formatSkillsCompact ( skills , { descriptionMaxChars : 0 } ) . length ;
291312 // Sanity: canonical paths are longer than compacted paths
292313 expect ( canonicalCompactLen ) . toBeGreaterThan ( compactedCompactLen ) ;
293314 // Set budget between compacted and canonical lengths — only fits if
294315 // budget check uses compacted paths (correct) not canonical (wrong).
295- const budget = Math . floor ( ( compactedCompactLen + canonicalCompactLen ) / 2 ) + 150 ;
316+ const budget =
317+ Math . floor ( ( compactedCompactLen + canonicalCompactLen ) / 2 ) +
318+ COMPACT_OMITTED_NOTICE . length +
319+ 1 ;
296320 const prompt = buildPrompt ( skills , { maxChars : budget } ) ;
297321 // All 30 skills should be preserved in compact form (tier 2, no dropping)
298322 expect ( prompt ) . toContain ( "skill-0" ) ;
0 commit comments