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
139 changes: 138 additions & 1 deletion apps/cli/src/commands/share-resume.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -621,6 +625,139 @@ 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('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('<!-- spool:summary:en -->\n', `<!-- spool:summary:en -->\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('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('<!-- spool:summary:en -->\n', `<!-- spool:summary:en -->\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-'))
Expand Down
52 changes: 44 additions & 8 deletions apps/cli/src/commands/share.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,12 @@ import {
isResumableSessionProvider,
parseSummaryFrontMatter,
parseSessionText,
repairOverlongSummaryTitles,
sessionRecordData,
SESSION_PROVIDERS,
SUMMARY_TITLE_CHAR_LIMIT,
type SessionProvider,
type SummaryTitleRepair,
} from '@spool-lab/session-kit'
import { Command } from 'commander'

Expand Down Expand Up @@ -420,12 +423,18 @@ 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 invalidSummary = bilingualSummaryValidationError(summary)
const generated = (await summarize(agent, prompt)).trim()
if (!generated) throw new Error(`${agent.name} returned an empty Summary.`)
const oversizedSummary = summaryDocumentSizeValidationError(generated)
if (oversizedSummary) {
throw new Error(`${agent.name} returned an invalid bilingual Summary: ${oversizedSummary}`)
}
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,
Expand All @@ -436,6 +445,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) {
Expand All @@ -457,13 +471,15 @@ export async function handleShareCommand(
}
}

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 {
if (Buffer.byteLength(summary, 'utf8') > 64 * 1024) {
return 'the UTF-8 document exceeds 64 KiB'
}
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 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'
Expand All @@ -475,11 +491,31 @@ export function bilingualSummaryValidationError(summary: string): string | null
repeatsTitleAsFirstHeading(parsed.summaries.en, parsed.titles.en) ||
repeatsTitleAsFirstHeading(parsed.summaries.zh, parsed.titles.zh)
) {
return 'Summary bodies must not repeat the Session title as their first H1'
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_REPEATED_TITLE_HEADING_ERROR
}
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
Expand Down
14 changes: 12 additions & 2 deletions packages/session-kit/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
92 changes: 91 additions & 1 deletion packages/session-kit/src/summary.test.ts
Original file line number Diff line number Diff line change
@@ -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', () => {
Expand Down Expand Up @@ -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',
'---',
'',
'<!-- spool:summary:en -->',
'The demo now uses the requested beta name.',
'<!-- /spool:summary -->',
'',
'<!-- spool:summary:zh -->',
'演示项目现在使用要求的 beta 名称。',
'<!-- /spool:summary -->',
].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',
'<!-- spool:summary:en -->\r\n',
'English body.\n',
'<!-- /spool:summary -->\r\n',
'\n',
'<!-- spool:summary:zh -->\r\n',
'中文正文。\n',
'<!-- /spool:summary -->',
].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({
Expand Down
Loading