From 7f40c19434dd1fd30e429bf69d7c1df24c5bed51 Mon Sep 17 00:00:00 2001 From: johnjyang Date: Sun, 26 Jul 2026 20:36:17 -0700 Subject: [PATCH 1/3] fix: shorten overlong Summary titles instead of discarding the generation `spool share` rejected a local-Agent Summary whose front-matter `title` or `title_zh` ran past 96 characters, throwing away the whole generation over the tail of one line. Every reader already bounds titles to 96 characters via `boundTitle`, so the overflow never reached a reader anyway. Repair the front-matter before validation: cut back to a word boundary for space-delimited titles, slice by codepoint otherwise, and mark the cut with an ellipsis. Bodies are left byte-identical, and the CLI reports which title was shortened. Structural defects stay hard failures. Co-Authored-By: Claude Opus 5 (1M context) --- apps/cli/src/commands/share-resume.test.ts | 80 +++++++++++++++++++++- apps/cli/src/commands/share.ts | 71 ++++++++++++++++++- 2 files changed, 147 insertions(+), 4 deletions(-) diff --git a/apps/cli/src/commands/share-resume.test.ts b/apps/cli/src/commands/share-resume.test.ts index 16134a5c..0ea616a7 100644 --- a/apps/cli/src/commands/share-resume.test.ts +++ b/apps/cli/src/commands/share-resume.test.ts @@ -11,7 +11,11 @@ import { import { tmpdir } from 'node:os' import { join, sep } from 'node:path' -import { sequenceRoot, serializePortableSession } from '@spool-lab/session-kit' +import { + parseSummaryFrontMatter, + sequenceRoot, + serializePortableSession, +} from '@spool-lab/session-kit' import Database from 'better-sqlite3' import { describe, expect, it } from 'vite-plus/test' @@ -24,6 +28,7 @@ import { bilingualSummaryValidationError, handleShareCommand, latestSessionUuidFor, + repairOverlongSummaryTitles, } from './share.js' // Command-level round trip against an in-memory hub that implements the @@ -414,6 +419,45 @@ describe('spool share local Agent Summary flow', () => { ).toMatch(/must not repeat the Session title/) }) + it('shortens overlong front-matter titles instead of discarding the Summary', () => { + const longEnglish = + 'Fix the daemon reconnect loop that keeps retrying after macOS sleep and wake, and add regression coverage for the backoff' + const repairedEnglish = repairOverlongSummaryTitles( + BILINGUAL_SUMMARY.replace('title: Rename alpha to beta', `title: ${longEnglish}`), + ) + expect(repairedEnglish.shortened).toEqual(['title']) + expect(bilingualSummaryValidationError(repairedEnglish.summary)).toBeNull() + const englishTitle = parseSummaryFrontMatter(repairedEnglish.summary).titles?.en as string + expect(Array.from(englishTitle).length).toBeLessThanOrEqual(96) + expect(englishTitle).toBe( + 'Fix the daemon reconnect loop that keeps retrying after macOS sleep and wake, and add…', + ) + // Only the front-matter is rewritten; both bodies survive byte for byte. + expect(repairedEnglish.summary.slice(repairedEnglish.summary.indexOf('\n---\n'))).toBe( + BILINGUAL_SUMMARY.slice(BILINGUAL_SUMMARY.indexOf('\n---\n')), + ) + + // Simplified Chinese has no spaces to cut back to, so it slices by codepoint. + const longChinese = '修'.repeat(120) + const repairedChinese = repairOverlongSummaryTitles( + BILINGUAL_SUMMARY.replace('title_zh: 将 alpha 重命名为 beta', `title_zh: ${longChinese}`), + ) + expect(repairedChinese.shortened).toEqual(['title_zh']) + expect(bilingualSummaryValidationError(repairedChinese.summary)).toBeNull() + expect(parseSummaryFrontMatter(repairedChinese.summary).titles?.zh).toBe(`${'修'.repeat(95)}…`) + + // A conforming Summary is returned untouched. + expect(repairOverlongSummaryTitles(BILINGUAL_SUMMARY)).toEqual({ + summary: BILINGUAL_SUMMARY, + shortened: [], + }) + const noFrontMatter = '# Legacy summary\n\nA single-language body.' + expect(repairOverlongSummaryTitles(noFrontMatter)).toEqual({ + summary: noFrontMatter, + shortened: [], + }) + }) + it('checks long closing heading sequences without regex backtracking', () => { const repeatedHeading = `# Rename alpha to beta${' '.repeat(32 * 1024)}###` expect( @@ -621,6 +665,40 @@ describe('spool share local Agent Summary flow', () => { expect(events).toContain('select:Which local Agent should generate the Summary?') }) + it('uploads a Summary whose only defect is an overlong title', async () => { + const hub = makeHub() + const workspace = mkdtempSync(join(tmpdir(), 'spool-summary-long-title-')) + const home = mkdtempSync(join(tmpdir(), 'spool-summary-long-title-home-')) + const filePath = writeFixtureSession(workspace) + const share = shareDeps(hub, workspace, filePath, home) + const events: string[] = [] + const overlong = `Rename alpha to beta across the demo workspace ${'and every downstream caller '.repeat(4)}` + + const exit = await handleShareCommand( + `${SESSION_UUID}@2`, + {}, + { + ...share.deps, + ui: interactiveUi({ selected: 'claude', events }), + detectSummaryAgents: async () => [ + { id: 'claude', name: 'Claude Code', path: '/bin/claude' }, + ], + generateSummary: async () => + BILINGUAL_SUMMARY.replace('title: Rename alpha to beta', `title: ${overlong}`), + }, + ) + + expect(exit).toBe(0) + const stored = hub.sessions.get(`claude_${SESSION_UUID}`)?.summaryMd as string + const titles = parseSummaryFrontMatter(stored).titles + expect(Array.from(titles?.en ?? '').length).toBeLessThanOrEqual(96) + expect(titles?.en).toMatch(/^Rename alpha to beta across the demo workspace .*…$/) + expect(titles?.zh).toBe('将 alpha 重命名为 beta') + expect(stored).toContain('The demo now uses the requested beta name.') + expect(events).toContain('info:Shortened `title` to the 96-character Session title limit.') + expect(events.some((event) => event.startsWith('spinner:error:'))).toBe(false) + }) + it('does not prompt or invoke an Agent when non-interactive visibility is acknowledged', async () => { const hub = makeHub() const workspace = mkdtempSync(join(tmpdir(), 'spool-summary-nontty-')) diff --git a/apps/cli/src/commands/share.ts b/apps/cli/src/commands/share.ts index 8ddf0c59..89efc922 100644 --- a/apps/cli/src/commands/share.ts +++ b/apps/cli/src/commands/share.ts @@ -420,8 +420,9 @@ export async function handleShareCommand( const prompt = dependencies.buildSummaryPrompt?.(target, prepared) ?? buildPreparedSummaryPrompt(target, prepared) - const summary = (await summarize(agent, prompt)).trim() - if (!summary) throw new Error(`${agent.name} returned an empty Summary.`) + const generated = (await summarize(agent, prompt)).trim() + if (!generated) throw new Error(`${agent.name} returned an empty Summary.`) + const { summary, shortened } = repairOverlongSummaryTitles(generated) const invalidSummary = bilingualSummaryValidationError(summary) if (invalidSummary) { throw new Error(`${agent.name} returned an invalid bilingual Summary: ${invalidSummary}`) @@ -436,6 +437,11 @@ export async function handleShareCommand( generation.message(`Uploading Summary objects ${uploaded}/${total}`), }) generation.stop(`Summary generated by ${agent.name} and uploaded`) + if (shortened.length > 0) { + ui.info( + `Shortened ${shortened.map((key) => `\`${key}\``).join(' and ')} to the ${SUMMARY_TITLE_CHAR_LIMIT}-character Session title limit.`, + ) + } ui.outro('Summary uploaded.') return 0 } catch (cause) { @@ -457,13 +463,72 @@ export async function handleShareCommand( } } +export const SUMMARY_TITLE_CHAR_LIMIT = 96 +const SUMMARY_TITLE_KEYS = ['title', 'title_zh'] as const +type SummaryTitleKey = (typeof SUMMARY_TITLE_KEYS)[number] + +/** + * An overlong front-matter title is the one Summary defect worth repairing + * instead of rejecting: every reader already bounds titles to + * `SUMMARY_TITLE_CHAR_LIMIT` characters, so shortening here removes nothing a + * reader would have seen, while a rejection discards a whole local-Agent + * generation — minutes of provider usage — over the tail of one line. + * Structural defects stay hard failures; only the front-matter is rewritten. + */ +export function repairOverlongSummaryTitles(summary: string): { + summary: string + shortened: SummaryTitleKey[] +} { + const lines = summary.split(/\r?\n/) + if (lines[0]?.trim() !== '---') return { summary, shortened: [] } + const closing = lines.findIndex((line, index) => index > 0 && line.trim() === '---') + if (closing === -1) return { summary, shortened: [] } + + const shortened: SummaryTitleKey[] = [] + for (let index = 1; index < closing; index++) { + const match = /^([A-Za-z0-9_-]+)\s*:\s*(.*)$/.exec(lines[index]!.trim()) + if (!match) continue + const key = SUMMARY_TITLE_KEYS.find((candidate) => candidate === match[1]) + if (!key) continue + const value = normalizeSummaryTitle(match[2]!) + if (Array.from(value).length <= SUMMARY_TITLE_CHAR_LIMIT) continue + lines[index] = `${key}: ${shortenSummaryTitle(value)}` + shortened.push(key) + } + + return shortened.length === 0 ? { summary, shortened } : { summary: lines.join('\n'), shortened } +} + +/** Mirrors the parser: drop wrapping quotes and collapse runs of whitespace. */ +function normalizeSummaryTitle(raw: string): string { + return raw + .trim() + .replace(/^["']|["']$/g, '') + .replace(/\s+/g, ' ') + .trim() +} + +/** + * Cut back to a word boundary when the title has one, so a shortened English + * title does not end mid-word. Scripts written without spaces (the usual + * `title_zh` case) fall back to a codepoint slice. The ellipsis keeps the + * result honest about being cut. + */ +function shortenSummaryTitle(value: string): string { + const budget = SUMMARY_TITLE_CHAR_LIMIT - 1 + const clipped = Array.from(value).slice(0, budget).join('') + const lastSpace = clipped.lastIndexOf(' ') + const bounded = lastSpace >= Math.floor(budget / 2) ? clipped.slice(0, lastSpace) : clipped + return `${bounded.replace(/[\s\p{P}]+$/u, '')}…` +} + export function bilingualSummaryValidationError(summary: string): string | null { if (Buffer.byteLength(summary, 'utf8') > 64 * 1024) { return 'the UTF-8 document exceeds 64 KiB' } const parsed = parseSummaryFrontMatter(summary) if (parsed.titleOverflow) { - return '`title` and `title_zh` must each be at most 96 characters' + return `\`title\` and \`title_zh\` must each be at most ${SUMMARY_TITLE_CHAR_LIMIT} characters` } if (!parsed.titles?.en || !parsed.titles.zh) { return 'both `title` and `title_zh` are required in leading front-matter' From 6b2a04438b9a2ccd3cba23861cd38d1d145da4ea Mon Sep 17 00:00:00 2001 From: Xinyao Date: Mon, 27 Jul 2026 21:01:02 +0800 Subject: [PATCH 2/3] fix: preserve summary validation invariants --- apps/cli/src/commands/share-resume.test.ts | 90 ++++++++++++++- apps/cli/src/commands/share.ts | 89 ++++----------- packages/session-kit/src/index.ts | 14 ++- packages/session-kit/src/summary.ts | 127 +++++++++++++++++---- 4 files changed, 227 insertions(+), 93 deletions(-) diff --git a/apps/cli/src/commands/share-resume.test.ts b/apps/cli/src/commands/share-resume.test.ts index 0ea616a7..aeda5ba2 100644 --- a/apps/cli/src/commands/share-resume.test.ts +++ b/apps/cli/src/commands/share-resume.test.ts @@ -13,6 +13,7 @@ import { join, sep } from 'node:path' import { parseSummaryFrontMatter, + repairOverlongSummaryTitles, sequenceRoot, serializePortableSession, } from '@spool-lab/session-kit' @@ -28,7 +29,6 @@ import { bilingualSummaryValidationError, handleShareCommand, latestSessionUuidFor, - repairOverlongSummaryTitles, } from './share.js' // Command-level round trip against an in-memory hub that implements the @@ -450,14 +450,37 @@ describe('spool share local Agent Summary flow', () => { expect(repairOverlongSummaryTitles(BILINGUAL_SUMMARY)).toEqual({ summary: BILINGUAL_SUMMARY, shortened: [], + sourceTitles: { + en: 'Rename alpha to beta', + zh: '将 alpha 重命名为 beta', + }, }) const noFrontMatter = '# Legacy summary\n\nA single-language body.' expect(repairOverlongSummaryTitles(noFrontMatter)).toEqual({ summary: noFrontMatter, shortened: [], + sourceTitles: null, }) }) + it('preserves Summary body bytes when shortening a CRLF front-matter title', () => { + const body = [ + '', + '', + 'English body.', + '', + '', + '', + '中文正文。', + '', + ].join('\r\n') + const source = `---\r\ntitle: ${'a'.repeat(120)}\r\ntitle_zh: 中文标题\r\n---\r\n${body}` + + const repaired = repairOverlongSummaryTitles(source) + + expect(repaired.summary.slice(repaired.summary.indexOf('\r\n---\r\n') + 7)).toBe(body) + }) + it('checks long closing heading sequences without regex backtracking', () => { const repeatedHeading = `# Rename alpha to beta${' '.repeat(32 * 1024)}###` expect( @@ -699,6 +722,71 @@ describe('spool share local Agent Summary flow', () => { expect(events.some((event) => event.startsWith('spinner:error:'))).toBe(false) }) + it('rejects an Agent Summary that exceeded 64 KiB before title repair', async () => { + const hub = makeHub() + const workspace = mkdtempSync(join(tmpdir(), 'spool-summary-too-large-')) + const home = mkdtempSync(join(tmpdir(), 'spool-summary-too-large-home-')) + const filePath = writeFixtureSession(workspace) + const share = shareDeps(hub, workspace, filePath, home) + const events: string[] = [] + const generated = BILINGUAL_SUMMARY.replace( + 'title: Rename alpha to beta', + `title: ${'a'.repeat(64 * 1024)}`, + ) + + const exit = await handleShareCommand( + `${SESSION_UUID}@2`, + {}, + { + ...share.deps, + ui: interactiveUi({ selected: 'claude', events }), + detectSummaryAgents: async () => [ + { id: 'claude', name: 'Claude Code', path: '/bin/claude' }, + ], + generateSummary: async () => generated, + }, + ) + + expect(exit).toBe(1) + expect(hub.sessions.get(`claude_${SESSION_UUID}`)?.summaryMd).toBeNull() + expect(events).toContain( + 'error:Claude Code returned an invalid bilingual Summary: the UTF-8 document exceeds 64 KiB', + ) + }) + + it('rejects a repeated first H1 even when its overlong title is repaired', async () => { + const hub = makeHub() + const workspace = mkdtempSync(join(tmpdir(), 'spool-summary-repeated-long-title-')) + const home = mkdtempSync(join(tmpdir(), 'spool-summary-repeated-long-title-home-')) + const filePath = writeFixtureSession(workspace) + const share = shareDeps(hub, workspace, filePath, home) + const events: string[] = [] + const overlong = `Rename alpha to beta across the demo workspace ${'and every downstream caller '.repeat(4)}` + const generated = BILINGUAL_SUMMARY.replace( + 'title: Rename alpha to beta', + `title: ${overlong}`, + ).replace('\n', `\n# ${overlong}\n\n`) + + const exit = await handleShareCommand( + `${SESSION_UUID}@2`, + {}, + { + ...share.deps, + ui: interactiveUi({ selected: 'claude', events }), + detectSummaryAgents: async () => [ + { id: 'claude', name: 'Claude Code', path: '/bin/claude' }, + ], + generateSummary: async () => generated, + }, + ) + + expect(exit).toBe(1) + expect(hub.sessions.get(`claude_${SESSION_UUID}`)?.summaryMd).toBeNull() + expect(events).toContain( + 'error:Claude Code returned an invalid bilingual Summary: Summary bodies must not repeat the Session title as their first H1', + ) + }) + it('does not prompt or invoke an Agent when non-interactive visibility is acknowledged', async () => { const hub = makeHub() const workspace = mkdtempSync(join(tmpdir(), 'spool-summary-nontty-')) diff --git a/apps/cli/src/commands/share.ts b/apps/cli/src/commands/share.ts index 89efc922..aee08779 100644 --- a/apps/cli/src/commands/share.ts +++ b/apps/cli/src/commands/share.ts @@ -16,9 +16,12 @@ import { isResumableSessionProvider, parseSummaryFrontMatter, parseSessionText, + repairOverlongSummaryTitles, sessionRecordData, SESSION_PROVIDERS, + SUMMARY_TITLE_CHAR_LIMIT, type SessionProvider, + type SessionTitles, } from '@spool-lab/session-kit' import { Command } from 'commander' @@ -422,8 +425,12 @@ export async function handleShareCommand( buildPreparedSummaryPrompt(target, prepared) const generated = (await summarize(agent, prompt)).trim() if (!generated) throw new Error(`${agent.name} returned an empty Summary.`) - const { summary, shortened } = repairOverlongSummaryTitles(generated) - const invalidSummary = bilingualSummaryValidationError(summary) + const oversizedSummary = summaryDocumentSizeValidationError(generated) + if (oversizedSummary) { + throw new Error(`${agent.name} returned an invalid bilingual Summary: ${oversizedSummary}`) + } + const { summary, shortened, sourceTitles } = repairOverlongSummaryTitles(generated) + const invalidSummary = bilingualSummaryValidationError(summary, sourceTitles) if (invalidSummary) { throw new Error(`${agent.name} returned an invalid bilingual Summary: ${invalidSummary}`) } @@ -463,69 +470,12 @@ export async function handleShareCommand( } } -export const SUMMARY_TITLE_CHAR_LIMIT = 96 -const SUMMARY_TITLE_KEYS = ['title', 'title_zh'] as const -type SummaryTitleKey = (typeof SUMMARY_TITLE_KEYS)[number] - -/** - * An overlong front-matter title is the one Summary defect worth repairing - * instead of rejecting: every reader already bounds titles to - * `SUMMARY_TITLE_CHAR_LIMIT` characters, so shortening here removes nothing a - * reader would have seen, while a rejection discards a whole local-Agent - * generation — minutes of provider usage — over the tail of one line. - * Structural defects stay hard failures; only the front-matter is rewritten. - */ -export function repairOverlongSummaryTitles(summary: string): { - summary: string - shortened: SummaryTitleKey[] -} { - const lines = summary.split(/\r?\n/) - if (lines[0]?.trim() !== '---') return { summary, shortened: [] } - const closing = lines.findIndex((line, index) => index > 0 && line.trim() === '---') - if (closing === -1) return { summary, shortened: [] } - - const shortened: SummaryTitleKey[] = [] - for (let index = 1; index < closing; index++) { - const match = /^([A-Za-z0-9_-]+)\s*:\s*(.*)$/.exec(lines[index]!.trim()) - if (!match) continue - const key = SUMMARY_TITLE_KEYS.find((candidate) => candidate === match[1]) - if (!key) continue - const value = normalizeSummaryTitle(match[2]!) - if (Array.from(value).length <= SUMMARY_TITLE_CHAR_LIMIT) continue - lines[index] = `${key}: ${shortenSummaryTitle(value)}` - shortened.push(key) - } - - return shortened.length === 0 ? { summary, shortened } : { summary: lines.join('\n'), shortened } -} - -/** Mirrors the parser: drop wrapping quotes and collapse runs of whitespace. */ -function normalizeSummaryTitle(raw: string): string { - return raw - .trim() - .replace(/^["']|["']$/g, '') - .replace(/\s+/g, ' ') - .trim() -} - -/** - * Cut back to a word boundary when the title has one, so a shortened English - * title does not end mid-word. Scripts written without spaces (the usual - * `title_zh` case) fall back to a codepoint slice. The ellipsis keeps the - * result honest about being cut. - */ -function shortenSummaryTitle(value: string): string { - const budget = SUMMARY_TITLE_CHAR_LIMIT - 1 - const clipped = Array.from(value).slice(0, budget).join('') - const lastSpace = clipped.lastIndexOf(' ') - const bounded = lastSpace >= Math.floor(budget / 2) ? clipped.slice(0, lastSpace) : clipped - return `${bounded.replace(/[\s\p{P}]+$/u, '')}…` -} - -export function bilingualSummaryValidationError(summary: string): string | null { - if (Buffer.byteLength(summary, 'utf8') > 64 * 1024) { - return 'the UTF-8 document exceeds 64 KiB' - } +export function bilingualSummaryValidationError( + summary: string, + sourceTitles?: SessionTitles | null, +): string | null { + const oversizedSummary = summaryDocumentSizeValidationError(summary) + if (oversizedSummary) return oversizedSummary const parsed = parseSummaryFrontMatter(summary) if (parsed.titleOverflow) { return `\`title\` and \`title_zh\` must each be at most ${SUMMARY_TITLE_CHAR_LIMIT} characters` @@ -536,15 +486,20 @@ export function bilingualSummaryValidationError(summary: string): string | null if (!parsed.summaries?.en || !parsed.summaries.zh) { return 'both English and Simplified Chinese bodies must use the required Summary delimiters' } + const headingTitles = sourceTitles ?? parsed.titles if ( - repeatsTitleAsFirstHeading(parsed.summaries.en, parsed.titles.en) || - repeatsTitleAsFirstHeading(parsed.summaries.zh, parsed.titles.zh) + (headingTitles?.en && repeatsTitleAsFirstHeading(parsed.summaries.en, headingTitles.en)) || + (headingTitles?.zh && repeatsTitleAsFirstHeading(parsed.summaries.zh, headingTitles.zh)) ) { return 'Summary bodies must not repeat the Session title as their first H1' } return null } +function summaryDocumentSizeValidationError(summary: string): string | null { + return Buffer.byteLength(summary, 'utf8') > 64 * 1024 ? 'the UTF-8 document exceeds 64 KiB' : null +} + function repeatsTitleAsFirstHeading(markdown: string, title: string): boolean { const first = markdown.split(/\r?\n/).find((line) => line.trim() !== '') if (!first || !/^\s{0,3}#\s+/.test(first)) return false diff --git a/packages/session-kit/src/index.ts b/packages/session-kit/src/index.ts index cb15a5c9..4a2f9732 100644 --- a/packages/session-kit/src/index.ts +++ b/packages/session-kit/src/index.ts @@ -95,7 +95,17 @@ export { isSessionProvider, } from './types.js' -export { parseSummaryFrontMatter } from './summary.js' -export type { ParsedSummary, SessionSummaries, SessionTitles } from './summary.js' +export { + parseSummaryFrontMatter, + repairOverlongSummaryTitles, + SUMMARY_TITLE_CHAR_LIMIT, +} from './summary.js' +export type { + ParsedSummary, + SessionSummaries, + SessionTitles, + SummaryTitleKey, + SummaryTitleRepair, +} from './summary.js' export { costForUsage, MODEL_PRICING } from './pricing.js' export type { ModelPricing, SessionCost } from './pricing.js' diff --git a/packages/session-kit/src/summary.ts b/packages/session-kit/src/summary.ts index 418b7fc2..1a0d2749 100644 --- a/packages/session-kit/src/summary.ts +++ b/packages/session-kit/src/summary.ts @@ -22,7 +22,26 @@ export interface ParsedSummary { titleOverflow?: boolean } -const MAX_TITLE_CHARS = 96 +export const SUMMARY_TITLE_CHAR_LIMIT = 96 +export type SummaryTitleKey = 'title' | 'title_zh' + +export interface SummaryTitleRepair { + summary: string + shortened: SummaryTitleKey[] + /** Normalized, unbounded titles from the source document. */ + sourceTitles: SessionTitles | null +} + +interface SummarySourceLine { + content: string + ending: string +} + +interface LeadingSummaryFrontMatter { + lines: SummarySourceLine[] + closing: number +} + const SUMMARY_SECTION_START_RE = /^$/ const SUMMARY_SECTION_END = '' @@ -43,40 +62,94 @@ const SUMMARY_SECTION_END = '' */ export function parseSummaryFrontMatter(summaryMd: string | null | undefined): ParsedSummary { const source = summaryMd ?? '' - const lines = source.split(/\r?\n/) - if (lines[0]?.trim() !== '---') return withParsedSummaries(null, source, false) - - let closing = -1 - for (let i = 1; i < lines.length; i++) { - if (lines[i]!.trim() === '---') { - closing = i - break - } - } - if (closing === -1) return withParsedSummaries(null, source, false) + const frontMatter = leadingSummaryFrontMatter(source) + if (!frontMatter) return withParsedSummaries(null, source, false) const titles: SessionTitles = {} let titleOverflow = false - for (const line of lines.slice(1, closing)) { - const match = /^([A-Za-z0-9_-]+)\s*:\s*(.*)$/.exec(line.trim()) - if (!match) continue - const locale = match[1] === 'title' ? 'en' : match[1] === 'title_zh' ? 'zh' : null - if (locale === null) continue - const normalized = normalizeTitle(match[2]!) - if (Array.from(normalized).length > MAX_TITLE_CHARS) titleOverflow = true - const value = boundTitle(normalized) + for (const line of frontMatter.lines.slice(1, frontMatter.closing)) { + const title = parseSummaryTitleLine(line.content) + if (!title) continue + const locale = title.key === 'title' ? 'en' : 'zh' + if (Array.from(title.value).length > SUMMARY_TITLE_CHAR_LIMIT) titleOverflow = true + const value = boundTitle(title.value) if (!value) continue titles[locale] = value } - const body = lines - .slice(closing + 1) + const body = frontMatter.lines + .slice(frontMatter.closing + 1) + .map((line) => line.content) .join('\n') .replace(/^\s*\n/, '') return withParsedSummaries(titles.en || titles.zh ? titles : null, body, titleOverflow) } +/** + * Shorten only overlong leading front-matter titles while preserving every + * other source byte, including mixed LF/CRLF line endings and both bodies. + */ +export function repairOverlongSummaryTitles(summary: string): SummaryTitleRepair { + const frontMatter = leadingSummaryFrontMatter(summary) + if (!frontMatter) return { summary, shortened: [], sourceTitles: null } + + const shortened: SummaryTitleKey[] = [] + const sourceTitles: SessionTitles = {} + for (const line of frontMatter.lines.slice(1, frontMatter.closing)) { + const title = parseSummaryTitleLine(line.content) + if (!title) continue + sourceTitles[title.key === 'title' ? 'en' : 'zh'] = title.value + if (Array.from(title.value).length <= SUMMARY_TITLE_CHAR_LIMIT) continue + line.content = `${title.key}: ${shortenSummaryTitle(title.value)}` + shortened.push(title.key) + } + + return { + summary: + shortened.length === 0 + ? summary + : frontMatter.lines.map((line) => `${line.content}${line.ending}`).join(''), + shortened, + sourceTitles: sourceTitles.en || sourceTitles.zh ? sourceTitles : null, + } +} + +function leadingSummaryFrontMatter(source: string): LeadingSummaryFrontMatter | null { + const lines = splitSummarySourceLines(source) + if (lines[0]?.content.trim() !== '---') return null + const closing = lines.findIndex((line, index) => index > 0 && line.content.trim() === '---') + return closing === -1 ? null : { lines, closing } +} + +function splitSummarySourceLines(source: string): SummarySourceLine[] { + const lines: SummarySourceLine[] = [] + let start = 0 + while (start <= source.length) { + const newline = source.indexOf('\n', start) + if (newline === -1) { + lines.push({ content: source.slice(start), ending: '' }) + break + } + const contentEnd = newline > start && source[newline - 1] === '\r' ? newline - 1 : newline + lines.push({ + content: source.slice(start, contentEnd), + ending: source.slice(contentEnd, newline + 1), + }) + start = newline + 1 + } + return lines +} + +function parseSummaryTitleLine(line: string): { key: SummaryTitleKey; value: string } | null { + const trimmed = line.trim() + const separator = trimmed.indexOf(':') + if (separator === -1) return null + const candidate = trimmed.slice(0, separator).trimEnd() + if (candidate !== 'title' && candidate !== 'title_zh') return null + return { key: candidate, value: normalizeTitle(trimmed.slice(separator + 1)) } +} + /** * Canonical bilingual bodies are delimited with invisible Markdown comments: * @@ -143,5 +216,13 @@ function normalizeTitle(raw: string): string { } function boundTitle(value: string): string { - return Array.from(value).slice(0, MAX_TITLE_CHARS).join('') + return Array.from(value).slice(0, SUMMARY_TITLE_CHAR_LIMIT).join('') +} + +function shortenSummaryTitle(value: string): string { + const budget = SUMMARY_TITLE_CHAR_LIMIT - 1 + const clipped = Array.from(value).slice(0, budget).join('') + const lastSpace = clipped.lastIndexOf(' ') + const bounded = lastSpace >= Math.floor(budget / 2) ? clipped.slice(0, lastSpace) : clipped + return `${bounded.replace(/[\s\p{P}]+$/u, '')}…` } From 836af0825c0938fc9fc371b307855d264bb62d49 Mon Sep 17 00:00:00 2001 From: Xinyao Date: Mon, 27 Jul 2026 21:08:30 +0800 Subject: [PATCH 3/3] fix: cover summary repair edge cases --- apps/cli/src/commands/share-resume.test.ts | 97 ++++++++-------------- apps/cli/src/commands/share.ts | 38 ++++++--- packages/session-kit/src/summary.test.ts | 92 +++++++++++++++++++- packages/session-kit/src/summary.ts | 4 +- 4 files changed, 154 insertions(+), 77 deletions(-) diff --git a/apps/cli/src/commands/share-resume.test.ts b/apps/cli/src/commands/share-resume.test.ts index aeda5ba2..207ead89 100644 --- a/apps/cli/src/commands/share-resume.test.ts +++ b/apps/cli/src/commands/share-resume.test.ts @@ -13,7 +13,6 @@ import { join, sep } from 'node:path' import { parseSummaryFrontMatter, - repairOverlongSummaryTitles, sequenceRoot, serializePortableSession, } from '@spool-lab/session-kit' @@ -419,68 +418,6 @@ describe('spool share local Agent Summary flow', () => { ).toMatch(/must not repeat the Session title/) }) - it('shortens overlong front-matter titles instead of discarding the Summary', () => { - const longEnglish = - 'Fix the daemon reconnect loop that keeps retrying after macOS sleep and wake, and add regression coverage for the backoff' - const repairedEnglish = repairOverlongSummaryTitles( - BILINGUAL_SUMMARY.replace('title: Rename alpha to beta', `title: ${longEnglish}`), - ) - expect(repairedEnglish.shortened).toEqual(['title']) - expect(bilingualSummaryValidationError(repairedEnglish.summary)).toBeNull() - const englishTitle = parseSummaryFrontMatter(repairedEnglish.summary).titles?.en as string - expect(Array.from(englishTitle).length).toBeLessThanOrEqual(96) - expect(englishTitle).toBe( - 'Fix the daemon reconnect loop that keeps retrying after macOS sleep and wake, and add…', - ) - // Only the front-matter is rewritten; both bodies survive byte for byte. - expect(repairedEnglish.summary.slice(repairedEnglish.summary.indexOf('\n---\n'))).toBe( - BILINGUAL_SUMMARY.slice(BILINGUAL_SUMMARY.indexOf('\n---\n')), - ) - - // Simplified Chinese has no spaces to cut back to, so it slices by codepoint. - const longChinese = '修'.repeat(120) - const repairedChinese = repairOverlongSummaryTitles( - BILINGUAL_SUMMARY.replace('title_zh: 将 alpha 重命名为 beta', `title_zh: ${longChinese}`), - ) - expect(repairedChinese.shortened).toEqual(['title_zh']) - expect(bilingualSummaryValidationError(repairedChinese.summary)).toBeNull() - expect(parseSummaryFrontMatter(repairedChinese.summary).titles?.zh).toBe(`${'修'.repeat(95)}…`) - - // A conforming Summary is returned untouched. - expect(repairOverlongSummaryTitles(BILINGUAL_SUMMARY)).toEqual({ - summary: BILINGUAL_SUMMARY, - shortened: [], - sourceTitles: { - en: 'Rename alpha to beta', - zh: '将 alpha 重命名为 beta', - }, - }) - const noFrontMatter = '# Legacy summary\n\nA single-language body.' - expect(repairOverlongSummaryTitles(noFrontMatter)).toEqual({ - summary: noFrontMatter, - shortened: [], - sourceTitles: null, - }) - }) - - it('preserves Summary body bytes when shortening a CRLF front-matter title', () => { - const body = [ - '', - '', - 'English body.', - '', - '', - '', - '中文正文。', - '', - ].join('\r\n') - const source = `---\r\ntitle: ${'a'.repeat(120)}\r\ntitle_zh: 中文标题\r\n---\r\n${body}` - - const repaired = repairOverlongSummaryTitles(source) - - expect(repaired.summary.slice(repaired.summary.indexOf('\r\n---\r\n') + 7)).toBe(body) - }) - it('checks long closing heading sequences without regex backtracking', () => { const repeatedHeading = `# Rename alpha to beta${' '.repeat(32 * 1024)}###` expect( @@ -787,6 +724,40 @@ describe('spool share local Agent Summary flow', () => { ) }) + it('rejects a first H1 that repeats the repaired Session title', async () => { + const hub = makeHub() + const workspace = mkdtempSync(join(tmpdir(), 'spool-summary-repeated-repaired-title-')) + const home = mkdtempSync(join(tmpdir(), 'spool-summary-repeated-repaired-title-home-')) + const filePath = writeFixtureSession(workspace) + const share = shareDeps(hub, workspace, filePath, home) + const events: string[] = [] + const overlong = 'a'.repeat(120) + const repairedTitle = `${'a'.repeat(95)}…` + const generated = BILINGUAL_SUMMARY.replace( + 'title: Rename alpha to beta', + `title: ${overlong}`, + ).replace('\n', `\n# ${repairedTitle}\n\n`) + + const exit = await handleShareCommand( + `${SESSION_UUID}@2`, + {}, + { + ...share.deps, + ui: interactiveUi({ selected: 'claude', events }), + detectSummaryAgents: async () => [ + { id: 'claude', name: 'Claude Code', path: '/bin/claude' }, + ], + generateSummary: async () => generated, + }, + ) + + expect(exit).toBe(1) + expect(hub.sessions.get(`claude_${SESSION_UUID}`)?.summaryMd).toBeNull() + expect(events).toContain( + 'error:Claude Code returned an invalid bilingual Summary: Summary bodies must not repeat the Session title as their first H1', + ) + }) + it('does not prompt or invoke an Agent when non-interactive visibility is acknowledged', async () => { const hub = makeHub() const workspace = mkdtempSync(join(tmpdir(), 'spool-summary-nontty-')) diff --git a/apps/cli/src/commands/share.ts b/apps/cli/src/commands/share.ts index aee08779..3833a3b4 100644 --- a/apps/cli/src/commands/share.ts +++ b/apps/cli/src/commands/share.ts @@ -21,7 +21,7 @@ import { SESSION_PROVIDERS, SUMMARY_TITLE_CHAR_LIMIT, type SessionProvider, - type SessionTitles, + type SummaryTitleRepair, } from '@spool-lab/session-kit' import { Command } from 'commander' @@ -429,11 +429,12 @@ export async function handleShareCommand( if (oversizedSummary) { throw new Error(`${agent.name} returned an invalid bilingual Summary: ${oversizedSummary}`) } - const { summary, shortened, sourceTitles } = repairOverlongSummaryTitles(generated) - const invalidSummary = bilingualSummaryValidationError(summary, sourceTitles) + const repaired = repairOverlongSummaryTitles(generated) + const invalidSummary = repairedBilingualSummaryValidationError(repaired) if (invalidSummary) { throw new Error(`${agent.name} returned an invalid bilingual Summary: ${invalidSummary}`) } + const { summary, shortened } = repaired generation.message('Uploading generated Summary') await publishPreparedShare(client, prepared, { card, @@ -470,10 +471,10 @@ export async function handleShareCommand( } } -export function bilingualSummaryValidationError( - summary: string, - sourceTitles?: SessionTitles | null, -): string | null { +const SUMMARY_REPEATED_TITLE_HEADING_ERROR = + 'Summary bodies must not repeat the Session title as their first H1' + +export function bilingualSummaryValidationError(summary: string): string | null { const oversizedSummary = summaryDocumentSizeValidationError(summary) if (oversizedSummary) return oversizedSummary const parsed = parseSummaryFrontMatter(summary) @@ -486,12 +487,27 @@ export function bilingualSummaryValidationError( if (!parsed.summaries?.en || !parsed.summaries.zh) { return 'both English and Simplified Chinese bodies must use the required Summary delimiters' } - const headingTitles = sourceTitles ?? parsed.titles if ( - (headingTitles?.en && repeatsTitleAsFirstHeading(parsed.summaries.en, headingTitles.en)) || - (headingTitles?.zh && repeatsTitleAsFirstHeading(parsed.summaries.zh, headingTitles.zh)) + repeatsTitleAsFirstHeading(parsed.summaries.en, parsed.titles.en) || + repeatsTitleAsFirstHeading(parsed.summaries.zh, parsed.titles.zh) + ) { + return SUMMARY_REPEATED_TITLE_HEADING_ERROR + } + return null +} + +function repairedBilingualSummaryValidationError(repaired: SummaryTitleRepair): string | null { + const invalidSummary = bilingualSummaryValidationError(repaired.summary) + if (invalidSummary || !repaired.sourceTitles) return invalidSummary + const parsed = parseSummaryFrontMatter(repaired.summary) + if (!parsed.summaries?.en || !parsed.summaries.zh) return null + if ( + (repaired.sourceTitles.en && + repeatsTitleAsFirstHeading(parsed.summaries.en, repaired.sourceTitles.en)) || + (repaired.sourceTitles.zh && + repeatsTitleAsFirstHeading(parsed.summaries.zh, repaired.sourceTitles.zh)) ) { - return 'Summary bodies must not repeat the Session title as their first H1' + return SUMMARY_REPEATED_TITLE_HEADING_ERROR } return null } diff --git a/packages/session-kit/src/summary.test.ts b/packages/session-kit/src/summary.test.ts index 97c8f02a..b9035178 100644 --- a/packages/session-kit/src/summary.test.ts +++ b/packages/session-kit/src/summary.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vite-plus/test' import { costForUsage } from './pricing.js' -import { parseSummaryFrontMatter } from './summary.js' +import { parseSummaryFrontMatter, repairOverlongSummaryTitles } from './summary.js' describe('parseSummaryFrontMatter', () => { it('parses bilingual titles and strips the block from the body', () => { @@ -106,6 +106,96 @@ describe('parseSummaryFrontMatter', () => { }) }) +describe('repairOverlongSummaryTitles', () => { + it('shortens English and Chinese titles without changing the bodies', () => { + const source = [ + '---', + 'title: Rename alpha to beta', + 'title_zh: 将 alpha 重命名为 beta', + '---', + '', + '', + 'The demo now uses the requested beta name.', + '', + '', + '', + '演示项目现在使用要求的 beta 名称。', + '', + ].join('\n') + const longEnglish = + 'Fix the daemon reconnect loop that keeps retrying after macOS sleep and wake, and add regression coverage for the backoff' + + const repairedEnglish = repairOverlongSummaryTitles( + source.replace('title: Rename alpha to beta', `title: ${longEnglish}`), + ) + + expect(repairedEnglish.shortened).toEqual(['title']) + expect(repairedEnglish.sourceTitles?.en).toBe(longEnglish) + expect(parseSummaryFrontMatter(repairedEnglish.summary).titles?.en).toBe( + 'Fix the daemon reconnect loop that keeps retrying after macOS sleep and wake, and add…', + ) + expect(repairedEnglish.summary.slice(repairedEnglish.summary.indexOf('\n---\n'))).toBe( + source.slice(source.indexOf('\n---\n')), + ) + + const longChinese = '修'.repeat(120) + const repairedChinese = repairOverlongSummaryTitles( + source.replace('title_zh: 将 alpha 重命名为 beta', `title_zh: ${longChinese}`), + ) + expect(repairedChinese.shortened).toEqual(['title_zh']) + expect(parseSummaryFrontMatter(repairedChinese.summary).titles?.zh).toBe(`${'修'.repeat(95)}…`) + + expect(repairOverlongSummaryTitles(source)).toEqual({ + summary: source, + shortened: [], + sourceTitles: { en: 'Rename alpha to beta', zh: '将 alpha 重命名为 beta' }, + }) + const legacy = '# Legacy summary\n\nA single-language body.' + expect(repairOverlongSummaryTitles(legacy)).toEqual({ + summary: legacy, + shortened: [], + sourceTitles: null, + }) + }) + + it('measures the word-boundary floor in Unicode codepoints', () => { + const source = [ + '---', + `title: ${'😀'.repeat(30)} ${'a'.repeat(70)}`, + 'title_zh: 中文标题', + '---', + 'Body.', + ].join('\n') + + const repaired = repairOverlongSummaryTitles(source) + + expect(parseSummaryFrontMatter(repaired.summary).titles?.en).toBe( + `${'😀'.repeat(30)} ${'a'.repeat(64)}…`, + ) + }) + + it('preserves mixed LF and CRLF outside repaired title lines', () => { + const source = [ + '---\r\n', + `title: ${'a'.repeat(120)}\n`, + 'title_zh: 中文标题\r\n', + '---\n', + '\r\n', + '\r\n', + 'English body.\n', + '\r\n', + '\n', + '\r\n', + '中文正文。\n', + '', + ].join('') + + const repaired = repairOverlongSummaryTitles(source) + + expect(repaired.summary).toBe(source.replace('a'.repeat(120), `${'a'.repeat(95)}…`)) + }) +}) + describe('costForUsage', () => { it('prices by longest model prefix and reports totals', () => { const cost = costForUsage({ diff --git a/packages/session-kit/src/summary.ts b/packages/session-kit/src/summary.ts index 1a0d2749..0dcde678 100644 --- a/packages/session-kit/src/summary.ts +++ b/packages/session-kit/src/summary.ts @@ -221,8 +221,8 @@ function boundTitle(value: string): string { function shortenSummaryTitle(value: string): string { const budget = SUMMARY_TITLE_CHAR_LIMIT - 1 - const clipped = Array.from(value).slice(0, budget).join('') + const clipped = Array.from(value).slice(0, budget) const lastSpace = clipped.lastIndexOf(' ') const bounded = lastSpace >= Math.floor(budget / 2) ? clipped.slice(0, lastSpace) : clipped - return `${bounded.replace(/[\s\p{P}]+$/u, '')}…` + return `${bounded.join('').replace(/[\s\p{P}]+$/u, '')}…` }