mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-16 13:03:31 +08:00
Telegram Bot API text edits share the same practical message length ceiling as sends, so streaming one growing placeholder can stop exposing new assistant output once the text approaches the limit. The adapter now seals full chunks, opens a fresh editable placeholder, and routes Telegram outbound text through a table-to-bullet formatter for phone readability. Constraint: Telegram text messages and edits must stay below the platform message limit. Rejected: Wait until message_complete to split the answer | users still lose streaming visibility during long replies. Confidence: high Scope-risk: narrow Directive: Keep Telegram-specific streaming state in adapters/telegram/format.ts instead of spreading chunk math across message handlers. Tested: bun test adapters/telegram/__tests__/telegram.test.ts adapters/common/__tests__/format.test.ts Tested: cd adapters && bunx tsc --noEmit Tested: bun run check:adapters Tested: cd desktop && bun run build:sidecars Not-tested: Live Telegram Bot API run with a real bot token.
211 lines
6.1 KiB
TypeScript
211 lines
6.1 KiB
TypeScript
import { describe, it, expect } from 'bun:test'
|
|
import {
|
|
convertMarkdownTablesToBullets,
|
|
formatImHelp,
|
|
formatImStatus,
|
|
splitMessage,
|
|
formatToolUse,
|
|
formatPermissionRequest,
|
|
truncateInput,
|
|
escapeMarkdownV2,
|
|
} from '../format.js'
|
|
|
|
describe('splitMessage', () => {
|
|
it('returns single chunk for short text', () => {
|
|
expect(splitMessage('hello', 100)).toEqual(['hello'])
|
|
})
|
|
|
|
it('splits at paragraph boundary', () => {
|
|
const text = 'First paragraph.\n\nSecond paragraph.'
|
|
const chunks = splitMessage(text, 20)
|
|
expect(chunks.length).toBeGreaterThan(1)
|
|
expect(chunks.join(' ').replace(/\s+/g, ' ')).toContain('First paragraph')
|
|
expect(chunks.join(' ').replace(/\s+/g, ' ')).toContain('Second paragraph')
|
|
})
|
|
|
|
it('splits at newline if no paragraph break', () => {
|
|
const text = 'Line one\nLine two\nLine three\nLine four'
|
|
const chunks = splitMessage(text, 20)
|
|
expect(chunks.length).toBeGreaterThan(1)
|
|
})
|
|
|
|
it('hard-splits at limit if no natural break', () => {
|
|
const text = 'a'.repeat(50)
|
|
const chunks = splitMessage(text, 20)
|
|
expect(chunks.length).toBe(3) // 20 + 20 + 10
|
|
expect(chunks.every((c) => c.length <= 20)).toBe(true)
|
|
})
|
|
|
|
it('preserves all content after splitting', () => {
|
|
const text = 'Hello world. This is a test. Foo bar baz.'
|
|
const chunks = splitMessage(text, 15)
|
|
const joined = chunks.join(' ')
|
|
// All words should be present
|
|
expect(joined).toContain('Hello')
|
|
expect(joined).toContain('test')
|
|
expect(joined).toContain('baz')
|
|
})
|
|
})
|
|
|
|
describe('convertMarkdownTablesToBullets', () => {
|
|
it('converts pipe tables into row-labeled bullets', () => {
|
|
const markdown = [
|
|
'Before',
|
|
'',
|
|
'| Feature | Status | Notes |',
|
|
'| --- | --- | --- |',
|
|
'| Auth | Done | OAuth2 |',
|
|
'| API | WIP | REST only |',
|
|
'',
|
|
'After',
|
|
].join('\n')
|
|
|
|
expect(convertMarkdownTablesToBullets(markdown)).toBe([
|
|
'Before',
|
|
'',
|
|
'Auth',
|
|
'• Status: Done',
|
|
'• Notes: OAuth2',
|
|
'',
|
|
'API',
|
|
'• Status: WIP',
|
|
'• Notes: REST only',
|
|
'',
|
|
'After',
|
|
].join('\n'))
|
|
})
|
|
|
|
it('skips empty table cells', () => {
|
|
const markdown = [
|
|
'| Item | Value | Notes |',
|
|
'| --- | --- | --- |',
|
|
'| One | 1 | |',
|
|
].join('\n')
|
|
|
|
expect(convertMarkdownTablesToBullets(markdown)).toBe([
|
|
'One',
|
|
'• Value: 1',
|
|
].join('\n'))
|
|
})
|
|
|
|
it('leaves non-table pipe text unchanged', () => {
|
|
const markdown = 'Use foo | bar as plain text.'
|
|
expect(convertMarkdownTablesToBullets(markdown)).toBe(markdown)
|
|
})
|
|
|
|
it('does not rewrite pipe tables inside fenced code blocks', () => {
|
|
const markdown = [
|
|
'```',
|
|
'| Feature | Status |',
|
|
'| --- | --- |',
|
|
'| Auth | Done |',
|
|
'```',
|
|
].join('\n')
|
|
|
|
expect(convertMarkdownTablesToBullets(markdown)).toBe(markdown)
|
|
})
|
|
})
|
|
|
|
describe('formatToolUse', () => {
|
|
it('includes tool name and input preview', () => {
|
|
const result = formatToolUse('Bash', { command: 'npm test' })
|
|
expect(result).toContain('🔧 Bash')
|
|
expect(result).toContain('npm test')
|
|
})
|
|
})
|
|
|
|
describe('formatPermissionRequest', () => {
|
|
it('includes tool name, input preview, and request ID', () => {
|
|
const result = formatPermissionRequest('Bash', { command: 'rm -rf /' }, 'abcde')
|
|
expect(result).toContain('🔐')
|
|
expect(result).toContain('Bash')
|
|
expect(result).toContain('abcde')
|
|
expect(result).toContain('rm -rf')
|
|
})
|
|
})
|
|
|
|
describe('truncateInput', () => {
|
|
it('returns short input as-is', () => {
|
|
expect(truncateInput('hello', 100)).toBe('hello')
|
|
})
|
|
|
|
it('truncates long input with ellipsis', () => {
|
|
const long = 'x'.repeat(300)
|
|
const result = truncateInput(long, 100)
|
|
expect(result.length).toBe(101) // 100 chars + '…'
|
|
expect(result.endsWith('…')).toBe(true)
|
|
})
|
|
|
|
it('handles objects by stringifying', () => {
|
|
const result = truncateInput({ key: 'value' }, 100)
|
|
expect(result).toContain('key')
|
|
expect(result).toContain('value')
|
|
})
|
|
|
|
it('handles unserializable input', () => {
|
|
const circular: any = {}
|
|
circular.self = circular
|
|
expect(truncateInput(circular, 100)).toBe('(unserializable)')
|
|
})
|
|
})
|
|
|
|
describe('escapeMarkdownV2', () => {
|
|
it('escapes special characters', () => {
|
|
expect(escapeMarkdownV2('hello_world')).toBe('hello\\_world')
|
|
expect(escapeMarkdownV2('a*b*c')).toBe('a\\*b\\*c')
|
|
expect(escapeMarkdownV2('test.md')).toBe('test\\.md')
|
|
})
|
|
|
|
it('leaves plain text unchanged', () => {
|
|
expect(escapeMarkdownV2('hello world')).toBe('hello world')
|
|
})
|
|
})
|
|
|
|
describe('formatImHelp', () => {
|
|
it('lists the lightweight IM commands', () => {
|
|
const text = formatImHelp()
|
|
expect(text).toContain('/new')
|
|
expect(text).toContain('/projects')
|
|
expect(text).toContain('/status')
|
|
expect(text).toContain('/clear')
|
|
expect(text).toContain('/stop')
|
|
expect(text).toContain('/help')
|
|
expect(text).toContain('项目列表')
|
|
expect(text).toContain('/allow <id>')
|
|
})
|
|
})
|
|
|
|
describe('formatImStatus', () => {
|
|
it('formats an active session summary for mobile reading', () => {
|
|
const text = formatImStatus({
|
|
sessionId: 'abc1234567890',
|
|
projectName: 'claude-code-haha',
|
|
branch: 'main',
|
|
model: 'claude-sonnet',
|
|
state: 'tool_executing',
|
|
verb: 'Running tests',
|
|
pendingPermissionCount: 1,
|
|
taskCounts: {
|
|
total: 4,
|
|
pending: 1,
|
|
inProgress: 2,
|
|
completed: 1,
|
|
},
|
|
})
|
|
|
|
expect(text).toContain('项目: claude-code-haha (main)')
|
|
expect(text).toContain('会话: abc12345…')
|
|
expect(text).toContain('模型: claude-sonnet')
|
|
expect(text).toContain('状态: 执行工具中 (Running tests)')
|
|
expect(text).toContain('审批: 1 个待确认')
|
|
expect(text).toContain('任务: 总计 4 · 进行中 2 · 待处理 1 · 已完成 1')
|
|
})
|
|
|
|
it('returns a friendly empty-session message when nothing is active', () => {
|
|
const text = formatImStatus(null)
|
|
expect(text).toContain('当前没有活动会话')
|
|
expect(text).toContain('/new')
|
|
expect(text).toContain('/projects')
|
|
})
|
|
})
|