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 <noreply@anthropic.com>
This commit is contained in:
chenhg5andClaude committed 2026-03-06 10:30:29 +08:00
1 parent 914198e597
commit e40d033c3f
5 files changed
+231 -42

No files matched your search

+57 -17
View File
@@ -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 <number or id_prefix>")
e.reply(p, msg.ReplyCtx, "Usage: /switch <number | id_prefix | name>")
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))
+33 -13
View File
@@ -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
// `<b>bold <i>text</b></i>` 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, "<b>$1</b>")
s = reBoldUndHTML.ReplaceAllString(s, "<b>$1</b>")
s = reStrikeHTML.ReplaceAllString(s, "<s>$1</s>")
// 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("<b>" + inner + "</b>")
})
s = reBoldUndHTML.ReplaceAllStringFunc(s, func(m string) string {
inner := m[2 : len(m)-2]
return nextPH("<b>" + inner + "</b>")
})
// 5. Strikethrough → placeholder
s = reStrikeHTML.ReplaceAllStringFunc(s, func(m string) string {
inner := m[2 : len(m)-2]
return nextPH("<s>" + inner + "</s>")
})
// 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] + "<i>" + m[idx+1:lastIdx] + "</i>" + 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, "&", "&amp;")
s = strings.ReplaceAll(s, "<", "&lt;")
s = strings.ReplaceAll(s, ">", "&gt;")
s = strings.ReplaceAll(s, "\"", "&quot;")
return s
}
+87
View File
@@ -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, `&quot;`) {
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, `&quot;hello&quot;`) {
t.Errorf("expected &quot; 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 <b> must close before enclosing </i> etc.
// Simple check: no </b> inside an <i> 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 </%s>", closing)
}
top := stack[len(stack)-1]
if top != closing {
return fmt.Errorf("expected </%s>, found </%s>", 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" {
+18 -9
View File
@@ -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
}
+36 -3
View File
@@ -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()) {