refactor(channels): move SplitMessage from pkg/utils to pkg/channels

Message splitting is exclusively a Manager responsibility. Moving it
into the channels package eliminates the cross-package dependency and
aligns with the refactoring plan.
This commit is contained in:
Hoshina
2026-02-23 05:46:34 +08:00
parent ced55e768c
commit f4b0f080e2
3 changed files with 3 additions and 4 deletions
-201
View File
@@ -1,201 +0,0 @@
package utils
import (
"strings"
)
// SplitMessage splits long messages into chunks, preserving code block integrity.
// The maxLen parameter is measured in runes (Unicode characters), not bytes.
// The function reserves a buffer (10% of maxLen, min 50) to leave room for closing code blocks,
// but may extend to maxLen when needed.
// Call SplitMessage with the full text content and the maximum allowed length of a single message;
// it returns a slice of message chunks that each respect maxLen and avoid splitting fenced code blocks.
func SplitMessage(content string, maxLen int) []string {
if maxLen <= 0 {
if content == "" {
return nil
}
return []string{content}
}
runes := []rune(content)
var messages []string
// Dynamic buffer: 10% of maxLen, but at least 50 chars if possible
codeBlockBuffer := maxLen / 10
if codeBlockBuffer < 50 {
codeBlockBuffer = 50
}
if codeBlockBuffer > maxLen/2 {
codeBlockBuffer = maxLen / 2
}
for len(runes) > 0 {
if len(runes) <= maxLen {
messages = append(messages, string(runes))
break
}
// Effective split point: maxLen minus buffer, to leave room for code blocks
effectiveLimit := maxLen - codeBlockBuffer
if effectiveLimit < maxLen/2 {
effectiveLimit = maxLen / 2
}
// Find natural split point within the effective limit
msgEnd := findLastNewlineRunes(runes[:effectiveLimit], 200)
if msgEnd <= 0 {
msgEnd = findLastSpaceRunes(runes[:effectiveLimit], 100)
}
if msgEnd <= 0 {
msgEnd = effectiveLimit
}
// Check if this would end with an incomplete code block
candidate := runes[:msgEnd]
unclosedIdx := findLastUnclosedCodeBlockRunes(candidate)
if unclosedIdx >= 0 {
// Message would end with incomplete code block
// Try to extend up to maxLen to include the closing ```
if len(runes) > msgEnd {
closingIdx := findNextClosingCodeBlockRunes(runes, msgEnd)
if closingIdx > 0 && closingIdx <= maxLen {
// Extend to include the closing ```
msgEnd = closingIdx
} else {
// Code block is too long to fit in one chunk or missing closing fence.
// Try to split inside by injecting closing and reopening fences.
candidateStr := string(candidate)
unclosedStr := string(runes[unclosedIdx:])
headerEnd := strings.Index(unclosedStr, "\n")
var header string
if headerEnd == -1 {
header = strings.TrimSpace(string(runes[unclosedIdx : unclosedIdx+3]))
} else {
header = strings.TrimSpace(string(runes[unclosedIdx : unclosedIdx+headerEnd]))
}
headerEndIdx := unclosedIdx + len([]rune(header))
if headerEnd != -1 {
headerEndIdx = unclosedIdx + headerEnd
}
_ = candidateStr // used above for context
// If we have a reasonable amount of content after the header, split inside
if msgEnd > headerEndIdx+20 {
// Find a better split point closer to maxLen
innerLimit := maxLen - 5 // Leave room for "\n```"
betterEnd := findLastNewlineRunes(runes[:innerLimit], 200)
if betterEnd > headerEndIdx {
msgEnd = betterEnd
} else {
msgEnd = innerLimit
}
chunk := strings.TrimRight(string(runes[:msgEnd]), " \t\n\r") + "\n```"
messages = append(messages, chunk)
remaining := strings.TrimSpace(header + "\n" + string(runes[msgEnd:]))
runes = []rune(remaining)
continue
}
// Otherwise, try to split before the code block starts
newEnd := findLastNewlineRunes(runes[:unclosedIdx], 200)
if newEnd <= 0 {
newEnd = findLastSpaceRunes(runes[:unclosedIdx], 100)
}
if newEnd > 0 {
msgEnd = newEnd
} else {
// If we can't split before, we MUST split inside (last resort)
if unclosedIdx > 20 {
msgEnd = unclosedIdx
} else {
msgEnd = maxLen - 5
chunk := strings.TrimRight(string(runes[:msgEnd]), " \t\n\r") + "\n```"
messages = append(messages, chunk)
remaining := strings.TrimSpace(header + "\n" + string(runes[msgEnd:]))
runes = []rune(remaining)
continue
}
}
}
}
}
if msgEnd <= 0 {
msgEnd = effectiveLimit
}
messages = append(messages, string(runes[:msgEnd]))
remaining := strings.TrimSpace(string(runes[msgEnd:]))
runes = []rune(remaining)
}
return messages
}
// findLastUnclosedCodeBlockRunes finds the last opening ``` that doesn't have a closing ```
// Returns the rune position of the opening ``` or -1 if all code blocks are complete
func findLastUnclosedCodeBlockRunes(runes []rune) int {
inCodeBlock := false
lastOpenIdx := -1
for i := 0; i < len(runes); i++ {
if i+2 < len(runes) && runes[i] == '`' && runes[i+1] == '`' && runes[i+2] == '`' {
// Toggle code block state on each fence
if !inCodeBlock {
// Entering a code block: record this opening fence
lastOpenIdx = i
}
inCodeBlock = !inCodeBlock
i += 2
}
}
if inCodeBlock {
return lastOpenIdx
}
return -1
}
// findNextClosingCodeBlockRunes finds the next closing ``` starting from a rune position
// Returns the rune position after the closing ``` or -1 if not found
func findNextClosingCodeBlockRunes(runes []rune, startIdx int) int {
for i := startIdx; i < len(runes); i++ {
if i+2 < len(runes) && runes[i] == '`' && runes[i+1] == '`' && runes[i+2] == '`' {
return i + 3
}
}
return -1
}
// findLastNewlineRunes finds the last newline character within the last N runes
// Returns the rune position of the newline or -1 if not found
func findLastNewlineRunes(runes []rune, searchWindow int) int {
searchStart := len(runes) - searchWindow
if searchStart < 0 {
searchStart = 0
}
for i := len(runes) - 1; i >= searchStart; i-- {
if runes[i] == '\n' {
return i
}
}
return -1
}
// findLastSpaceRunes finds the last space character within the last N runes
// Returns the rune position of the space or -1 if not found
func findLastSpaceRunes(runes []rune, searchWindow int) int {
searchStart := len(runes) - searchWindow
if searchStart < 0 {
searchStart = 0
}
for i := len(runes) - 1; i >= searchStart; i-- {
if runes[i] == ' ' || runes[i] == '\t' {
return i
}
}
return -1
}
-177
View File
@@ -1,177 +0,0 @@
package utils
import (
"strings"
"testing"
)
func TestSplitMessage(t *testing.T) {
longText := strings.Repeat("a", 2500)
longCode := "```go\n" + strings.Repeat("fmt.Println(\"hello\")\n", 100) + "```" // ~2100 chars
tests := []struct {
name string
content string
maxLen int
expectChunks int // Check number of chunks
checkContent func(t *testing.T, chunks []string) // Custom validation
}{
{
name: "Empty message",
content: "",
maxLen: 2000,
expectChunks: 0,
},
{
name: "Short message fits in one chunk",
content: "Hello world",
maxLen: 2000,
expectChunks: 1,
},
{
name: "Simple split regular text",
content: longText,
maxLen: 2000,
expectChunks: 2,
checkContent: func(t *testing.T, chunks []string) {
if len([]rune(chunks[0])) > 2000 {
t.Errorf("Chunk 0 too large: %d runes", len([]rune(chunks[0])))
}
if len([]rune(chunks[0]))+len([]rune(chunks[1])) != len([]rune(longText)) {
t.Errorf(
"Total rune length mismatch. Got %d, want %d",
len([]rune(chunks[0]))+len([]rune(chunks[1])),
len([]rune(longText)),
)
}
},
},
{
name: "Split at newline",
// 1750 chars then newline, then more chars.
// Dynamic buffer: 2000 / 10 = 200.
// Effective limit: 2000 - 200 = 1800.
// Split should happen at newline because it's at 1750 (< 1800).
// Total length must > 2000 to trigger split. 1750 + 1 + 300 = 2051.
content: strings.Repeat("a", 1750) + "\n" + strings.Repeat("b", 300),
maxLen: 2000,
expectChunks: 2,
checkContent: func(t *testing.T, chunks []string) {
if len([]rune(chunks[0])) != 1750 {
t.Errorf("Expected chunk 0 to be 1750 runes (split at newline), got %d", len([]rune(chunks[0])))
}
if chunks[1] != strings.Repeat("b", 300) {
t.Errorf("Chunk 1 content mismatch. Len: %d", len([]rune(chunks[1])))
}
},
},
{
name: "Long code block split",
content: "Prefix\n" + longCode,
maxLen: 2000,
expectChunks: 2,
checkContent: func(t *testing.T, chunks []string) {
// Check that first chunk ends with closing fence
if !strings.HasSuffix(chunks[0], "\n```") {
t.Error("First chunk should end with injected closing fence")
}
// Check that second chunk starts with execution header
if !strings.HasPrefix(chunks[1], "```go") {
t.Error("Second chunk should start with injected code block header")
}
},
},
{
name: "Preserve Unicode characters (rune-aware)",
content: strings.Repeat("\u4e16", 2500), // 2500 runes, 7500 bytes
maxLen: 2000,
expectChunks: 2,
checkContent: func(t *testing.T, chunks []string) {
// Verify chunks contain valid unicode and don't split mid-rune
for i, chunk := range chunks {
runeCount := len([]rune(chunk))
if runeCount > 2000 {
t.Errorf("Chunk %d has %d runes, exceeds maxLen 2000", i, runeCount)
}
if !strings.Contains(chunk, "\u4e16") {
t.Errorf("Chunk %d should contain unicode characters", i)
}
}
// Verify total rune count is preserved
totalRunes := 0
for _, chunk := range chunks {
totalRunes += len([]rune(chunk))
}
if totalRunes != 2500 {
t.Errorf("Total rune count mismatch. Got %d, want 2500", totalRunes)
}
},
},
{
name: "Zero maxLen returns single chunk",
content: "Hello world",
maxLen: 0,
expectChunks: 1,
checkContent: func(t *testing.T, chunks []string) {
if chunks[0] != "Hello world" {
t.Errorf("Expected original content, got %q", chunks[0])
}
},
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got := SplitMessage(tc.content, tc.maxLen)
if tc.expectChunks == 0 {
if len(got) != 0 {
t.Errorf("Expected 0 chunks, got %d", len(got))
}
return
}
if len(got) != tc.expectChunks {
t.Errorf("Expected %d chunks, got %d", tc.expectChunks, len(got))
// Log sizes for debugging
for i, c := range got {
t.Logf("Chunk %d length: %d", i, len(c))
}
return // Stop further checks if count assumes specific split
}
if tc.checkContent != nil {
tc.checkContent(t, got)
}
})
}
}
func TestSplitMessage_CodeBlockIntegrity(t *testing.T) {
// Focused test for the core requirement: splitting inside a code block preserves syntax highlighting
// 60 chars total approximately
content := "```go\npackage main\n\nfunc main() {\n\tprintln(\"Hello\")\n}\n```"
maxLen := 40
chunks := SplitMessage(content, maxLen)
if len(chunks) != 2 {
t.Fatalf("Expected 2 chunks, got %d: %q", len(chunks), chunks)
}
// First chunk must end with "\n```"
if !strings.HasSuffix(chunks[0], "\n```") {
t.Errorf("First chunk should end with closing fence. Got: %q", chunks[0])
}
// Second chunk must start with the header "```go"
if !strings.HasPrefix(chunks[1], "```go") {
t.Errorf("Second chunk should start with code block header. Got: %q", chunks[1])
}
// First chunk should contain meaningful content
if len([]rune(chunks[0])) > 40 {
t.Errorf("First chunk exceeded maxLen: length %d runes", len([]rune(chunks[0])))
}
}