From 2b0da9f508d4eaddca77eb6391058231b500ac68 Mon Sep 17 00:00:00 2001 From: Codex Date: Thu, 28 May 2026 09:39:40 +0000 Subject: [PATCH] Support Codex 0.134 approvals Use available approval decisions from the app-server schema, preserve structured policy decisions in callbacks, and keep approval rendering aligned with normal tool-call output. Also simplify thread commands, clear stale active turns more carefully, and update command/help docs. --- PLAN.md | 4 +- README.md | 2 +- internal/store/store.go | 37 ++ internal/store/store_test.go | 24 ++ internal/telegram/api.go | 5 + internal/telegram/bot.go | 612 ++++++++++++++++++++++++------- internal/telegram/render.go | 127 ++++++- internal/telegram/render_test.go | 144 +++++++- internal/telegram/types.go | 5 + 9 files changed, 813 insertions(+), 147 deletions(-) diff --git a/PLAN.md b/PLAN.md index a9506be..e4a790f 100644 --- a/PLAN.md +++ b/PLAN.md @@ -52,7 +52,7 @@ Before implementation, carefully read the official docs and check against the gu - Telegram UX: - One-to-one chats only; reject groups, supergroups, and channels. - Allowlisted Telegram user IDs only. - - Commands: `/start`, `/help`, `/new`, `/threads`, `/resume`, `/fork`, `/archive`, `/status`, `/cancel`, `/workspaces`, `/workspace`, `/model`, `/sandbox`, `/diff`. + - Commands: `/start`, `/help`, `/new`, `/thread`, `/fork`, `/archive`, `/status`, `/cancel`, `/workspaces`, `/workspace`, `/model`, `/sandbox`, `/diff`. - Plain text continues the active Codex thread, creating one if needed. - Send assistant messages and rendered tool/status blocks as separate Telegram messages; chunk only when a single message exceeds Telegram limits. - Send long output, logs, and diffs as chunked messages. @@ -77,7 +77,7 @@ Any interactive related test that requires user action should be done properly - - Integration tests: - Use a low/mini model in codex for testing. - Verify initialize, thread start, turn start, streamed output, approval, and cancellation. - - Verify `/start`, `/new`, `/threads`, `/resume`, `/workspace`, `/cancel`, image input, and document staging. + - Verify `/start`, `/new`, `/thread`, `/workspace`, `/cancel`, image input, and document staging. - Verify non-allowlisted users are rejected and logged. - Manual acceptance: diff --git a/README.md b/README.md index 5342a60..b2f263e 100644 --- a/README.md +++ b/README.md @@ -38,7 +38,7 @@ Docker Compose runs only the Go Telegram bot. Codex runs on the host through `co The bot accepts one-to-one chats from allowlisted Telegram user IDs only. It rejects group, supergroup, and channel updates in code. -Supported commands: `/start`, `/help`, `/new`, `/threads`, `/resume`, `/rename`, `/fork`, `/archive`, `/status`, `/cancel`, `/workspaces`, `/workspace`, `/model`, `/sandbox`, `/pic`, `/diff`. `/model` lists available Codex models as inline buttons, then shows reasoning-effort buttons for the selected model. +Supported commands: `/start`, `/help`, `/new`, `/thread`, `/rename`, `/fork`, `/archive`, `/status`, `/cancel`, `/workspaces`, `/workspace`, `/model`, `/sandbox`, `/pic`, `/diff`. `/model` lists available Codex models as inline buttons, then shows reasoning-effort buttons for the selected model. Plain text continues the active Codex thread and creates one if needed. `/pic PROMPT` starts a dedicated Codex image-generation turn and sends generated images back as Telegram photos. Telegram images are staged under `HOST_UPLOAD_DIR` and sent as `localImage` inputs. Other uploaded documents are staged and passed to Codex as host-visible file paths. diff --git a/internal/store/store.go b/internal/store/store.go index 7edc692..63a87b8 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -44,6 +44,12 @@ type Session struct { UpdatedAt string } +type ActiveTurn struct { + TelegramUserID int64 + CodexThreadID string + TurnID string +} + type Thread struct { ID int64 TelegramUserID int64 @@ -338,11 +344,42 @@ func (s *Store) SetActiveTurn(ctx context.Context, telegramUserID int64, turnID return err } +func (s *Store) ClearActiveTurn(ctx context.Context, telegramUserID int64, turnID string) error { + if strings.TrimSpace(turnID) == "" { + return s.SetActiveTurn(ctx, telegramUserID, "") + } + _, err := s.db.ExecContext(ctx, "UPDATE sessions SET active_turn_id = '', updated_at = datetime('now') WHERE telegram_user_id = ? AND active_turn_id = ?", telegramUserID, turnID) + return err +} + func (s *Store) ClearActiveTurns(ctx context.Context) error { _, err := s.db.ExecContext(ctx, "UPDATE sessions SET active_turn_id = '', updated_at = datetime('now') WHERE active_turn_id <> ''") return err } +func (s *Store) ListActiveTurns(ctx context.Context) ([]ActiveTurn, error) { + rows, err := s.db.QueryContext(ctx, ` +SELECT s.telegram_user_id, t.codex_thread_id, s.active_turn_id +FROM sessions s +JOIN threads t ON t.id = s.active_thread_id AND t.telegram_user_id = s.telegram_user_id +WHERE s.active_turn_id <> '' +ORDER BY s.updated_at`) + if err != nil { + return nil, err + } + defer rows.Close() + + var turns []ActiveTurn + for rows.Next() { + var turn ActiveTurn + if err := rows.Scan(&turn.TelegramUserID, &turn.CodexThreadID, &turn.TurnID); err != nil { + return nil, err + } + turns = append(turns, turn) + } + return turns, rows.Err() +} + func (s *Store) CreateThread(ctx context.Context, telegramUserID int64, codexThreadID string, workspaceID int64, title string) (Thread, error) { result, err := s.db.ExecContext(ctx, ` INSERT INTO threads (telegram_user_id, codex_thread_id, workspace_id, title) diff --git a/internal/store/store_test.go b/internal/store/store_test.go index bf477e4..464c669 100644 --- a/internal/store/store_test.go +++ b/internal/store/store_test.go @@ -72,9 +72,33 @@ func TestStoreUsersWorkspacesSessions(t *testing.T) { if session.SettingsChatID != 1001 || session.SettingsMessageID != 2002 { t.Fatalf("settings message not saved: %+v", session) } + thread, err := st.CreateThread(ctx, 42, "codex-thread-123", ws.ID, "test thread") + if err != nil { + t.Fatal(err) + } + if err := st.SetActiveThread(ctx, 42, thread.ID); err != nil { + t.Fatal(err) + } if err := st.SetActiveTurn(ctx, 42, "turn-123"); err != nil { t.Fatal(err) } + turns, err := st.ListActiveTurns(ctx) + if err != nil { + t.Fatal(err) + } + if len(turns) != 1 || turns[0].TelegramUserID != 42 || turns[0].CodexThreadID != "codex-thread-123" || turns[0].TurnID != "turn-123" { + t.Fatalf("active turns not listed: %+v", turns) + } + if err := st.ClearActiveTurn(ctx, 42, "other-turn"); err != nil { + t.Fatal(err) + } + session, err = st.GetSession(ctx, 42) + if err != nil { + t.Fatal(err) + } + if session.ActiveTurnID != "turn-123" { + t.Fatalf("wrong turn cleared: %+v", session) + } if err := st.ClearActiveTurns(ctx); err != nil { t.Fatal(err) } diff --git a/internal/telegram/api.go b/internal/telegram/api.go index a582750..84e26c6 100644 --- a/internal/telegram/api.go +++ b/internal/telegram/api.go @@ -50,6 +50,11 @@ func (c *Client) redact(text string) string { return strings.ReplaceAll(text, c.token, "") } +func (c *Client) SetMyCommands(ctx context.Context, commands []BotCommand) error { + var ok bool + return c.postJSON(ctx, "setMyCommands", map[string]any{"commands": commands}, &ok) +} + func (c *Client) GetUpdates(ctx context.Context, offset int, timeoutSeconds int) ([]Update, error) { params := map[string]any{ "offset": offset, diff --git a/internal/telegram/bot.go b/internal/telegram/bot.go index 8879a59..d3290b1 100644 --- a/internal/telegram/bot.go +++ b/internal/telegram/bot.go @@ -70,6 +70,7 @@ type assistantThreadCWDDirective struct { } type outputState struct { + turnID string chatID int64 assistant strings.Builder sentAny bool @@ -137,9 +138,15 @@ func NewBot(tg *Client, st *store.Store, codex *codexapp.Client, uploadDir, code } func (b *Bot) Run(ctx context.Context) error { + if err := b.interruptStoredActiveTurns(ctx); err != nil { + return err + } if err := b.store.ClearActiveTurns(ctx); err != nil { return err } + if err := b.tg.SetMyCommands(ctx, botCommands()); err != nil { + b.logger.Printf("set telegram commands: %v", err) + } go b.handleCodexEvents(ctx) offset := 0 @@ -165,6 +172,60 @@ func (b *Bot) Run(ctx context.Context) error { return ctx.Err() } +func (b *Bot) interruptStoredActiveTurns(ctx context.Context) error { + turns, err := b.store.ListActiveTurns(ctx) + if err != nil { + return err + } + if len(turns) == 0 { + return nil + } + interruptCtx, cancel := context.WithTimeout(ctx, 3*time.Second) + defer cancel() + for _, turn := range turns { + if strings.TrimSpace(turn.CodexThreadID) == "" || strings.TrimSpace(turn.TurnID) == "" { + continue + } + if err := b.codex.InterruptTurn(interruptCtx, turn.CodexThreadID, turn.TurnID); err != nil { + b.logger.Printf("interrupt stale active turn %s/%s: %v", turn.CodexThreadID, turn.TurnID, err) + } + } + return nil +} + +func (b *Bot) clearStaleActiveTurn(ctx context.Context, userID int64, thread store.Thread, turnID string) { + turnID = strings.TrimSpace(turnID) + if turnID == "" { + return + } + interruptCtx, cancel := context.WithTimeout(ctx, 3*time.Second) + if err := b.codex.InterruptTurn(interruptCtx, thread.CodexThreadID, turnID); err != nil { + b.logger.Printf("interrupt stale active turn %s/%s: %v", thread.CodexThreadID, turnID, err) + } + cancel() + if err := b.store.ClearActiveTurn(ctx, userID, turnID); err != nil { + b.logger.Printf("clear stale active turn %s for user %d: %v", turnID, userID, err) + } +} + +func botCommands() []BotCommand { + return []BotCommand{ + {Command: "new", Description: "Start a new thread"}, + {Command: "thread", Description: "List or switch threads"}, + {Command: "rename", Description: "Rename a thread"}, + {Command: "fork", Description: "Fork the active thread"}, + {Command: "archive", Description: "Archive a thread"}, + {Command: "status", Description: "Show active settings"}, + {Command: "cancel", Description: "Interrupt the active turn"}, + {Command: "workspace", Description: "Select workspace"}, + {Command: "model", Description: "Choose model"}, + {Command: "sandbox", Description: "Show or set sandbox"}, + {Command: "pic", Description: "Generate images"}, + {Command: "diff", Description: "Show latest diff"}, + {Command: "help", Description: "Show help"}, + } +} + func (b *Bot) handleUpdate(ctx context.Context, update Update) error { switch { case update.Message != nil: @@ -224,10 +285,10 @@ func (b *Bot) handleCommand(ctx context.Context, message *Message, session store case "new": _, _, err := b.createNewThread(ctx, userID, chatID, session, true) return true, err - case "thread", "threads": - return true, b.sendThreads(ctx, userID, chatID) - case "resume": - return true, b.resumeThread(ctx, userID, chatID, args) + case "thread": + return true, b.threadCommand(ctx, userID, chatID, args) + case "threads", "resume": + return true, b.legacyThreadCommand(ctx, userID, chatID, args) case "rename": return true, b.renameThread(ctx, userID, chatID, session, args) case "fork": @@ -261,9 +322,8 @@ func (b *Bot) sendHelp(ctx context.Context, chatID int64) error { "Codex Telegram Bot", "", "/new - start a new Codex thread", - "/thread or /threads - list recent threads", - "/resume - choose a recent thread", - "/resume ID - resume a thread", + "/thread - list recent threads", + "/thread ID - switch to a thread", "/rename TITLE or /rename ID TITLE - rename a thread", "/fork - fork the active thread", "/archive [ID] - archive a thread", @@ -281,16 +341,12 @@ func (b *Bot) sendHelp(ctx context.Context, chatID int64) error { return b.sendLong(ctx, chatID, text) } -func (b *Bot) sendThreads(ctx context.Context, userID, chatID int64) error { - return b.sendResumeChoices(ctx, userID, chatID, 0, 0) -} - -func (b *Bot) resumeThread(ctx context.Context, userID, chatID int64, args []string) error { +func (b *Bot) threadCommand(ctx context.Context, userID, chatID int64, args []string) error { if len(args) == 0 { return b.sendResumeChoices(ctx, userID, chatID, 0, 0) } if len(args) != 1 { - _, err := b.tg.SendMessage(ctx, chatID, "Use /resume to choose a thread, or /resume ID to resume directly.", SendMessageOptions{}) + _, err := b.tg.SendMessage(ctx, chatID, "Use /thread to choose a thread, or /thread ID to switch directly.", SendMessageOptions{}) return err } id, err := strconv.ParseInt(args[0], 10, 64) @@ -301,6 +357,13 @@ func (b *Bot) resumeThread(ctx context.Context, userID, chatID int64, args []str return b.resumeThreadByID(ctx, userID, chatID, id, 0) } +func (b *Bot) legacyThreadCommand(ctx context.Context, userID, chatID int64, args []string) error { + if len(args) == 0 { + return b.sendResumeChoices(ctx, userID, chatID, 0, 0) + } + return b.threadCommand(ctx, userID, chatID, args) +} + func (b *Bot) sendResumeChoices(ctx context.Context, userID, chatID int64, page int, messageID int) error { if page < 0 { page = 0 @@ -775,19 +838,24 @@ func (b *Bot) continueThread(ctx context.Context, message *Message, session stor return err } if session.ActiveTurnID != "" { - if err := b.codex.SteerTurn(ctx, thread.CodexThreadID, session.ActiveTurnID, input); err != nil { - return b.sendError(ctx, chatID, "Could not append to active turn", err) + if b.hasOutputTurn(thread.CodexThreadID, session.ActiveTurnID) { + if err := b.codex.SteerTurn(ctx, thread.CodexThreadID, session.ActiveTurnID, input); err != nil { + return b.sendError(ctx, chatID, "Could not append to active turn", err) + } + _, err := b.tg.SendMessage(ctx, chatID, "Added to the running turn.", SendMessageOptions{}) + return err } - _, err := b.tg.SendMessage(ctx, chatID, "Added to the running turn.", SendMessageOptions{}) - return err + b.clearStaleActiveTurn(ctx, userID, thread, session.ActiveTurnID) + session.ActiveTurnID = "" } - b.registerOutput(thread.CodexThreadID, chatID) + b.registerOutput(thread.CodexThreadID, "", chatID) turn, err := b.codex.StartTurn(ctx, thread.CodexThreadID, "", session.Model, session.ReasoningEffort, session.Sandbox, input) if err != nil { b.clearOutput(thread.CodexThreadID) return b.sendError(ctx, chatID, "Codex turn failed", err) } + b.setOutputTurnID(thread.CodexThreadID, turn.ID) if err := b.store.SetActiveTurn(ctx, userID, turn.ID); err != nil { return err } @@ -943,21 +1011,26 @@ func (b *Bot) handlePictureCommand(ctx context.Context, userID, chatID int64, se _, err := b.tg.SendMessage(ctx, chatID, "Use /pic PROMPT to generate image(s).", SendMessageOptions{}) return err } - if session.ActiveTurnID != "" { - _, err := b.tg.SendMessage(ctx, chatID, "A Codex turn is already running. Use /cancel first, or wait for it to finish.", SendMessageOptions{}) - return err - } thread, _, err := b.ensureThreadForPicture(ctx, userID, chatID, session) if err != nil { return err } + if session.ActiveTurnID != "" { + if b.hasOutputTurn(thread.CodexThreadID, session.ActiveTurnID) { + _, err := b.tg.SendMessage(ctx, chatID, "A Codex turn is already running. Use /cancel first, or wait for it to finish.", SendMessageOptions{}) + return err + } + b.clearStaleActiveTurn(ctx, userID, thread, session.ActiveTurnID) + session.ActiveTurnID = "" + } input := []codexapp.InputItem{{Type: "text", Text: pictureGenerationInstruction(prompt)}} - b.registerPictureOutput(thread.CodexThreadID, chatID) + b.registerPictureOutput(thread.CodexThreadID, "", chatID) turn, err := b.codex.StartTurn(ctx, thread.CodexThreadID, "", session.Model, session.ReasoningEffort, session.Sandbox, input) if err != nil { b.clearOutput(thread.CodexThreadID) return b.sendError(ctx, chatID, "Codex image generation failed", err) } + b.setOutputTurnID(thread.CodexThreadID, turn.ID) if err := b.store.SetActiveTurn(ctx, userID, turn.ID); err != nil { return err } @@ -1196,7 +1269,7 @@ func parseCodexThreadItem(raw json.RawMessage) (codexThreadItemView, error) { func renderCodexItemStarted(item codexThreadItemView) string { switch item.Type { case "commandExecution": - return SummaryDetailsRawHTMLLimited("Tool call: command started", commandStartedDetailsHTML(item), TelegramHTMLMessageLimit) + return SummaryRawHTMLSectionsLimited("Tool call: command started", commandExecutionSectionsHTML(item, ""), TelegramHTMLMessageLimit) case "fileChange": return "Tool call: file change started" case "mcpToolCall": @@ -1219,11 +1292,7 @@ func renderCodexItemStarted(item codexThreadItemView) string { func renderCodexItemCompleted(item codexThreadItemView) string { switch item.Type { case "commandExecution": - status := "" - if item.ExitCode != nil { - status = fmt.Sprintf("Exit code: %d", *item.ExitCode) - } - return SummaryDetailsRawHTMLLimited(joinNonEmpty("Tool call: command finished", status), renderCodexItemDetailsHTML(item), TelegramHTMLMessageLimit) + return SummaryRawHTMLSectionsLimited("Tool call: command finished", commandExecutionSectionsHTML(item, ""), TelegramHTMLMessageLimit) case "fileChange": return joinNonEmpty("Tool call: file change finished", fmt.Sprintf("Changed files: %d", len(item.Changes)), "Status: "+item.Status) case "mcpToolCall": @@ -1250,14 +1319,38 @@ func renderCodexItemCompleted(item codexThreadItemView) string { } func commandStartedDetailsHTML(item codexThreadItemView) string { - var parts []string - if command := strings.TrimSpace(item.Command); command != "" { - parts = append(parts, "Command", CodeBlockHTML("bash", command)) - } + return commandExecutionDetailsHTML(item, "") +} + +func commandExecutionDetailsHTML(item codexThreadItemView, editedAt string) string { + return strings.Join(commandExecutionSectionsHTML(item, editedAt), "\n\n") +} + +func commandExecutionSectionsHTML(item codexThreadItemView, editedAt string) []string { + var sections []string if cwd := strings.TrimSpace(item.CWD); cwd != "" { - parts = append(parts, FieldHTML("CWD", cwd)) + sections = append(sections, FieldHTML("CWD", cwd)) } - return strings.Join(parts, "\n") + if command := strings.TrimSpace(item.Command); command != "" { + sections = append(sections, "Command: "+CodeBlockHTML("bash", command)) + } + if item.AggregatedOutput != nil && strings.TrimSpace(*item.AggregatedOutput) != "" { + sections = append(sections, "Output: "+CodeBlockHTML("text", *item.AggregatedOutput)) + } + var fields []string + if item.ExitCode != nil { + fields = append(fields, FieldHTML("Exit code", strconv.Itoa(*item.ExitCode))) + } + if item.DurationMs != nil { + fields = append(fields, FieldHTML("Duration ms", strconv.FormatInt(*item.DurationMs, 10))) + } + if editedAt != "" { + fields = append(fields, FieldHTML("Edited at", editedAt)) + } + if len(fields) > 0 { + sections = append(sections, strings.Join(nonEmptyHTML(fields), "\n")) + } + return nonEmptyHTML(sections) } func renderCodexItemDetailsHTML(item codexThreadItemView) string { @@ -1267,16 +1360,6 @@ func renderCodexItemDetailsHTML(item codexThreadItemView) string { parts = append(parts, html) } } - appendInt := func(label string, value *int) { - if value != nil { - appendField(label, strconv.Itoa(*value)) - } - } - appendInt64 := func(label string, value *int64) { - if value != nil { - appendField(label, strconv.FormatInt(*value, 10)) - } - } appendBool := func(label string, value *bool) { if value != nil { appendField(label, strconv.FormatBool(*value)) @@ -1285,15 +1368,7 @@ func renderCodexItemDetailsHTML(item codexThreadItemView) string { switch item.Type { case "commandExecution": - if command := strings.TrimSpace(item.Command); command != "" { - parts = append(parts, "Command", CodeBlockHTML("bash", command)) - } - appendField("CWD", item.CWD) - appendInt("Exit code", item.ExitCode) - appendInt64("Duration ms", item.DurationMs) - if item.AggregatedOutput != nil && strings.TrimSpace(*item.AggregatedOutput) != "" { - parts = append(parts, "Output", CodeBlockHTML("text", *item.AggregatedOutput)) - } + parts = append(parts, commandExecutionDetailsHTML(item, "")) case "fileChange": appendField("Status", item.Status) for _, change := range item.Changes { @@ -1392,7 +1467,7 @@ func renderArgumentFieldHTML(key string, value any) string { return "" } if complex || shouldUseCodeBlock(key, text) { - return "" + EscapeHTML(label) + "\n" + CodeBlockHTML(languageForArgument(key, text), text) + return "" + EscapeHTML(label) + ": " + CodeBlockHTML(languageForArgument(key, text), text) } return FieldHTML(label, text) } @@ -1540,7 +1615,10 @@ func (b *Bot) handleCodexNotification(ctx context.Context, event codexapp.Event) } if params.ThreadID != "" && !params.WillRetry { if thread, err := b.store.GetThreadByCodexID(ctx, params.ThreadID); err == nil { - _ = b.store.SetActiveTurn(ctx, thread.TelegramUserID, "") + _ = b.store.ClearActiveTurn(ctx, thread.TelegramUserID, params.TurnID) + } + if !b.shouldHandleOutputEvent(params.ThreadID, params.TurnID) { + return nil } message := "Codex error" if params.Error.Message != "" { @@ -1561,48 +1639,55 @@ func (b *Bot) handleCodexNotification(ctx context.Context, event codexapp.Event) case "item/started": var params struct { ThreadID string `json:"threadId"` + TurnID string `json:"turnId"` Item json.RawMessage `json:"item"` } if err := json.Unmarshal(event.Params, ¶ms); err != nil { return err } + if params.ThreadID == "" || !b.shouldHandleOutputEvent(params.ThreadID, params.TurnID) { + return nil + } item, err := parseCodexThreadItem(params.Item) if err != nil { return err } - if params.ThreadID != "" && item.Type == "agentMessage" && b.hasAssistantText(params.ThreadID) { + if item.Type == "agentMessage" && b.hasAssistantText(params.ThreadID) { return b.flushAssistantMessage(ctx, params.ThreadID) } - if params.ThreadID != "" { - if b.shouldSuppressPictureToolMessage(params.ThreadID, item) { - return nil - } - return b.upsertToolMessage(ctx, params.ThreadID, item.ID, renderCodexItemStarted(item)) + if b.shouldSuppressPictureToolMessage(params.ThreadID, item) { + return nil } + return b.upsertToolMessage(ctx, params.ThreadID, params.TurnID, item.ID, renderCodexItemStarted(item)) case "item/agentMessage/delta": var params struct { ThreadID string `json:"threadId"` + TurnID string `json:"turnId"` Delta string `json:"delta"` } if err := json.Unmarshal(event.Params, ¶ms); err != nil { return err } - if params.ThreadID != "" && params.Delta != "" { + if params.ThreadID != "" && params.Delta != "" && b.shouldHandleOutputEvent(params.ThreadID, params.TurnID) { return b.appendAssistantDelta(ctx, params.ThreadID, params.Delta) } case "item/completed": var params struct { ThreadID string `json:"threadId"` + TurnID string `json:"turnId"` Item json.RawMessage `json:"item"` } if err := json.Unmarshal(event.Params, ¶ms); err != nil { return err } + if params.ThreadID == "" || !b.shouldHandleOutputEvent(params.ThreadID, params.TurnID) { + return nil + } item, err := parseCodexThreadItem(params.Item) if err != nil { return err } - if params.ThreadID != "" && item.Type == "agentMessage" { + if item.Type == "agentMessage" { if item.Text != "" && !b.hasAssistantText(params.ThreadID) { if err := b.appendAssistantDelta(ctx, params.ThreadID, item.Text); err != nil { return err @@ -1610,24 +1695,23 @@ func (b *Bot) handleCodexNotification(ctx context.Context, event codexapp.Event) } return b.flushAssistantMessage(ctx, params.ThreadID) } - if params.ThreadID != "" { - if b.queuePictureImageOutput(params.ThreadID, item) { - return nil - } - if err := b.upsertToolMessage(ctx, params.ThreadID, item.ID, renderCodexItemCompleted(item)); err != nil { - return err - } - return b.sendImageOutput(ctx, params.ThreadID, item) + if b.queuePictureImageOutput(params.ThreadID, item) { + return nil } + if err := b.upsertToolMessage(ctx, params.ThreadID, params.TurnID, item.ID, renderCodexItemCompleted(item)); err != nil { + return err + } + return b.sendImageOutput(ctx, params.ThreadID, item) case "turn/diff/updated": var params struct { ThreadID string `json:"threadId"` + TurnID string `json:"turnId"` Diff string `json:"diff"` } if err := json.Unmarshal(event.Params, ¶ms); err != nil { return err } - if params.ThreadID != "" { + if params.ThreadID != "" && b.shouldHandleOutputEvent(params.ThreadID, params.TurnID) { b.mu.Lock() b.diffs[params.ThreadID] = params.Diff b.mu.Unlock() @@ -1645,9 +1729,12 @@ func (b *Bot) handleCodexNotification(ctx context.Context, event codexapp.Event) } if params.ThreadID != "" { if thread, err := b.store.GetThreadByCodexID(ctx, params.ThreadID); err == nil { - _ = b.store.SetActiveTurn(ctx, thread.TelegramUserID, "") + _ = b.store.ClearActiveTurn(ctx, thread.TelegramUserID, params.Turn.ID) _ = b.store.TouchThread(ctx, params.ThreadID) } + if !b.shouldHandleOutputEvent(params.ThreadID, params.Turn.ID) { + return nil + } return b.completeTurnOutput(ctx, params.ThreadID) } case "thread/name/updated": @@ -1742,7 +1829,7 @@ func (b *Bot) handleCodexServerRequest(ctx context.Context, event codexapp.Event if threadID == "" { return errors.New("approval request missing threadId") } - itemID := firstNonEmpty(params.ItemID, params.ApprovalID, params.CallID) + itemID := firstNonEmpty(params.ApprovalID, params.ItemID, params.CallID) thread, err := b.store.GetThreadByCodexID(ctx, threadID) if err != nil { return err @@ -1768,8 +1855,8 @@ func (b *Bot) handleCodexServerRequest(ctx context.Context, event codexapp.Event return nil } text := renderApprovalHTML(kind, event.Params, "") - markup := approvalMarkup(approval.ID) - msg, err := b.upsertApprovalMessage(ctx, thread.TelegramUserID, threadID, itemID, text, markup) + markup := approvalMarkupForPayload(approval.ID, event.Params) + msg, err := b.upsertApprovalMessage(ctx, thread.TelegramUserID, threadID, params.TurnID, itemID, text, markup) if err != nil { return err } @@ -1785,8 +1872,9 @@ func firstNonEmpty(values ...string) string { return "" } -func (b *Bot) newOutputState(chatID int64) *outputState { +func (b *Bot) newOutputState(chatID int64, turnID string) *outputState { return &outputState{ + turnID: turnID, chatID: chatID, tools: make(map[string]toolMessageState), sentImages: make(map[string]bool), @@ -1794,17 +1882,17 @@ func (b *Bot) newOutputState(chatID int64) *outputState { } } -func (b *Bot) registerOutput(threadID string, chatID int64) { +func (b *Bot) registerOutput(threadID, turnID string, chatID int64) { b.mu.Lock() defer b.mu.Unlock() if state := b.outputs[threadID]; state != nil && state.workingIndicatorOff != nil { state.workingIndicatorOff() } - b.outputs[threadID] = b.newOutputState(chatID) + b.outputs[threadID] = b.newOutputState(chatID, turnID) } -func (b *Bot) registerPictureOutput(threadID string, chatID int64) { - b.registerOutput(threadID, chatID) +func (b *Bot) registerPictureOutput(threadID, turnID string, chatID int64) { + b.registerOutput(threadID, turnID, chatID) b.mu.Lock() defer b.mu.Unlock() if state := b.outputs[threadID]; state != nil { @@ -1812,6 +1900,43 @@ func (b *Bot) registerPictureOutput(threadID string, chatID int64) { } } +func (b *Bot) setOutputTurnID(threadID, turnID string) { + if strings.TrimSpace(turnID) == "" { + return + } + b.mu.Lock() + defer b.mu.Unlock() + if state := b.outputs[threadID]; state != nil { + state.turnID = turnID + } +} + +func (b *Bot) hasOutputTurn(threadID, turnID string) bool { + b.mu.Lock() + defer b.mu.Unlock() + state := b.outputs[threadID] + return state != nil && sameTurn(state.turnID, turnID) +} + +func (b *Bot) shouldHandleOutputEvent(threadID, turnID string) bool { + b.mu.Lock() + defer b.mu.Unlock() + state := b.outputs[threadID] + if state == nil { + return false + } + if state.turnID == "" && strings.TrimSpace(turnID) != "" { + state.turnID = turnID + } + return sameTurn(state.turnID, turnID) +} + +func sameTurn(ownedTurnID, eventTurnID string) bool { + ownedTurnID = strings.TrimSpace(ownedTurnID) + eventTurnID = strings.TrimSpace(eventTurnID) + return ownedTurnID == "" || eventTurnID == "" || ownedTurnID == eventTurnID +} + func (b *Bot) clearOutput(threadID string) { b.mu.Lock() state := b.outputs[threadID] @@ -1872,6 +1997,10 @@ func (b *Bot) hasAssistantText(threadID string) bool { } func (b *Bot) failActiveOutputs(ctx context.Context, message string) { + if err := b.store.ClearActiveTurns(ctx); err != nil { + b.logger.Printf("clear active turns after Codex disconnect: %v", err) + } + b.mu.Lock() threadIDs := make([]string, 0, len(b.outputs)) for threadID := range b.outputs { @@ -1880,9 +2009,6 @@ func (b *Bot) failActiveOutputs(ctx context.Context, message string) { b.mu.Unlock() for _, threadID := range threadIDs { - if thread, err := b.store.GetThreadByCodexID(ctx, threadID); err == nil { - _ = b.store.SetActiveTurn(ctx, thread.TelegramUserID, "") - } if err := b.flushAssistantMessage(ctx, threadID); err != nil { b.logger.Printf("flush failed output %s: %v", threadID, err) } @@ -2031,28 +2157,56 @@ func addEditedAtLine(htmlText, editedAt string) string { if htmlText == "" || editedAt == "" { return htmlText } - line := EscapeHTML("Edited at: " + editedAt) - quoteIndex := strings.Index(htmlText, "
") - if quoteIndex < 0 { - return htmlText + "\n" + line + line := FieldHTML("Edited at", editedAt) + if strings.Contains(htmlText, line) { + return htmlText } - summary := strings.TrimRight(htmlText[:quoteIndex], "\n") - details := strings.TrimLeft(htmlText[quoteIndex:], "\n") - if summary == "" { - return line + "\n" + details + for _, marker := range []string{"\n\nCodex requests command approval", "\n\nCodex requests file change approval", "\n\nCodex requests permission approval", "\n\nCodex approval requested"} { + if before, after, ok := strings.Cut(htmlText, marker); ok { + return addEditedAtToToolHTML(before, line) + marker + after + } } - return summary + "\n" + line + "\n" + details + return addEditedAtToToolHTML(htmlText, line) +} + +func addEditedAtToToolHTML(htmlText, line string) string { + const open = "
" + const close = "
" + 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 strings.Contains(content, "Exit code:") || strings.Contains(content, "Duration ms:") { + insert := strings.TrimRight(content, "\n") + "\n" + line + return htmlText[:contentStart] + insert + htmlText[end:] + } + searchFrom = end + len(close) + } + return htmlText + "\n" + ExpandableQuoteRawHTML(line) } 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 { +func (b *Bot) upsertToolMessage(ctx context.Context, threadID, turnID, itemID, htmlText string) error { htmlText = strings.TrimSpace(htmlText) if htmlText == "" { return nil } + if !b.hasOutputTurn(threadID, turnID) { + return nil + } if itemID == "" { return b.sendOutputHTMLBlock(ctx, threadID, htmlText) } @@ -2107,21 +2261,22 @@ func (b *Bot) upsertToolMessage(ctx context.Context, threadID, itemID, htmlText return nil } -func (b *Bot) upsertApprovalMessage(ctx context.Context, chatID int64, threadID, itemID, approvalHTML string, markup *InlineKeyboardMarkup) (Message, error) { +func (b *Bot) upsertApprovalMessage(ctx context.Context, chatID int64, threadID, turnID, itemID, approvalHTML string, markup *InlineKeyboardMarkup) (Message, error) { approvalHTML = strings.TrimSpace(approvalHTML) if approvalHTML == "" { return Message{}, errors.New("approval message is empty") } - if threadID == "" || itemID == "" { + if threadID == "" || itemID == "" || !b.hasOutputTurn(threadID, turnID) { return b.tg.SendMessage(ctx, chatID, approvalHTML, SendMessageOptions{ParseMode: "HTML", ReplyMarkup: markup}) } if err := b.flushAssistantMessage(ctx, threadID); err != nil { return Message{}, err } - chatID, err := b.outputChatID(ctx, threadID) + trackedChatID, err := b.outputChatID(ctx, threadID) if err != nil { - return Message{}, err + return b.tg.SendMessage(ctx, chatID, approvalHTML, SendMessageOptions{ParseMode: "HTML", ReplyMarkup: markup}) } + chatID = trackedChatID b.mu.Lock() state := b.outputs[threadID] @@ -2510,13 +2665,7 @@ func (b *Bot) outputChatID(ctx context.Context, threadID string) (int64, error) return chatID, nil } b.mu.Unlock() - - thread, err := b.store.GetThreadByCodexID(ctx, threadID) - if err != nil { - return 0, err - } - b.registerOutput(threadID, thread.TelegramUserID) - return thread.TelegramUserID, nil + return 0, sql.ErrNoRows } func (b *Bot) markOutputSent(threadID string) { @@ -2580,11 +2729,11 @@ func parseCommand(text string) (string, []string, bool) { } func resumeThreadListText(threads []store.Thread, page int) string { - lines := []string{fmt.Sprintf("Resume a thread (page %d):", page+1), ""} + lines := []string{fmt.Sprintf("Threads (page %d):", page+1), ""} for _, thread := range threads { lines = append(lines, fmt.Sprintf("Thread ID %d: %s", thread.ID, threadDisplayTitle(thread))) } - lines = append(lines, "", "Choose a button below, or use /resume THREAD_ID directly.") + lines = append(lines, "", "Choose a button below, or use /thread THREAD_ID directly.") return strings.Join(lines, "\n") } @@ -2743,23 +2892,158 @@ func editReplyMarkup(markup *InlineKeyboardMarkup) *InlineKeyboardMarkup { return clearInlineKeyboardMarkup() } +type approvalDecisionOption struct { + Key string + Label string + Approves bool +} + func approvalMarkup(id int64) *InlineKeyboardMarkup { - return &InlineKeyboardMarkup{InlineKeyboard: [][]InlineKeyboardButton{ - { - {Text: "Approve", CallbackData: ApprovalCallbackData(id, "accept")}, - {Text: "Deny", CallbackData: ApprovalCallbackData(id, "decline")}, - }, - { - {Text: "Details", CallbackData: ApprovalCallbackData(id, "details")}, - {Text: "Cancel", CallbackData: ApprovalCallbackData(id, "cancel")}, - }, - }} + return approvalMarkupForOptions(id, nil) +} + +func approvalMarkupForPayload(id int64, raw json.RawMessage) *InlineKeyboardMarkup { + return approvalMarkupForOptions(id, approvalDecisionOptions(raw)) +} + +func approvalDecisionOptions(raw json.RawMessage) []approvalDecisionOption { + var params struct { + AvailableDecisions []json.RawMessage `json:"availableDecisions"` + } + if err := json.Unmarshal(raw, ¶ms); err != nil || params.AvailableDecisions == nil { + return nil + } + seen := map[string]bool{} + var options []approvalDecisionOption + networkIndex := 0 + for _, rawDecision := range params.AvailableDecisions { + if option, ok := stringApprovalDecisionOption(rawDecision); ok { + if !seen[option.Key] { + options = append(options, option) + seen[option.Key] = true + } + continue + } + if option, ok := structuredApprovalDecisionOption(rawDecision, networkIndex); ok { + if strings.HasPrefix(option.Key, "networkPolicy") { + networkIndex++ + } + if !seen[option.Key] { + options = append(options, option) + seen[option.Key] = true + } + } + } + return options +} + +func stringApprovalDecisionOption(raw json.RawMessage) (approvalDecisionOption, bool) { + var decision string + if err := json.Unmarshal(raw, &decision); err != nil { + return approvalDecisionOption{}, false + } + switch decision { + case "accept": + return approvalDecisionOption{Key: decision, Label: "Approve", Approves: true}, true + case "acceptForSession": + return approvalDecisionOption{Key: decision, Label: "Approve session", Approves: true}, true + case "decline": + return approvalDecisionOption{Key: decision, Label: "Deny"}, true + case "cancel": + return approvalDecisionOption{Key: decision, Label: "Cancel"}, true + default: + return approvalDecisionOption{}, false + } +} + +func structuredApprovalDecisionOption(raw json.RawMessage, networkIndex int) (approvalDecisionOption, bool) { + var object map[string]json.RawMessage + if err := json.Unmarshal(raw, &object); err != nil { + return approvalDecisionOption{}, false + } + if _, ok := object["acceptWithExecpolicyAmendment"]; ok { + return approvalDecisionOption{Key: "acceptWithExecpolicyAmendment", Label: "Approve rule", Approves: true}, true + } + if rawNetwork, ok := object["applyNetworkPolicyAmendment"]; ok { + label := "Apply network rule" + approves := true + var payload struct { + NetworkPolicyAmendment struct { + Action string `json:"action"` + Host string `json:"host"` + } `json:"network_policy_amendment"` + } + if err := json.Unmarshal(rawNetwork, &payload); err == nil { + host := strings.TrimSpace(payload.NetworkPolicyAmendment.Host) + switch payload.NetworkPolicyAmendment.Action { + case "allow": + label = "Allow network" + if host != "" { + label = "Allow " + host + } + case "deny": + label = "Deny network" + approves = false + if host != "" { + label = "Deny " + host + } + } + } + return approvalDecisionOption{Key: fmt.Sprintf("networkPolicy%d", networkIndex), Label: label, Approves: approves}, true + } + return approvalDecisionOption{}, false +} + +func approvalMarkupForOptions(id int64, options []approvalDecisionOption) *InlineKeyboardMarkup { + if len(options) == 0 { + options = []approvalDecisionOption{ + {Key: "accept", Label: "Approve", Approves: true}, + {Key: "decline", Label: "Deny"}, + {Key: "cancel", Label: "Cancel"}, + } + } + var approveRow []InlineKeyboardButton + var denyRow []InlineKeyboardButton + for _, option := range options { + if option.Key == "" || option.Label == "" { + continue + } + button := InlineKeyboardButton{Text: truncateButtonLabel(option.Label), CallbackData: ApprovalCallbackData(id, option.Key)} + if option.Approves { + approveRow = append(approveRow, button) + } else { + denyRow = append(denyRow, button) + } + } + keyboard := make([][]InlineKeyboardButton, 0, 3) + if len(approveRow) > 0 { + keyboard = append(keyboard, approveRow) + } + if len(denyRow) > 0 { + keyboard = append(keyboard, denyRow) + } + keyboard = append(keyboard, []InlineKeyboardButton{{Text: "Details", CallbackData: ApprovalCallbackData(id, "details")}}) + return &InlineKeyboardMarkup{InlineKeyboard: keyboard} +} + +func truncateButtonLabel(label string) string { + const maxRunes = 48 + runes := []rune(label) + if len(runes) <= maxRunes { + return label + } + return string(runes[:maxRunes-3]) + "..." } func approvalResponse(approval store.PendingApproval, decision string) any { if isLegacyApprovalKind(approval.Kind) { return map[string]any{"decision": legacyApprovalDecision(decision)} } + if approval.Kind == "item/commandExecution/requestApproval" { + if responseDecision, ok := commandApprovalResponseDecision(json.RawMessage(approval.PayloadJSON), decision); ok { + return map[string]any{"decision": responseDecision} + } + } if approval.Kind != "item/permissions/requestApproval" { return map[string]any{"decision": decision} } @@ -2784,6 +3068,77 @@ func approvalResponse(approval store.PendingApproval, decision string) any { } } +func commandApprovalResponseDecision(raw json.RawMessage, decision string) (any, bool) { + switch decision { + case "accept", "acceptForSession", "decline", "cancel": + return decision, true + case "acceptWithExecpolicyAmendment": + if value, ok := firstStructuredApprovalDecision(raw, "acceptWithExecpolicyAmendment", 0); ok { + return value, true + } + if value, ok := fallbackExecpolicyDecision(raw); ok { + return value, true + } + case "details": + return nil, false + default: + if strings.HasPrefix(decision, "networkPolicy") { + index, err := strconv.Atoi(strings.TrimPrefix(decision, "networkPolicy")) + if err == nil { + return firstStructuredApprovalDecision(raw, "applyNetworkPolicyAmendment", index) + } + } + } + return nil, false +} + +func firstStructuredApprovalDecision(raw json.RawMessage, key string, index int) (any, bool) { + if index < 0 { + return nil, false + } + var params struct { + AvailableDecisions []json.RawMessage `json:"availableDecisions"` + } + if err := json.Unmarshal(raw, ¶ms); err != nil { + return nil, false + } + seen := 0 + for _, rawDecision := range params.AvailableDecisions { + var object map[string]json.RawMessage + if err := json.Unmarshal(rawDecision, &object); err != nil { + continue + } + payload, ok := object[key] + if !ok { + continue + } + if seen != index { + seen++ + continue + } + var value map[string]any + if err := json.Unmarshal(payload, &value); err != nil { + return nil, false + } + return map[string]any{key: value}, true + } + return nil, false +} + +func fallbackExecpolicyDecision(raw json.RawMessage) (any, bool) { + var params struct { + ProposedExecpolicyAmendment []string `json:"proposedExecpolicyAmendment"` + } + if err := json.Unmarshal(raw, ¶ms); err != nil || len(params.ProposedExecpolicyAmendment) == 0 { + return nil, false + } + return map[string]any{ + "acceptWithExecpolicyAmendment": map[string]any{ + "execpolicy_amendment": params.ProposedExecpolicyAmendment, + }, + }, true +} + func isLegacyApprovalKind(kind string) bool { switch kind { case "execCommandApproval", "applyPatchApproval": @@ -2827,12 +3182,12 @@ func renderApprovalHTML(kind string, raw json.RawMessage, status string) string lines = append(lines, "", reason) } summary := strings.Join(lines, "\n") - details := renderApprovalDetailsHTML(kind, raw) + sections := renderApprovalDetailSectionsHTML(kind, raw) limit := TelegramHTMLMessageLimit if status != "" { limit -= len([]rune(status)) + 1 } - text := SummaryDetailsRawHTMLLimited(summary, details, limit) + text := SummaryRawHTMLSectionsLimited(summary, sections, limit) if status != "" { text += "\n" + EscapeHTML(status) } @@ -2840,23 +3195,32 @@ func renderApprovalHTML(kind string, raw json.RawMessage, status string) string } func renderApprovalDetailsHTML(kind string, raw json.RawMessage) string { + return strings.Join(renderApprovalDetailSectionsHTML(kind, raw), "\n") +} + +func renderApprovalDetailSectionsHTML(kind string, raw json.RawMessage) []string { var params map[string]any if err := json.Unmarshal(raw, ¶ms); err != nil { - return CodeBlockHTML("json", string(raw)) + return []string{CodeBlockHTML("json", string(raw))} } - parts := renderSelectedArgumentDetailsHTML(params, []string{"command", "cwd", "grantRoot", "permissions", "fileChanges", "parsedCmd", "reason"}) + parts := renderSelectedArgumentDetailsHTML(params, []string{"cwd", "command", "additionalPermissions", "networkApprovalContext", "proposedExecpolicyAmendment", "proposedNetworkPolicyAmendments", "availableDecisions", "grantRoot", "permissions", "fileChanges", "parsedCmd", "reason"}) if len(parts) == 0 { - return CodeBlockHTML("json", prettyJSON(raw)) + return []string{CodeBlockHTML("json", prettyJSON(raw))} } - return strings.Join(nonEmptyHTML(parts), "\n") + return nonEmptyHTML(parts) } func approvalStatusLine(decision string) string { + if strings.HasPrefix(decision, "networkPolicy") { + return "Applied network rule." + } switch decision { case "accept": return "Approved." case "acceptForSession": return "Approved for this session." + case "acceptWithExecpolicyAmendment": + return "Approved and saved command rule." case "decline": return "Disapproved." case "cancel": diff --git a/internal/telegram/render.go b/internal/telegram/render.go index c53e747..03b0ed2 100644 --- a/internal/telegram/render.go +++ b/internal/telegram/render.go @@ -87,6 +87,26 @@ func SummaryDetailsRawHTMLLimited(summary, detailsHTML string, limit int) string return SummaryDetailsRawHTML(summary, EscapeHTML(suffix)) } +func SummaryRawHTMLSections(summary string, sections []string) string { + summary = strings.TrimSpace(summary) + sections = nonEmptyHTML(sections) + var parts []string + if summary != "" { + parts = append(parts, EscapeHTML(summary)) + } + for _, section := range sections { + parts = append(parts, ExpandableQuoteRawHTML(section)) + } + return strings.Join(parts, "\n") +} + +func SummaryRawHTMLSectionsLimited(summary string, sections []string, limit int) string { + if limit <= 0 { + limit = TelegramHTMLMessageLimit + } + return FitHTMLMessage(SummaryRawHTMLSections(summary, sections), limit) +} + func CodeBlockHTML(language, text string) string { text = strings.TrimSpace(text) if text == "" { @@ -197,7 +217,16 @@ func FitHTMLMessage(htmlText string, limit int) string { return truncateHTMLText(htmlText, limit) } - contentRunes := []rune(strings.TrimSpace(html.UnescapeString(content))) + prefix, body, codeLanguage, codeBlock := splitSafeQuotePrefix(content) + contentRunes := []rune(strings.TrimSpace(html.UnescapeString(body))) + if len(contentRunes) == 0 { + replacement := prefix + EscapeHTML(truncatedQuote) + if replacement == content { + return summaryOnlyHTML(htmlText, limit) + } + htmlText = htmlText[:contentStart] + replacement + htmlText[contentEnd:] + continue + } over := len([]rune(htmlText)) - limit keep := len(contentRunes) - over - 80 if keep < 0 { @@ -210,7 +239,10 @@ func FitHTMLMessage(htmlText string, limit int) string { if keep > 0 { replacementText = strings.TrimSpace(string(contentRunes[:keep])) + "\n" + truncatedQuote } - replacement := EscapeHTML(replacementText) + replacement := prefix + EscapeHTML(replacementText) + if codeBlock { + replacement = prefix + CodeBlockHTML(codeLanguage, replacementText) + } if replacement == content { return summaryOnlyHTML(htmlText, limit) } @@ -219,6 +251,81 @@ func FitHTMLMessage(htmlText string, limit int) string { return htmlText } +func splitSafeQuotePrefix(content string) (prefix, body, codeLanguage string, codeBlock bool) { + content = strings.TrimSpace(content) + prefix, body = splitLeadingBoldLabel(content) + body = strings.TrimSpace(body) + if language, code, ok := splitSingleCodeBlock(body); ok { + return prefix, code, language, true + } + return prefix, body, "", false +} + +func splitLeadingBoldLabel(content string) (string, string) { + if !strings.HasPrefix(content, "") { + return "", content + } + end := strings.Index(content, "") + if end < 0 { + return "", content + } + labelEnd := end + len("") + label := content[:labelEnd] + if !strings.HasSuffix(label, ":") { + return "", content + } + afterLabel := content[labelEnd:] + if strings.HasPrefix(afterLabel, "
") {
+		return label + " ", strings.TrimLeft(afterLabel, " ")
+	}
+	if strings.HasPrefix(afterLabel, "\n
") {
+		return label + " ", strings.TrimLeft(afterLabel, "\n")
+	}
+	if strings.HasPrefix(afterLabel, " ") {
+		lineEnd := strings.Index(afterLabel, "\n")
+		if lineEnd < 0 {
+			return content + "\n", ""
+		}
+		prefixEnd := labelEnd + lineEnd + 1
+		return content[:prefixEnd], strings.TrimLeft(content[prefixEnd:], "\n")
+	}
+	rest := strings.TrimLeft(afterLabel, " \n")
+	return label + " ", rest
+}
+
+func splitSingleCodeBlock(content string) (string, string, bool) {
+	const preOpen = "
"
+	const preClose = "
" + const codeClose = "" + if !strings.HasPrefix(content, preOpen+"") + if tagEnd < 0 { + return "", "", false + } + tagEnd += codeStart + tag := content[codeStart : tagEnd+1] + const classPrefix = `class="language-` + classStart := strings.Index(tag, classPrefix) + if classStart < 0 { + return "", "", false + } + classStart += len(classPrefix) + classEnd := strings.Index(tag[classStart:], `"`) + if classEnd < 0 { + return "", "", false + } + language := tag[classStart : classStart+classEnd] + bodyStart := tagEnd + 1 + bodyEnd := len(content) - len(codeClose+preClose) + if bodyEnd < bodyStart { + return "", "", false + } + return safeCodeLanguage(language), html.UnescapeString(content[bodyStart:bodyEnd]), true +} + func largestBlockquoteContent(htmlText, open, close string) (int, int, string) { bestStart := -1 bestEnd := -1 @@ -270,15 +377,19 @@ func truncateHTMLText(htmlText string, limit int) string { limit = TelegramHTMLMessageLimit } suffix := "\n...[truncated]" - runes := []rune(htmlText) - if len(runes) <= limit { + if len([]rune(htmlText)) <= limit { return htmlText } + plain := stripSimpleHTML(htmlText) + runes := []rune(plain) keep := limit - len([]rune(suffix)) if keep < 0 { keep = 0 } - return string(runes[:keep]) + suffix + if keep > len(runes) { + keep = len(runes) + } + return EscapeHTML(strings.TrimSpace(string(runes[:keep]))) + EscapeHTML(suffix) } func ChunkText(text string, max int) []string { @@ -321,9 +432,13 @@ func ParseApprovalCallbackData(data string) (int64, string, bool) { return 0, "", false } switch parts[2] { - case "accept", "acceptForSession", "decline", "cancel", "details": + case "accept", "acceptForSession", "acceptWithExecpolicyAmendment", "decline", "cancel", "details": return id, parts[2], true default: + if strings.HasPrefix(parts[2], "networkPolicy") { + index, err := strconv.Atoi(strings.TrimPrefix(parts[2], "networkPolicy")) + return id, parts[2], err == nil && index >= 0 + } return 0, "", false } } diff --git a/internal/telegram/render_test.go b/internal/telegram/render_test.go index d649e65..ccdc0d6 100644 --- a/internal/telegram/render_test.go +++ b/internal/telegram/render_test.go @@ -53,6 +53,72 @@ func TestApprovalCallbackData(t *testing.T) { } } +func TestApprovalCallbackDataForStructuredDecision(t *testing.T) { + data := ApprovalCallbackData(12, "acceptWithExecpolicyAmendment") + id, decision, ok := ParseApprovalCallbackData(data) + if !ok || id != 12 || decision != "acceptWithExecpolicyAmendment" { + t.Fatalf("unexpected structured callback parse: id=%d decision=%s ok=%v", id, decision, ok) + } + data = ApprovalCallbackData(12, "networkPolicy0") + id, decision, ok = ParseApprovalCallbackData(data) + if !ok || id != 12 || decision != "networkPolicy0" { + t.Fatalf("unexpected network callback parse: id=%d decision=%s ok=%v", id, decision, ok) + } + if _, _, ok := ParseApprovalCallbackData("approval:12:networkPolicyx"); ok { + t.Fatal("invalid network policy callback should be rejected") + } +} + +func TestApprovalMarkupHonorsAvailableDecisions(t *testing.T) { + raw := json.RawMessage(`{"availableDecisions":["accept",{"acceptWithExecpolicyAmendment":{"execpolicy_amendment":["git","push"]}},"decline"]}`) + markup := approvalMarkupForPayload(42, raw) + var labels []string + for _, row := range markup.InlineKeyboard { + for _, button := range row { + labels = append(labels, button.Text) + } + } + joined := strings.Join(labels, "|") + for _, want := range []string{"Approve", "Approve rule", "Deny", "Details"} { + if !strings.Contains(joined, want) { + t.Fatalf("approval markup missing %q in %#v", want, markup.InlineKeyboard) + } + } + if strings.Contains(joined, "Cancel") { + t.Fatalf("cancel should not be shown when Codex does not advertise it: %#v", markup.InlineKeyboard) + } +} + +func TestApprovalResponseForCommandStructuredDecision(t *testing.T) { + approval := store.PendingApproval{ + Kind: "item/commandExecution/requestApproval", + PayloadJSON: `{"availableDecisions":[{"acceptWithExecpolicyAmendment":{"execpolicy_amendment":["git","push"]}},{"applyNetworkPolicyAmendment":{"network_policy_amendment":{"action":"allow","host":"example.com"}}}]}`, + } + response, ok := approvalResponse(approval, "acceptWithExecpolicyAmendment").(map[string]any) + if !ok { + t.Fatal("structured command response should be a map") + } + decision, ok := response["decision"].(map[string]any) + if !ok { + t.Fatalf("decision should be structured: %#v", response["decision"]) + } + if _, ok := decision["acceptWithExecpolicyAmendment"]; !ok { + t.Fatalf("missing execpolicy decision: %#v", decision) + } + + response, ok = approvalResponse(approval, "networkPolicy0").(map[string]any) + if !ok { + t.Fatal("network command response should be a map") + } + decision, ok = response["decision"].(map[string]any) + if !ok { + t.Fatalf("network decision should be structured: %#v", response["decision"]) + } + if _, ok := decision["applyNetworkPolicyAmendment"]; !ok { + t.Fatalf("missing network policy decision: %#v", decision) + } +} + func TestApprovalResponseForPermissions(t *testing.T) { approval := store.PendingApproval{ Kind: "item/permissions/requestApproval", @@ -131,9 +197,25 @@ func TestEditReplyMarkupClearsInlineKeyboard(t *testing.T) { } } +func TestBotCommandsUseSingleThreadCommand(t *testing.T) { + commands := botCommands() + seen := map[string]bool{} + for _, command := range commands { + seen[command.Command] = true + } + if !seen["thread"] { + t.Fatal("bot command list should include /thread") + } + for _, removed := range []string{"threads", "resume"} { + if seen[removed] { + t.Fatalf("bot command list should not include /%s", removed) + } + } +} + func TestParseCommand(t *testing.T) { - name, args, ok := parseCommand("/resume@my_bot 123") - if !ok || name != "resume" || len(args) != 1 || args[0] != "123" { + name, args, ok := parseCommand("/thread@my_bot 123") + if !ok || name != "thread" || len(args) != 1 || args[0] != "123" { t.Fatalf("unexpected command parse: %q %#v %v", name, args, ok) } } @@ -204,15 +286,43 @@ func TestRenderCodexCommandExecutionItem(t *testing.T) { ExitCode: &exitCode, } text := renderCodexItemCompleted(item) - for _, want := range []string{"Tool call: command finished", "Command", "
go test ./...
", "Exit code: 0", "
line 1\nline 2
"} { + for _, want := range []string{"Tool call: command finished", "CWD: /workspace/project", "Command:
go test ./...
", "Output:
line 1\nline 2
", "Exit code: 0"} { if !strings.Contains(text, want) { t.Fatalf("rendered command item missing %q in %q", want, text) } } - commandAt := strings.Index(text, "Command") + summary, _, _ := strings.Cut(text, "
") + if strings.Contains(summary, "Exit code") { + t.Fatalf("exit code should not be in summary: %q", summary) + } cwdAt := strings.Index(text, "CWD:") - if cwdAt >= 0 && commandAt > cwdAt { - t.Fatalf("command label should render before CWD to avoid Telegram attaching it to the CWD line: %q", text) + commandAt := strings.Index(text, "Command:") + outputAt := strings.Index(text, "Output:") + exitAt := strings.Index(text, "Exit code:") + if !(cwdAt >= 0 && commandAt > cwdAt && outputAt > commandAt && exitAt > outputAt) { + t.Fatalf("command details order should be CWD, Command, Output, fields: %q", text) + } + if got := strings.Count(text, "
"); got != 4 { + t.Fatalf("command details should use four quoted sections, got %d in %q", got, text) + } +} + +func TestFitHTMLMessageKeepsCodeBlockTagsBalanced(t *testing.T) { + longOutput := strings.Repeat("0123456789abcdef\n", 600) + text := SummaryRawHTMLSectionsLimited("Tool call: command finished", []string{"Output: " + CodeBlockHTML("text", longOutput)}, 900) + if len([]rune(text)) > 900 { + t.Fatalf("fitted message exceeds limit: %d", len([]rune(text))) + } + for _, tag := range []string{"
", "
", "
", "
", "", ""} { + if !strings.Contains(text, tag) { + t.Fatalf("fitted message missing %q in %q", tag, text) + } + } + if strings.Count(text, "
") != strings.Count(text, "
") || strings.Count(text, "") || strings.Count(text, "
") != strings.Count(text, "
") { + t.Fatalf("fitted message has unbalanced HTML tags: %q", text) + } + if strings.Contains(text, "Output:\n") { + t.Fatalf("label should not be separated from code block by an immediate newline: %q", text) } } @@ -232,7 +342,7 @@ func TestRenderDynamicToolDetailsSelectsUsefulArguments(t *testing.T) { Arguments: json.RawMessage(`{"cmd":"go test ./...","irrelevant":{"large":"object"}}`), } text := renderCodexItemCompleted(item) - for _, want := range []string{"Tool: functions.exec_command", "cmd", "language-bash", "go test ./..."} { + for _, want := range []string{"Tool: functions.exec_command", "cmd:", "language-bash", "go test ./..."} { if !strings.Contains(text, want) { t.Fatalf("rendered tool details missing %q in %q", want, text) } @@ -277,22 +387,28 @@ func TestApprovalOnlyToolMessageCanReceiveCompletionDetails(t *testing.T) { AggregatedOutput: &output, }) text := tool.html() - for _, want := range []string{"Tool call: command finished", "Exit code: 0", "Duration ms", "1234", "Codex requests command approval"} { + for _, want := range []string{"Tool call: command finished", "Exit code: 0", "Duration ms:", "1234", "Codex requests command approval"} { if !strings.Contains(text, want) { t.Fatalf("combined approval tool message missing %q in %q", want, text) } } } -func TestToolMessageAddsEditedAtBeforeDetails(t *testing.T) { +func TestToolMessageAddsEditedAtInsideDetails(t *testing.T) { tool := toolMessageState{ - toolHTML: SummaryDetailsHTML("Tool call: command finished\nCommand: go test ./...", "full output"), + toolHTML: SummaryDetailsHTML("Tool call: command finished", "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
" - if !strings.Contains(text, want) { - t.Fatalf("edited timestamp not placed before details: %q", text) + summary, details, ok := strings.Cut(text, "
") + if !ok { + t.Fatalf("tool message should contain details quote: %q", text) + } + if strings.Contains(summary, "Edited at") { + t.Fatalf("edited timestamp should not be in summary: %q", summary) + } + if !strings.Contains(details, "Edited at: 2026-05-21 12:34:56 UTC") { + t.Fatalf("edited timestamp not placed inside details: %q", text) } } @@ -308,7 +424,7 @@ func TestToolMessageFitsCombinedApprovalDetails(t *testing.T) { 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]"} { + 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) } diff --git a/internal/telegram/types.go b/internal/telegram/types.go index 1943692..b823623 100644 --- a/internal/telegram/types.go +++ b/internal/telegram/types.go @@ -74,3 +74,8 @@ type EditMessageTextOptions struct { ParseMode string `json:"parse_mode,omitempty"` ReplyMarkup *InlineKeyboardMarkup `json:"reply_markup,omitempty"` } + +type BotCommand struct { + Command string `json:"command"` + Description string `json:"description"` +}