diff --git a/agent/codex/session.go b/agent/codex/session.go index b22ea4a..ecd95fa 100644 --- a/agent/codex/session.go +++ b/agent/codex/session.go @@ -24,18 +24,18 @@ import ( // codexSession manages a multi-turn Codex conversation. // First Send() uses `codex exec`, subsequent ones use `codex exec resume `. type codexSession struct { - workDir string - model string - effort string - mode string - extraEnv []string - events chan core.Event - threadID atomic.Value // stores string — Codex thread_id - ctx context.Context - cancel context.CancelFunc - wg sync.WaitGroup - alive atomic.Bool - closeOnce sync.Once + workDir string + model string + effort string + mode string + extraEnv []string + events chan core.Event + threadID atomic.Value // stores string — Codex thread_id + ctx context.Context + cancel context.CancelFunc + wg sync.WaitGroup + alive atomic.Bool + closeOnce sync.Once pendingMsgs []string // buffered agent_message texts awaiting classification } @@ -535,13 +535,25 @@ func (cs *codexSession) Close() error { }() select { case <-done: + // readLoop has exited; safe to close the events channel. + cs.closeOnce.Do(func() { + close(cs.events) + }) + return nil case <-time.After(8 * time.Second): - slog.Warn("codexSession: close timed out, abandoning wg.Wait") + // Do not close(cs.events) here: readLoop may still be in handleEvent + // (e.g. turn.completed -> flushPendingAsText) and would panic on send. + // See https://github.com/chenhg5/cc-connect/issues/281 + slog.Warn("codexSession: close timed out, deferring events channel close until readLoop exits", + "wait", 8*time.Second) + go func() { + <-done + cs.closeOnce.Do(func() { + close(cs.events) + }) + }() + return nil } - cs.closeOnce.Do(func() { - close(cs.events) - }) - return nil } // extractItemText extracts text from an item's array field (e.g. "summary" or "content"). diff --git a/core/markdown_html.go b/core/markdown_html.go index 9a4ddd2..7ba8e3a 100644 --- a/core/markdown_html.go +++ b/core/markdown_html.go @@ -69,7 +69,11 @@ func MarkdownToSimpleHTML(md string) string { if reTableSep.MatchString(tl) { b.WriteString("——————————") } else { - inner := tl[1 : len(tl)-1] + tlTrim := strings.TrimSpace(tl) + inner := tlTrim + if strings.HasPrefix(tlTrim, "|") && strings.HasSuffix(tlTrim, "|") && len(tlTrim) >= 2 { + inner = strings.TrimSpace(strings.TrimSuffix(strings.TrimPrefix(tlTrim, "|"), "|")) + } cells := strings.Split(inner, "|") for k := range cells { cells[k] = strings.TrimSpace(cells[k]) @@ -193,18 +197,18 @@ func MarkdownToSimpleHTML(md string) string { } var ( - reInlineCodeHTML = regexp.MustCompile("`([^`]+)`") - reBoldItalicHTML = regexp.MustCompile(`\*\*\*(.+?)\*\*\*`) - reBoldAstHTML = regexp.MustCompile(`\*\*(.+?)\*\*`) - reBoldUndHTML = regexp.MustCompile(`__(.+?)__`) - reItalicAstHTML = regexp.MustCompile(`(?:^|[^*])\*([^*]+?)\*(?:[^*]|$)`) - reStrikeHTML = regexp.MustCompile(`~~(.+?)~~`) - reLinkHTML = regexp.MustCompile(`\[([^\]]+)\]\(([^)]+)\)`) - reWikilinkHTML = regexp.MustCompile(`\[\[([^\]|]+)\|([^\]]+)\]\]|\[\[([^\]]+)\]\]`) - reUnorderedList = regexp.MustCompile(`^(\s*)[-*]\s+(.*)$`) - reOrderedList = regexp.MustCompile(`^(\s*)\d+\.\s+(.*)$`) - reTableSep = regexp.MustCompile(`^\|[\s:|-]+\|$`) - reCallout = regexp.MustCompile(`^\[!(\w+)\]\s*(.*)$`) + reInlineCodeHTML = regexp.MustCompile("`([^`]+)`") + reBoldItalicHTML = regexp.MustCompile(`\*\*\*(.+?)\*\*\*`) + reBoldAstHTML = regexp.MustCompile(`\*\*(.+?)\*\*`) + reBoldUndHTML = regexp.MustCompile(`__(.+?)__`) + reItalicAstHTML = regexp.MustCompile(`(?:^|[^*])\*([^*]+?)\*(?:[^*]|$)`) + reStrikeHTML = regexp.MustCompile(`~~(.+?)~~`) + reLinkHTML = regexp.MustCompile(`\[([^\]]+)\]\(([^)]+)\)`) + reWikilinkHTML = regexp.MustCompile(`\[\[([^\]|]+)\|([^\]]+)\]\]|\[\[([^\]]+)\]\]`) + reUnorderedList = regexp.MustCompile(`^(\s*)[-*]\s+(.*)$`) + reOrderedList = regexp.MustCompile(`^(\s*)\d+\.\s+(.*)$`) + reTableSep = regexp.MustCompile(`^\|[\s:|-]+\|$`) + reCallout = regexp.MustCompile(`^\[!(\w+)\]\s*(.*)$`) ) // convertInlineHTML converts inline Markdown formatting to Telegram-compatible HTML. diff --git a/platform/telegram/telegram.go b/platform/telegram/telegram.go index 786e29a..db369db 100644 --- a/platform/telegram/telegram.go +++ b/platform/telegram/telegram.go @@ -13,6 +13,7 @@ import ( "sync" "time" "unicode/utf16" + "unicode/utf8" "github.com/chenhg5/cc-connect/core" @@ -1232,10 +1233,29 @@ func (p *Platform) StartTyping(ctx context.Context, rctx any) (stop func()) { } func truncateForLog(s string, maxLen int) string { - if len(s) <= maxLen { + if maxLen <= 0 { + return "" + } + if utf8.RuneCountInString(s) <= maxLen { return s } - return s[:maxLen] + "..." + r := []rune(s) + if len(r) <= maxLen { + return s + } + return string(r[:maxLen]) + "..." +} + +// truncateTelegramBotDescription enforces Telegram's 256-character limit for +// BotCommand.Description. Byte slicing breaks UTF-8 for CJK text and triggers +// "text must be encoded in UTF-8" from the API (#119). +func truncateTelegramBotDescription(s string) string { + const max = 256 + if utf8.RuneCountInString(s) <= max { + return s + } + r := []rune(s) + return string(r[:253]) + "..." } func (p *Platform) Stop() error { @@ -1276,10 +1296,7 @@ func (p *Platform) RegisterCommands(commands []core.BotCommandInfo) error { continue } seen[cmd] = true - desc := c.Description - if len(desc) > 256 { - desc = desc[:253] + "..." - } + desc := truncateTelegramBotDescription(c.Description) tgCommands = append(tgCommands, tgbotapi.BotCommand{ Command: cmd, Description: desc, diff --git a/platform/telegram/telegram_test.go b/platform/telegram/telegram_test.go index 192d4df..e97d03c 100644 --- a/platform/telegram/telegram_test.go +++ b/platform/telegram/telegram_test.go @@ -11,6 +11,7 @@ import ( "sync/atomic" "testing" "time" + "unicode/utf8" "github.com/chenhg5/cc-connect/core" tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5" @@ -760,6 +761,39 @@ func TestSendAudioReturnsConversionErrorForWAV(t *testing.T) { } } +func TestTruncateTelegramBotDescription_UTF8Safe(t *testing.T) { + t.Parallel() + cjk := strings.Repeat("你", 200) + out := truncateTelegramBotDescription(cjk) + if !utf8.ValidString(out) { + t.Fatal("invalid UTF-8 from CJK description") + } + if got, max := utf8.RuneCountInString(out), 256; got > max { + t.Fatalf("rune count %d > %d", got, max) + } + + long := strings.Repeat("b", 260) + out2 := truncateTelegramBotDescription(long) + if want := 256; utf8.RuneCountInString(out2) != want { + t.Fatalf("ascii truncation: got %d runes want %d", utf8.RuneCountInString(out2), want) + } + if !utf8.ValidString(out2) { + t.Fatal("invalid UTF-8 after ascii truncation") + } +} + +func TestTruncateForLog_UTF8Safe(t *testing.T) { + t.Parallel() + s := strings.Repeat("世", 50) // 50 runes + out := truncateForLog(s, 10) + if !utf8.ValidString(out) { + t.Fatal("invalid UTF-8") + } + if utf8.RuneCountInString(out) != 13 { // 10 + "..." + t.Fatalf("got %d runes", utf8.RuneCountInString(out)) + } +} + func newTelegramTestPlatform(t *testing.T, handler func(http.ResponseWriter, *http.Request)) *Platform { t.Helper()