From e40d033c3fdf28afaa4c4b32b61dbce836f311ce Mon Sep 17 00:00:00 2001 From: chenhg5 Date: Fri, 6 Mar 2026 10:30:29 +0800 Subject: [PATCH] feat(engine): enhance session switching and markdown conversion - Improved `/switch` command to support session names and better matching logic - Refactored markdown-to-HTML conversion with proper tag nesting and quote escaping - Added detailed logging for streaming preview operations - Fixed HTML tag validation in markdown tests - Updated Telegram platform to handle HTML parsing errors gracefully generated by llmgit Co-Authored-By: Claude --- core/engine.go | 74 ++++++++++++++++++++++------- core/markdown_html.go | 46 ++++++++++++------ core/markdown_html_test.go | 87 +++++++++++++++++++++++++++++++++++ core/streaming.go | 27 +++++++---- platform/telegram/telegram.go | 39 ++++++++++++++-- 5 files changed, 231 insertions(+), 42 deletions(-) diff --git a/core/engine.go b/core/engine.go index a76eb46..7916939 100644 --- a/core/engine.go +++ b/core/engine.go @@ -991,8 +991,9 @@ func (e *Engine) processInteractiveEvents(state *interactiveState, session *Sess // If streaming preview was active, try to finalize in-place if sp.finish(fullResponse) { - slog.Debug("stream preview: finalized in-place", "response_len", len(fullResponse)) + slog.Debug("EventResult: finalized via stream preview", "response_len", len(fullResponse)) } else { + slog.Debug("EventResult: sending via p.Send (preview inactive or failed)", "response_len", len(fullResponse), "chunks", len(splitMessage(fullResponse, maxPlatformMessageLen))) for _, chunk := range splitMessage(fullResponse, maxPlatformMessageLen) { if err := p.Send(e.ctx, replyCtx, chunk); err != nil { slog.Error("failed to send reply", "error", err) @@ -1283,10 +1284,10 @@ func (e *Engine) cmdList(p Platform, msg *Message) { func (e *Engine) cmdSwitch(p Platform, msg *Message, args []string) { if len(args) == 0 { - e.reply(p, msg.ReplyCtx, "Usage: /switch ") + e.reply(p, msg.ReplyCtx, "Usage: /switch ") return } - prefix := strings.TrimSpace(args[0]) + query := strings.TrimSpace(strings.Join(args, " ")) slog.Info("cmdSwitch: listing agent sessions", "session_key", msg.SessionKey) agentSessions, err := e.agent.ListSessions(e.ctx) @@ -1295,21 +1296,9 @@ func (e *Engine) cmdSwitch(p Platform, msg *Message, args []string) { return } - var matched *AgentSessionInfo - - // Try numeric index first (1-based, matching /list output) - if idx, err := strconv.Atoi(prefix); err == nil && idx >= 1 && idx <= len(agentSessions) { - matched = &agentSessions[idx-1] - } else { - for i := range agentSessions { - if strings.HasPrefix(agentSessions[i].ID, prefix) { - matched = &agentSessions[i] - break - } - } - } + matched := e.matchSession(agentSessions, query) if matched == nil { - e.reply(p, msg.ReplyCtx, fmt.Sprintf("❌ No session matching %q", prefix)) + e.reply(p, msg.ReplyCtx, fmt.Sprintf("❌ No session matching %q", query)) return } @@ -1335,6 +1324,57 @@ func (e *Engine) cmdSwitch(p Platform, msg *Message, args []string) { fmt.Sprintf("✅ Switched to: %s (%s, %d msgs)", displayName, shortID, matched.MessageCount)) } +// matchSession resolves a user query to an agent session. Priority: +// 1. Numeric index (1-based, matching /list output) +// 2. Exact custom name match (case-insensitive) +// 3. Session ID prefix match +// 4. Custom name prefix match (case-insensitive) +// 5. Summary substring match (case-insensitive) +func (e *Engine) matchSession(sessions []AgentSessionInfo, query string) *AgentSessionInfo { + if len(sessions) == 0 { + return nil + } + + // 1. Numeric index + if idx, err := strconv.Atoi(query); err == nil && idx >= 1 && idx <= len(sessions) { + return &sessions[idx-1] + } + + queryLower := strings.ToLower(query) + + // 2. Exact custom name match + for i := range sessions { + name := e.sessions.GetSessionName(sessions[i].ID) + if name != "" && strings.ToLower(name) == queryLower { + return &sessions[i] + } + } + + // 3. Session ID prefix match + for i := range sessions { + if strings.HasPrefix(sessions[i].ID, query) { + return &sessions[i] + } + } + + // 4. Custom name prefix match + for i := range sessions { + name := e.sessions.GetSessionName(sessions[i].ID) + if name != "" && strings.HasPrefix(strings.ToLower(name), queryLower) { + return &sessions[i] + } + } + + // 5. Summary substring match + for i := range sessions { + if sessions[i].Summary != "" && strings.Contains(strings.ToLower(sessions[i].Summary), queryLower) { + return &sessions[i] + } + } + + return nil +} + func (e *Engine) cmdName(p Platform, msg *Message, args []string) { if len(args) == 0 { e.reply(p, msg.ReplyCtx, e.i18n.T(MsgNameUsage)) diff --git a/core/markdown_html.go b/core/markdown_html.go index f85322d..a32cca2 100644 --- a/core/markdown_html.go +++ b/core/markdown_html.go @@ -93,10 +93,10 @@ var ( // convertInlineHTML converts inline Markdown formatting to Telegram-compatible HTML. // -// Strategy (matches Claude-to-IM): extract code/links into placeholders first, -// then HTML-escape ALL remaining text, then apply bold/italic/strike regex on -// the escaped text (their markers **, *, ~~ don't contain HTML special chars), -// then restore placeholders. +// Each formatting pass (bold, strikethrough) protects its output as placeholders +// so that subsequent passes (italic) cannot match across HTML tag boundaries. +// Without this, input like `**bold *text***` would produce crossed tags +// `bold text` which Telegram rejects. func convertInlineHTML(s string) string { type placeholder struct { key string @@ -128,15 +128,25 @@ func convertInlineHTML(s string) string { }) // 3. HTML-escape the entire remaining text. - // Placeholders (\x00PH...\x00) contain no HTML special chars so they survive. - // Markdown markers **, *, ~~, __ also survive since they aren't <, >, or &. s = escapeHTML(s) - // 4. Apply formatting on the escaped text. - s = reBoldAstHTML.ReplaceAllString(s, "$1") - s = reBoldUndHTML.ReplaceAllString(s, "$1") - s = reStrikeHTML.ReplaceAllString(s, "$1") + // 4. Bold → placeholder (so italic regex can't cross bold boundaries) + s = reBoldAstHTML.ReplaceAllStringFunc(s, func(m string) string { + inner := m[2 : len(m)-2] + return nextPH("" + inner + "") + }) + s = reBoldUndHTML.ReplaceAllStringFunc(s, func(m string) string { + inner := m[2 : len(m)-2] + return nextPH("" + inner + "") + }) + // 5. Strikethrough → placeholder + s = reStrikeHTML.ReplaceAllStringFunc(s, func(m string) string { + inner := m[2 : len(m)-2] + return nextPH("" + inner + "") + }) + + // 6. Italic (applied last, on text with bold/strike already protected) s = reItalicAstHTML.ReplaceAllStringFunc(s, func(m string) string { idx := strings.Index(m, "*") if idx < 0 { @@ -149,9 +159,18 @@ func convertInlineHTML(s string) string { return m[:idx] + "" + m[idx+1:lastIdx] + "" + m[lastIdx+1:] }) - // 5. Restore placeholders - for _, ph := range phs { - s = strings.Replace(s, ph.key, ph.html, 1) + // 7. Restore all placeholders (may be nested, so iterate until stable) + for i := 0; i < 3; i++ { + changed := false + for _, ph := range phs { + if strings.Contains(s, ph.key) { + s = strings.Replace(s, ph.key, ph.html, 1) + changed = true + } + } + if !changed { + break + } } return s @@ -161,6 +180,7 @@ func escapeHTML(s string) string { s = strings.ReplaceAll(s, "&", "&") s = strings.ReplaceAll(s, "<", "<") s = strings.ReplaceAll(s, ">", ">") + s = strings.ReplaceAll(s, "\"", """) return s } diff --git a/core/markdown_html_test.go b/core/markdown_html_test.go index 0fb17e9..f8e9682 100644 --- a/core/markdown_html_test.go +++ b/core/markdown_html_test.go @@ -1,6 +1,7 @@ package core import ( + "fmt" "strings" "testing" ) @@ -89,6 +90,29 @@ func TestMarkdownToTelegramHTML_LinkWithAmpersand(t *testing.T) { } } +func TestMarkdownToTelegramHTML_LinkWithQuotesInURL(t *testing.T) { + out := MarkdownToTelegramHTML(`visit [book](https://example.com/q="test")`) + if strings.Contains(out, `href="https://example.com/q="`) { + t.Errorf("unescaped quote in href attribute, got %q", out) + } + if !strings.Contains(out, `"`) { + t.Errorf("expected escaped quote in URL, got %q", out) + } + if err := validateHTMLNesting(out); err != nil { + t.Errorf("invalid HTML: %v, got %q", err, out) + } +} + +func TestMarkdownToTelegramHTML_EscapesQuotesInText(t *testing.T) { + out := MarkdownToTelegramHTML(`He said "hello" world`) + if strings.Contains(out, `"hello"`) { + t.Errorf("quotes in text should be escaped, got %q", out) + } + if !strings.Contains(out, `"hello"`) { + t.Errorf("expected " in output, got %q", out) + } +} + func TestMarkdownToTelegramHTML_CodeBlockEscapesHTML(t *testing.T) { md := "```\nif a < b && c > d {\n}\n```" out := MarkdownToTelegramHTML(md) @@ -117,6 +141,69 @@ func TestMarkdownToTelegramHTML_MixedFormattingWithSpecialChars(t *testing.T) { } } +func TestMarkdownToTelegramHTML_NoCrossedTags(t *testing.T) { + tests := []struct { + name string + input string + }{ + {"bold then italic", "**bold *text***"}, + {"italic around bold", "*italic **bold** more*"}, + {"heading with bold", "## **important** heading"}, + {"heading with italic", "## *weather* report"}, + {"mixed line", "**北京** *晴天* 25°C"}, + {"triple star", "***bold italic***"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + out := MarkdownToTelegramHTML(tt.input) + // Check no crossed tags: every must close before enclosing etc. + // Simple check: no inside an block or vice versa + if err := validateHTMLNesting(out); err != nil { + t.Errorf("crossed tags in output %q: %v", out, err) + } + }) + } +} + +func validateHTMLNesting(html string) error { + var stack []string + i := 0 + for i < len(html) { + if html[i] != '<' { + i++ + continue + } + end := strings.Index(html[i:], ">") + if end < 0 { + break + } + tag := html[i+1 : i+end] + i += end + 1 + if strings.HasPrefix(tag, "/") { + closing := tag[1:] + // strip attributes + if sp := strings.IndexByte(closing, ' '); sp > 0 { + closing = closing[:sp] + } + if len(stack) == 0 { + return fmt.Errorf("unexpected closing tag ", closing) + } + top := stack[len(stack)-1] + if top != closing { + return fmt.Errorf("expected , found ", top, closing) + } + stack = stack[:len(stack)-1] + } else { + name := tag + if sp := strings.IndexByte(name, ' '); sp > 0 { + name = name[:sp] + } + stack = append(stack, name) + } + } + return nil +} + func TestSplitMessageCodeFenceAware_Short(t *testing.T) { chunks := SplitMessageCodeFenceAware("hello", 100) if len(chunks) != 1 || chunks[0] != "hello" { diff --git a/core/streaming.go b/core/streaming.go index 5d9bf43..ba23aeb 100644 --- a/core/streaming.go +++ b/core/streaming.go @@ -152,6 +152,7 @@ func (sp *streamPreview) flushLocked(text string) { updater, ok := sp.platform.(MessageUpdater) if !ok { + slog.Debug("stream preview: platform does not support UpdateMessage, degrading") sp.degraded = true return } @@ -159,6 +160,7 @@ func (sp *streamPreview) flushLocked(text string) { if sp.previewMsgID == nil { // First preview: try to send a new preview message if starter, ok := sp.platform.(PreviewStarter); ok { + slog.Debug("stream preview: sending first preview via SendPreviewStart", "text_len", len(text)) handle, err := starter.SendPreviewStart(sp.ctx, sp.replyCtx, text) if err != nil { slog.Debug("stream preview: start failed, degrading", "error", err) @@ -167,14 +169,11 @@ func (sp *streamPreview) flushLocked(text string) { } sp.previewMsgID = handle } else { - // Platform supports UpdateMessage but not PreviewStarter; - // use Send to create initial message, then update in-place if err := sp.platform.Send(sp.ctx, sp.replyCtx, text); err != nil { slog.Debug("stream preview: initial send failed", "error", err) sp.degraded = true return } - // For platforms without PreviewStarter, replyCtx itself serves as the handle sp.previewMsgID = sp.replyCtx } sp.lastSentText = text @@ -183,6 +182,7 @@ func (sp *streamPreview) flushLocked(text string) { } // Update existing preview message + slog.Debug("stream preview: updating via UpdateMessage", "text_len", len(text)) if err := updater.UpdateMessage(sp.ctx, sp.previewMsgID, text); err != nil { slog.Debug("stream preview: update failed, degrading", "error", err) sp.degraded = true @@ -235,24 +235,28 @@ func (sp *streamPreview) finish(finalText string) bool { } if sp.previewMsgID == nil || sp.degraded { + slog.Debug("stream preview finish: no active preview", "hasHandle", sp.previewMsgID != nil, "degraded", sp.degraded) return false } // If platform wants to delete the preview and send fresh, let it if cleaner, ok := sp.platform.(PreviewCleaner); ok { + slog.Debug("stream preview finish: deleting preview (PreviewCleaner)") _ = cleaner.DeletePreviewMessage(sp.ctx, sp.previewMsgID) return false } updater, ok := sp.platform.(MessageUpdater) if !ok { + slog.Debug("stream preview finish: no MessageUpdater") return false } // For very long responses, we may need chunked sending instead maxChars := sp.cfg.MaxChars if maxChars > 0 && len([]rune(finalText)) > maxChars { - // Preview content stays as-is; fall back to chunked send for the full response + slog.Debug("stream preview finish: text exceeds maxChars, falling back to chunked send", + "text_runes", len([]rune(finalText)), "maxChars", maxChars) if sp.lastSentText != "" { _ = updater.UpdateMessage(sp.ctx, sp.previewMsgID, sp.lastSentText) } @@ -260,17 +264,22 @@ func (sp *streamPreview) finish(finalText string) bool { } if finalText == "" { - // Empty final text (error path) — nothing to update - if sp.lastSentText != "" { - _ = updater.UpdateMessage(sp.ctx, sp.previewMsgID, sp.lastSentText) - } + slog.Debug("stream preview finish: empty final text") return false } + // Always call UpdateMessage for the final text, even if the raw text matches + // what was last sent. The preview may have been sent as plain text (via + // SendPreviewStart), while UpdateMessage applies platform-specific formatting + // (e.g., Markdown→HTML for Telegram). Platform implementations should treat + // "message is not modified" as success to avoid duplicate sends. + slog.Debug("stream preview finish: sending final UpdateMessage", + "text_len", len(finalText), "lastSent_len", len(sp.lastSentText), "same", finalText == sp.lastSentText) if err := updater.UpdateMessage(sp.ctx, sp.previewMsgID, finalText); err != nil { - slog.Debug("stream preview: final update failed", "error", err) + slog.Debug("stream preview finish: final update FAILED", "error", err) return false } + slog.Debug("stream preview finish: success via UpdateMessage") return true } diff --git a/platform/telegram/telegram.go b/platform/telegram/telegram.go index c9bb5cc..d2e46e5 100644 --- a/platform/telegram/telegram.go +++ b/platform/telegram/telegram.go @@ -496,14 +496,47 @@ func (p *Platform) UpdateMessage(ctx context.Context, previewHandle any, content if !ok { return fmt.Errorf("telegram: invalid preview handle type %T", previewHandle) } - edit := tgbotapi.NewEditMessageText(h.chatID, h.messageID, content) - _, err := p.bot.Send(edit) - if err != nil { + + html := core.MarkdownToTelegramHTML(content) + slog.Debug("telegram: UpdateMessage", + "content_len", len(content), "html_len", len(html), + "content_prefix", truncateForLog(content, 80), + "html_prefix", truncateForLog(html, 80)) + + edit := tgbotapi.NewEditMessageText(h.chatID, h.messageID, html) + edit.ParseMode = tgbotapi.ModeHTML + + if _, err := p.bot.Send(edit); err != nil { + errMsg := err.Error() + slog.Debug("telegram: UpdateMessage HTML failed", "error", errMsg) + if strings.Contains(errMsg, "not modified") { + return nil + } + if strings.Contains(errMsg, "can't parse") { + slog.Debug("telegram: UpdateMessage falling back to plain text", "full_html", html) + edit.Text = content + edit.ParseMode = "" + if _, err2 := p.bot.Send(edit); err2 != nil { + if strings.Contains(err2.Error(), "not modified") { + return nil + } + return fmt.Errorf("telegram: edit message: %w", err2) + } + return nil + } return fmt.Errorf("telegram: edit message: %w", err) } + slog.Debug("telegram: UpdateMessage HTML success") return nil } +func truncateForLog(s string, maxLen int) string { + if len(s) <= maxLen { + return s + } + return s[:maxLen] + "..." +} + // StartTyping sends a "typing…" chat action and repeats every 5 seconds // until the returned stop function is called. func (p *Platform) StartTyping(ctx context.Context, rctx any) (stop func()) {