Reduce Telegram tool message noise

This commit is contained in:
Codex
2026-05-21 11:20:44 +00:00
parent 139471df31
commit e9425c6d9b
4 changed files with 422 additions and 11 deletions

View File

@@ -60,6 +60,25 @@ func (c *Client) SendMessage(ctx context.Context, chatID int64, text string, opt
return message, nil return message, nil
} }
func (c *Client) SendChatAction(ctx context.Context, chatID int64, action string) error {
params := map[string]any{
"chat_id": chatID,
"action": action,
}
var ok bool
return c.postJSON(ctx, "sendChatAction", params, &ok)
}
func (c *Client) SendMessageDraft(ctx context.Context, chatID int64, draftID int64, text string) error {
params := map[string]any{
"chat_id": chatID,
"draft_id": draftID,
"text": text,
}
var ok bool
return c.postJSON(ctx, "sendMessageDraft", params, &ok)
}
func (c *Client) EditMessageText(ctx context.Context, chatID int64, messageID int, text string, opts EditMessageTextOptions) (Message, error) { func (c *Client) EditMessageText(ctx context.Context, chatID int64, messageID int, text string, opts EditMessageTextOptions) (Message, error) {
params := map[string]any{ params := map[string]any{
"chat_id": chatID, "chat_id": chatID,

View File

@@ -45,6 +45,17 @@ type outputState struct {
chatID int64 chatID int64
assistant strings.Builder assistant strings.Builder
sentAny bool sentAny bool
tools map[string]toolMessageState
workingIndicatorOff context.CancelFunc
}
type toolMessageState struct {
chatID int64
messageID int
toolHTML string
approvalHTML string
approvalMarkup *InlineKeyboardMarkup
editedAt string
} }
type codexThreadItemView struct { type codexThreadItemView struct {
@@ -1039,9 +1050,9 @@ func (b *Bot) handleApprovalCallback(ctx context.Context, callback *CallbackQuer
if err := b.tg.AnswerCallbackQuery(ctx, callback.ID, "Sent to Codex."); err != nil { if err := b.tg.AnswerCallbackQuery(ctx, callback.ID, "Sent to Codex."); err != nil {
return err return err
} }
updated := renderApprovalHTML(approval.Kind, json.RawMessage(approval.PayloadJSON), approvalStatusLine(decision)) updated := b.resolveApprovalMessageHTML(approval, decision)
_, err = b.tg.EditMessageText(ctx, callback.Message.Chat.ID, callback.Message.MessageID, updated, EditMessageTextOptions{ParseMode: "HTML"}) _, err = b.tg.EditMessageText(ctx, callback.Message.Chat.ID, callback.Message.MessageID, updated, EditMessageTextOptions{ParseMode: "HTML"})
return err return ignoreTelegramMessageNotModified(err)
} }
func (b *Bot) handleCodexEvents(ctx context.Context) { func (b *Bot) handleCodexEvents(ctx context.Context) {
@@ -1288,7 +1299,7 @@ func (b *Bot) handleCodexNotification(ctx context.Context, event codexapp.Event)
return b.flushAssistantMessage(ctx, params.ThreadID) return b.flushAssistantMessage(ctx, params.ThreadID)
} }
if params.ThreadID != "" { if params.ThreadID != "" {
return b.sendOutputHTMLBlock(ctx, params.ThreadID, renderCodexItemStarted(item)) return b.upsertToolMessage(ctx, params.ThreadID, item.ID, renderCodexItemStarted(item))
} }
case "item/agentMessage/delta": case "item/agentMessage/delta":
var params struct { var params struct {
@@ -1322,7 +1333,7 @@ func (b *Bot) handleCodexNotification(ctx context.Context, event codexapp.Event)
return b.flushAssistantMessage(ctx, params.ThreadID) return b.flushAssistantMessage(ctx, params.ThreadID)
} }
if params.ThreadID != "" { if params.ThreadID != "" {
return b.sendOutputHTMLBlock(ctx, params.ThreadID, renderCodexItemCompleted(item)) return b.upsertToolMessage(ctx, params.ThreadID, item.ID, renderCodexItemCompleted(item))
} }
case "turn/diff/updated": case "turn/diff/updated":
var params struct { var params struct {
@@ -1424,9 +1435,16 @@ func (b *Bot) handleCodexServerRequest(ctx context.Context, event codexapp.Event
return err return err
} }
text := renderApprovalHTML(kind, event.Params, "") text := renderApprovalHTML(kind, event.Params, "")
markup := approvalMarkup(approval.ID)
if msg, ok, err := b.attachApprovalToToolMessage(ctx, params.ThreadID, params.ItemID, text, markup); err != nil {
return err
} else if ok {
return b.store.UpdatePendingApprovalMessage(ctx, approval.ID, msg.Chat.ID, msg.MessageID)
}
msg, err := b.tg.SendMessage(ctx, thread.TelegramUserID, text, SendMessageOptions{ msg, err := b.tg.SendMessage(ctx, thread.TelegramUserID, text, SendMessageOptions{
ParseMode: "HTML", ParseMode: "HTML",
ReplyMarkup: approvalMarkup(approval.ID), ReplyMarkup: markup,
}) })
if err != nil { if err != nil {
return err return err
@@ -1434,16 +1452,73 @@ func (b *Bot) handleCodexServerRequest(ctx context.Context, event codexapp.Event
return b.store.UpdatePendingApprovalMessage(ctx, approval.ID, msg.Chat.ID, msg.MessageID) return b.store.UpdatePendingApprovalMessage(ctx, approval.ID, msg.Chat.ID, msg.MessageID)
} }
func (b *Bot) newOutputState(chatID int64) *outputState {
return &outputState{
chatID: chatID,
tools: make(map[string]toolMessageState),
workingIndicatorOff: b.startWorkingIndicator(chatID),
}
}
func (b *Bot) registerOutput(threadID string, chatID int64) { func (b *Bot) registerOutput(threadID string, chatID int64) {
b.mu.Lock() b.mu.Lock()
defer b.mu.Unlock() defer b.mu.Unlock()
b.outputs[threadID] = &outputState{chatID: chatID} if state := b.outputs[threadID]; state != nil && state.workingIndicatorOff != nil {
state.workingIndicatorOff()
}
b.outputs[threadID] = b.newOutputState(chatID)
} }
func (b *Bot) clearOutput(threadID string) { func (b *Bot) clearOutput(threadID string) {
b.mu.Lock() b.mu.Lock()
defer b.mu.Unlock() state := b.outputs[threadID]
delete(b.outputs, threadID) delete(b.outputs, threadID)
b.mu.Unlock()
if state != nil && state.workingIndicatorOff != nil {
state.workingIndicatorOff()
}
}
func (b *Bot) startWorkingIndicator(chatID int64) context.CancelFunc {
ctx, cancel := context.WithCancel(context.Background())
draftID := time.Now().UnixNano()
go func() {
useDraft := true
sendDraft := func() bool {
return b.tg.SendMessageDraft(ctx, chatID, draftID, "") == nil
}
sendTyping := func() {
if err := b.tg.SendChatAction(ctx, chatID, "typing"); err != nil && ctx.Err() == nil {
b.logger.Printf("send typing action: %v", err)
}
}
if !sendDraft() {
useDraft = false
sendTyping()
}
draftTicker := time.NewTicker(25 * time.Second)
typingTicker := time.NewTicker(4 * time.Second)
defer draftTicker.Stop()
defer typingTicker.Stop()
for {
select {
case <-ctx.Done():
return
case <-draftTicker.C:
if useDraft && !sendDraft() {
useDraft = false
sendTyping()
}
case <-typingTicker.C:
if !useDraft {
sendTyping()
}
}
}
}()
return cancel
} }
func (b *Bot) hasAssistantText(threadID string) bool { func (b *Bot) hasAssistantText(threadID string) bool {
@@ -1513,6 +1588,182 @@ func (b *Bot) sendOutputHTMLBlock(ctx context.Context, threadID, htmlText string
return nil return nil
} }
func (s toolMessageState) html() string {
return FitHTMLMessage(addEditedAtLine(combineToolApprovalHTML(s.toolHTML, s.approvalHTML), s.editedAt), TelegramHTMLMessageLimit)
}
func combineToolApprovalHTML(toolHTML, approvalHTML string) string {
toolHTML = strings.TrimSpace(toolHTML)
approvalHTML = strings.TrimSpace(approvalHTML)
switch {
case toolHTML == "":
return approvalHTML
case approvalHTML == "":
return toolHTML
default:
return toolHTML + "\n\n" + approvalHTML
}
}
func addEditedAtLine(htmlText, editedAt string) string {
htmlText = strings.TrimSpace(htmlText)
if htmlText == "" || editedAt == "" {
return htmlText
}
line := EscapeHTML("Edited at: " + editedAt)
quoteIndex := strings.Index(htmlText, "<blockquote expandable>")
if quoteIndex < 0 {
return htmlText + "\n" + line
}
summary := strings.TrimRight(htmlText[:quoteIndex], "\n")
details := strings.TrimLeft(htmlText[quoteIndex:], "\n")
if summary == "" {
return line + "\n" + details
}
return summary + "\n" + line + "\n" + details
}
func editedAtTimestamp() string {
return time.Now().UTC().Format("2006-01-02 15:04:05 MST")
}
func (b *Bot) upsertToolMessage(ctx context.Context, threadID, itemID, htmlText string) error {
htmlText = strings.TrimSpace(htmlText)
if htmlText == "" {
return nil
}
if itemID == "" {
return b.sendOutputHTMLBlock(ctx, threadID, htmlText)
}
if err := b.flushAssistantMessage(ctx, threadID); err != nil {
return err
}
chatID, err := b.outputChatID(ctx, threadID)
if err != nil {
return nil
}
b.mu.Lock()
state := b.outputs[threadID]
if state != nil && state.tools == nil {
state.tools = make(map[string]toolMessageState)
}
if state != nil {
tool, ok := state.tools[itemID]
if ok && tool.messageID != 0 {
tool.toolHTML = htmlText
tool.editedAt = editedAtTimestamp()
state.tools[itemID] = tool
combined := tool.html()
msgChatID := tool.chatID
msgID := tool.messageID
markup := tool.approvalMarkup
b.mu.Unlock()
_, err := b.tg.EditMessageText(ctx, msgChatID, msgID, combined, EditMessageTextOptions{ParseMode: "HTML", ReplyMarkup: markup})
if err := ignoreTelegramMessageNotModified(err); err != nil {
return err
}
b.markOutputSent(threadID)
return nil
}
}
b.mu.Unlock()
msg, err := b.sendHTMLMessage(ctx, chatID, htmlText, nil)
if err != nil {
return err
}
b.mu.Lock()
state = b.outputs[threadID]
if state != nil {
if state.tools == nil {
state.tools = make(map[string]toolMessageState)
}
state.tools[itemID] = toolMessageState{chatID: msg.Chat.ID, messageID: msg.MessageID, toolHTML: htmlText}
}
b.mu.Unlock()
b.markOutputSent(threadID)
return nil
}
func (b *Bot) attachApprovalToToolMessage(ctx context.Context, threadID, itemID, approvalHTML string, markup *InlineKeyboardMarkup) (Message, bool, error) {
approvalHTML = strings.TrimSpace(approvalHTML)
if threadID == "" || itemID == "" || approvalHTML == "" {
return Message{}, false, nil
}
if err := b.flushAssistantMessage(ctx, threadID); err != nil {
return Message{}, false, err
}
b.mu.Lock()
state := b.outputs[threadID]
if state == nil {
b.mu.Unlock()
return Message{}, false, nil
}
tool, ok := state.tools[itemID]
if !ok || tool.messageID == 0 {
b.mu.Unlock()
return Message{}, false, nil
}
tool.approvalHTML = approvalHTML
tool.approvalMarkup = markup
tool.editedAt = editedAtTimestamp()
state.tools[itemID] = tool
combined := tool.html()
msg := Message{MessageID: tool.messageID, Chat: Chat{ID: tool.chatID}}
b.mu.Unlock()
_, err := b.tg.EditMessageText(ctx, msg.Chat.ID, msg.MessageID, combined, EditMessageTextOptions{ParseMode: "HTML", ReplyMarkup: markup})
if err := ignoreTelegramMessageNotModified(err); err != nil {
b.clearToolApproval(threadID, itemID)
b.logger.Printf("edit tool approval message %s/%s: %v", threadID, itemID, err)
return Message{}, false, nil
}
b.markOutputSent(threadID)
return msg, true, nil
}
func (b *Bot) clearToolApproval(threadID, itemID string) {
b.mu.Lock()
defer b.mu.Unlock()
if state := b.outputs[threadID]; state != nil {
if tool, ok := state.tools[itemID]; ok {
tool.approvalHTML = ""
tool.approvalMarkup = nil
state.tools[itemID] = tool
}
}
}
func (b *Bot) resolveApprovalMessageHTML(approval store.PendingApproval, decision string) string {
approvalHTML := renderApprovalHTML(approval.Kind, json.RawMessage(approval.PayloadJSON), approvalStatusLine(decision))
if approval.ItemID == "" {
return approvalHTML
}
b.mu.Lock()
defer b.mu.Unlock()
state := b.outputs[approval.CodexThreadID]
if state == nil {
return approvalHTML
}
tool, ok := state.tools[approval.ItemID]
if !ok || tool.messageID == 0 || tool.messageID != approval.MessageID {
return approvalHTML
}
tool.approvalHTML = approvalHTML
tool.approvalMarkup = nil
tool.editedAt = editedAtTimestamp()
state.tools[approval.ItemID] = tool
return tool.html()
}
func ignoreTelegramMessageNotModified(err error) error {
if err != nil && strings.Contains(err.Error(), "message is not modified") {
return nil
}
return err
}
func (b *Bot) appendAssistantDelta(ctx context.Context, threadID, delta string) error { func (b *Bot) appendAssistantDelta(ctx context.Context, threadID, delta string) error {
if delta == "" { if delta == "" {
return nil return nil
@@ -1560,8 +1811,12 @@ func (b *Bot) completeTurnOutput(ctx context.Context, threadID string) error {
} }
chatID := state.chatID chatID := state.chatID
sentAny := state.sentAny sentAny := state.sentAny
workingIndicatorOff := state.workingIndicatorOff
delete(b.outputs, threadID) delete(b.outputs, threadID)
b.mu.Unlock() b.mu.Unlock()
if workingIndicatorOff != nil {
workingIndicatorOff()
}
if !sentAny { if !sentAny {
_, err := b.tg.SendMessage(ctx, chatID, "Done.", SendMessageOptions{}) _, err := b.tg.SendMessage(ctx, chatID, "Done.", SendMessageOptions{})
@@ -1606,10 +1861,14 @@ func (b *Bot) sendLong(ctx context.Context, chatID int64, text string) error {
} }
func (b *Bot) sendHTML(ctx context.Context, chatID int64, htmlText string) error { func (b *Bot) sendHTML(ctx context.Context, chatID int64, htmlText string) error {
_, err := b.tg.SendMessage(ctx, chatID, htmlText, SendMessageOptions{ParseMode: "HTML"}) _, err := b.sendHTMLMessage(ctx, chatID, htmlText, nil)
return err return err
} }
func (b *Bot) sendHTMLMessage(ctx context.Context, chatID int64, htmlText string, markup *InlineKeyboardMarkup) (Message, error) {
return b.tg.SendMessage(ctx, chatID, htmlText, SendMessageOptions{ParseMode: "HTML", ReplyMarkup: markup})
}
func (b *Bot) sendError(ctx context.Context, chatID int64, prefix string, err error) error { func (b *Bot) sendError(ctx context.Context, chatID int64, prefix string, err error) error {
_, sendErr := b.tg.SendMessage(ctx, chatID, EscapeHTML(prefix+": "+err.Error()), SendMessageOptions{ParseMode: "HTML"}) _, sendErr := b.tg.SendMessage(ctx, chatID, EscapeHTML(prefix+": "+err.Error()), SendMessageOptions{ParseMode: "HTML"})
return sendErr return sendErr

View File

@@ -66,6 +66,108 @@ func SummaryDetailsHTMLLimited(summary, details string, limit int) string {
return SummaryDetailsHTML(summary, suffix) return SummaryDetailsHTML(summary, suffix)
} }
func FitHTMLMessage(htmlText string, limit int) string {
if limit <= 0 {
limit = TelegramHTMLMessageLimit
}
htmlText = strings.TrimSpace(htmlText)
if len([]rune(htmlText)) <= limit {
return htmlText
}
const open = "<blockquote expandable>"
const close = "</blockquote>"
const truncatedQuote = "...[truncated]"
for len([]rune(htmlText)) > limit {
contentStart, contentEnd, content := largestBlockquoteContent(htmlText, open, close)
if contentStart < 0 {
return truncateHTMLText(htmlText, limit)
}
contentRunes := []rune(strings.TrimSpace(html.UnescapeString(content)))
over := len([]rune(htmlText)) - limit
keep := len(contentRunes) - over - 80
if keep < 0 {
keep = 0
}
if keep >= len(contentRunes) {
keep = len(contentRunes) - 1
}
replacementText := truncatedQuote
if keep > 0 {
replacementText = strings.TrimSpace(string(contentRunes[:keep])) + "\n" + truncatedQuote
}
replacement := EscapeHTML(replacementText)
if replacement == content {
return summaryOnlyHTML(htmlText, limit)
}
htmlText = htmlText[:contentStart] + replacement + htmlText[contentEnd:]
}
return htmlText
}
func largestBlockquoteContent(htmlText, open, close string) (int, int, string) {
bestStart := -1
bestEnd := -1
bestContent := ""
searchFrom := 0
for {
start := strings.Index(htmlText[searchFrom:], open)
if start < 0 {
break
}
start += searchFrom
contentStart := start + len(open)
end := strings.Index(htmlText[contentStart:], close)
if end < 0 {
break
}
end += contentStart
content := htmlText[contentStart:end]
if bestStart < 0 || len([]rune(content)) > len([]rune(bestContent)) {
bestStart = contentStart
bestEnd = end
bestContent = content
}
searchFrom = end + len(close)
}
return bestStart, bestEnd, bestContent
}
func summaryOnlyHTML(htmlText string, limit int) string {
const open = "<blockquote expandable>"
const close = "</blockquote>"
for {
start := strings.Index(htmlText, open)
if start < 0 {
break
}
end := strings.Index(htmlText[start+len(open):], close)
if end < 0 {
break
}
end += start + len(open) + len(close)
htmlText = strings.TrimRight(htmlText[:start], "\n") + "\n" + strings.TrimLeft(htmlText[end:], "\n")
}
return truncateHTMLText(strings.TrimSpace(htmlText), limit)
}
func truncateHTMLText(htmlText string, limit int) string {
if limit <= 0 {
limit = TelegramHTMLMessageLimit
}
suffix := "\n...[truncated]"
runes := []rune(htmlText)
if len(runes) <= limit {
return htmlText
}
keep := limit - len([]rune(suffix))
if keep < 0 {
keep = 0
}
return string(runes[:keep]) + suffix
}
func ChunkText(text string, max int) []string { func ChunkText(text string, max int) []string {
if max <= 0 { if max <= 0 {
max = TelegramMessageLimit max = TelegramMessageLimit

View File

@@ -101,6 +101,37 @@ func TestRenderCodexStartedItems(t *testing.T) {
} }
} }
func TestToolMessageAddsEditedAtBeforeDetails(t *testing.T) {
tool := toolMessageState{
toolHTML: SummaryDetailsHTML("Tool call: command finished\nCommand: go test ./...", "full output"),
editedAt: "2026-05-21 12:34:56 UTC",
}
text := tool.html()
want := "Command: go test ./...\nEdited at: 2026-05-21 12:34:56 UTC\n<blockquote expandable>"
if !strings.Contains(text, want) {
t.Fatalf("edited timestamp not placed before details: %q", text)
}
}
func TestToolMessageFitsCombinedApprovalDetails(t *testing.T) {
largeToolDetails := strings.Repeat("tool output line\n", 600)
largeApprovalDetails := strings.Repeat("approval payload line\n", 600)
tool := toolMessageState{
toolHTML: SummaryDetailsHTML("Tool call: command finished", largeToolDetails),
approvalHTML: SummaryDetailsHTML("Codex requests command approval", largeApprovalDetails),
editedAt: "2026-05-21 12:34:56 UTC",
}
text := tool.html()
if len([]rune(text)) > TelegramHTMLMessageLimit {
t.Fatalf("tool message exceeds Telegram limit: %d", len([]rune(text)))
}
for _, want := range []string{"Tool call: command finished", "Codex requests command approval", "Edited at: 2026-05-21 12:34:56 UTC", "...[truncated]"} {
if !strings.Contains(text, want) {
t.Fatalf("fitted tool message missing %q in %q", want, text)
}
}
}
func TestResumeCallbackData(t *testing.T) { func TestResumeCallbackData(t *testing.T) {
threadID, ok := ParseResumeThreadCallbackData(ResumeThreadCallbackData(123)) threadID, ok := ParseResumeThreadCallbackData(ResumeThreadCallbackData(123))
if !ok || threadID != 123 { if !ok || threadID != 123 {