Compare commits

...

4 Commits

Author SHA1 Message Date
Codex
34e909f9cf Handle reused Codex approval request IDs
Treat app-server request IDs as connection-local by reopening reused approval rows when the thread, turn, or item context changes.

Keep duplicate resolved approvals in the same context closed, and add focused approval-path diagnostics without changing the Telegram approval UI.
2026-05-28 10:11:42 +00:00
Codex
c00ffb42f2 Route Codex approvals to Telegram
Force app-server turns to use the user approval reviewer so command approvals surface in the bot on Codex 0.134.

Add focused protocol logs for approval requests and guardian review events to diagnose silent approval stalls.
2026-05-28 09:57:43 +00:00
Codex
2b0da9f508 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.
2026-05-28 09:39:40 +00:00
Codex
e9dd840111 Unify approval tool message rendering 2026-05-25 06:53:44 +00:00
12 changed files with 1140 additions and 226 deletions

View File

@@ -52,7 +52,7 @@ Before implementation, carefully read the official docs and check against the gu
- Telegram UX: - Telegram UX:
- One-to-one chats only; reject groups, supergroups, and channels. - One-to-one chats only; reject groups, supergroups, and channels.
- Allowlisted Telegram user IDs only. - 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. - 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 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. - 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: - Integration tests:
- Use a low/mini model in codex for testing. - Use a low/mini model in codex for testing.
- Verify initialize, thread start, turn start, streamed output, approval, and cancellation. - 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. - Verify non-allowlisted users are rejected and logged.
- Manual acceptance: - Manual acceptance:

View File

@@ -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. 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. 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.

View File

@@ -34,6 +34,7 @@ func main() {
tg := telegram.NewClient(cfg.TelegramToken) tg := telegram.NewClient(cfg.TelegramToken)
codex := codexapp.New(cfg.CodexSocketPath, cfg.AppVersion) codex := codexapp.New(cfg.CodexSocketPath, cfg.AppVersion)
codex.SetLogger(logger)
defer codex.Close() defer codex.Close()
bot := telegram.NewBot(tg, st, codex, cfg.UploadDir, cfg.CodexHome, cfg.CodexStateDB, cfg.DefaultModel, cfg.DefaultSandbox, cfg.PollTimeout, logger) bot := telegram.NewBot(tg, st, codex, cfg.UploadDir, cfg.CodexHome, cfg.CodexStateDB, cfg.DefaultModel, cfg.DefaultSandbox, cfg.PollTimeout, logger)

View File

@@ -5,6 +5,7 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"log"
"net" "net"
"net/http" "net/http"
"strconv" "strconv"
@@ -26,6 +27,7 @@ type Client struct {
pending map[string]chan rpcResult pending map[string]chan rpcResult
events chan Event events chan Event
connected bool connected bool
logger *log.Logger
} }
type Event struct { type Event struct {
@@ -151,6 +153,12 @@ func New(socketPath, version string) *Client {
} }
} }
func (c *Client) SetLogger(logger *log.Logger) {
c.mu.Lock()
defer c.mu.Unlock()
c.logger = logger
}
func (c *Client) Events() <-chan Event { func (c *Client) Events() <-chan Event {
return c.events return c.events
} }
@@ -240,10 +248,11 @@ func (c *Client) StartThread(ctx context.Context, cwd, model, sandbox string) (T
return Thread{}, err return Thread{}, err
} }
params := map[string]any{ params := map[string]any{
"cwd": cwd, "cwd": cwd,
"approvalPolicy": "on-request", "approvalPolicy": "on-request",
"sandbox": threadSandbox(sandbox), "approvalsReviewer": "user",
"serviceName": "codex_telegram_bot", "sandbox": threadSandbox(sandbox),
"serviceName": "codex_telegram_bot",
} }
if model != "" { if model != "" {
params["model"] = model params["model"] = model
@@ -327,9 +336,10 @@ func (c *Client) StartTurn(ctx context.Context, threadID, cwd, model, reasoningE
return Turn{}, err return Turn{}, err
} }
params := map[string]any{ params := map[string]any{
"threadId": threadID, "threadId": threadID,
"input": input, "input": input,
"approvalPolicy": "on-request", "approvalPolicy": "on-request",
"approvalsReviewer": "user",
} }
if strings.TrimSpace(cwd) != "" { if strings.TrimSpace(cwd) != "" {
params["cwd"] = cwd params["cwd"] = cwd
@@ -463,6 +473,7 @@ func (c *Client) readLoop(conn *websocket.Conn) {
return return
} }
if env.Method != "" && hasID { if env.Method != "" && hasID {
c.logProtocolEvent("server request", env.Method, id.Key(), env.Params)
c.events <- Event{ c.events <- Event{
ID: &id, ID: &id,
Method: env.Method, Method: env.Method,
@@ -476,6 +487,9 @@ func (c *Client) readLoop(conn *websocket.Conn) {
continue continue
} }
if env.Method != "" { if env.Method != "" {
if shouldLogNotification(env.Method) {
c.logProtocolEvent("notification", env.Method, "", env.Params)
}
c.events <- Event{ c.events <- Event{
Method: env.Method, Method: env.Method,
Params: env.Params, Params: env.Params,
@@ -484,6 +498,29 @@ func (c *Client) readLoop(conn *websocket.Conn) {
} }
} }
func (c *Client) logProtocolEvent(kind, method, id string, params json.RawMessage) {
c.mu.Lock()
logger := c.logger
c.mu.Unlock()
if logger == nil {
return
}
if id != "" {
logger.Printf("codex %s: method=%s id=%s params_bytes=%d", kind, method, id, len(params))
return
}
logger.Printf("codex %s: method=%s params_bytes=%d", kind, method, len(params))
}
func shouldLogNotification(method string) bool {
switch method {
case "item/guardianApprovalReview/started", "item/guardianApprovalReview/completed", "guardianWarning", "serverRequest/resolved":
return true
default:
return false
}
}
func (c *Client) completeCall(id RequestID, result json.RawMessage, rpcErr *RPCError) { func (c *Client) completeCall(id RequestID, result json.RawMessage, rpcErr *RPCError) {
c.mu.Lock() c.mu.Lock()
ch := c.pending[id.Key()] ch := c.pending[id.Key()]

View File

@@ -77,6 +77,12 @@ func TestClientWebSocketUnixJSONRPC(t *testing.T) {
serverDone <- unexpectedMessage("thread/start", start["method"]) serverDone <- unexpectedMessage("thread/start", start["method"])
return return
} }
startParams := start["params"].(map[string]any)
if startParams["approvalsReviewer"] != "user" || startParams["approvalPolicy"] != "on-request" {
payload, _ := json.Marshal(startParams)
serverDone <- unexpectedMessage("thread/start approval params", string(payload))
return
}
if err := conn.WriteJSON(map[string]any{ if err := conn.WriteJSON(map[string]any{
"id": start["id"], "id": start["id"],
"result": map[string]any{ "result": map[string]any{
@@ -88,6 +94,26 @@ func TestClientWebSocketUnixJSONRPC(t *testing.T) {
return return
} }
var turnStart map[string]any
if err := conn.ReadJSON(&turnStart); err != nil {
serverDone <- err
return
}
if turnStart["method"] != "turn/start" {
serverDone <- unexpectedMessage("turn/start", turnStart["method"])
return
}
turnParams := turnStart["params"].(map[string]any)
if turnParams["approvalsReviewer"] != "user" || turnParams["approvalPolicy"] != "on-request" {
payload, _ := json.Marshal(turnParams)
serverDone <- unexpectedMessage("turn/start approval params", string(payload))
return
}
if err := conn.WriteJSON(map[string]any{"id": turnStart["id"], "result": map[string]any{"turn": map[string]any{"id": "turn_1", "status": "running"}}}); err != nil {
serverDone <- err
return
}
var readThread map[string]any var readThread map[string]any
if err := conn.ReadJSON(&readThread); err != nil { if err := conn.ReadJSON(&readThread); err != nil {
serverDone <- err serverDone <- err
@@ -174,6 +200,14 @@ func TestClientWebSocketUnixJSONRPC(t *testing.T) {
if thread.ID != "thr_1" || thread.CWD != projectCWD { if thread.ID != "thr_1" || thread.CWD != projectCWD {
t.Fatalf("unexpected thread: %+v", thread) t.Fatalf("unexpected thread: %+v", thread)
} }
turn, err := client.StartTurn(ctx, "thr_1", projectCWD, "", "", "workspace-write", []InputItem{{Type: "text", Text: "hello"}})
if err != nil {
t.Fatal(err)
}
if turn.ID != "turn_1" {
t.Fatalf("unexpected turn: %+v", turn)
}
readThread, err := client.ReadThread(ctx, "thr_1") readThread, err := client.ReadThread(ctx, "thr_1")
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)

View File

@@ -44,6 +44,12 @@ type Session struct {
UpdatedAt string UpdatedAt string
} }
type ActiveTurn struct {
TelegramUserID int64
CodexThreadID string
TurnID string
}
type Thread struct { type Thread struct {
ID int64 ID int64
TelegramUserID int64 TelegramUserID int64
@@ -338,11 +344,42 @@ func (s *Store) SetActiveTurn(ctx context.Context, telegramUserID int64, turnID
return err 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 { 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 <> ''") _, err := s.db.ExecContext(ctx, "UPDATE sessions SET active_turn_id = '', updated_at = datetime('now') WHERE active_turn_id <> ''")
return err 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) { func (s *Store) CreateThread(ctx context.Context, telegramUserID int64, codexThreadID string, workspaceID int64, title string) (Thread, error) {
result, err := s.db.ExecContext(ctx, ` result, err := s.db.ExecContext(ctx, `
INSERT INTO threads (telegram_user_id, codex_thread_id, workspace_id, title) INSERT INTO threads (telegram_user_id, codex_thread_id, workspace_id, title)
@@ -459,9 +496,41 @@ INSERT INTO pending_approvals (
message_chat_id, message_id, status message_chat_id, message_id, status
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'pending') ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'pending')
ON CONFLICT(telegram_user_id, codex_request_id) DO UPDATE SET ON CONFLICT(telegram_user_id, codex_request_id) DO UPDATE SET
codex_thread_id = excluded.codex_thread_id,
turn_id = excluded.turn_id,
item_id = excluded.item_id,
kind = excluded.kind,
payload_json = excluded.payload_json, payload_json = excluded.payload_json,
message_chat_id = CASE WHEN pending_approvals.status = 'pending' THEN excluded.message_chat_id ELSE pending_approvals.message_chat_id END, message_chat_id = CASE
message_id = CASE WHEN pending_approvals.status = 'pending' THEN excluded.message_id ELSE pending_approvals.message_id END`, WHEN pending_approvals.codex_thread_id = excluded.codex_thread_id
AND pending_approvals.turn_id = excluded.turn_id
AND pending_approvals.item_id = excluded.item_id
THEN pending_approvals.message_chat_id
ELSE excluded.message_chat_id
END,
message_id = CASE
WHEN pending_approvals.codex_thread_id = excluded.codex_thread_id
AND pending_approvals.turn_id = excluded.turn_id
AND pending_approvals.item_id = excluded.item_id
THEN pending_approvals.message_id
ELSE excluded.message_id
END,
status = CASE
WHEN pending_approvals.status <> 'pending'
AND pending_approvals.codex_thread_id = excluded.codex_thread_id
AND pending_approvals.turn_id = excluded.turn_id
AND pending_approvals.item_id = excluded.item_id
THEN pending_approvals.status
ELSE 'pending'
END,
resolved_at = CASE
WHEN pending_approvals.status <> 'pending'
AND pending_approvals.codex_thread_id = excluded.codex_thread_id
AND pending_approvals.turn_id = excluded.turn_id
AND pending_approvals.item_id = excluded.item_id
THEN pending_approvals.resolved_at
ELSE ''
END`,
approval.TelegramUserID, approval.CodexRequestID, approval.CodexThreadID, approval.TurnID, approval.TelegramUserID, approval.CodexRequestID, approval.CodexThreadID, approval.TurnID,
approval.ItemID, approval.Kind, approval.PayloadJSON, approval.MessageChatID, approval.MessageID) approval.ItemID, approval.Kind, approval.PayloadJSON, approval.MessageChatID, approval.MessageID)
if err != nil { if err != nil {

View File

@@ -72,9 +72,33 @@ func TestStoreUsersWorkspacesSessions(t *testing.T) {
if session.SettingsChatID != 1001 || session.SettingsMessageID != 2002 { if session.SettingsChatID != 1001 || session.SettingsMessageID != 2002 {
t.Fatalf("settings message not saved: %+v", session) 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 { if err := st.SetActiveTurn(ctx, 42, "turn-123"); err != nil {
t.Fatal(err) 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 { if err := st.ClearActiveTurns(ctx); err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -243,6 +267,62 @@ func TestUpsertPendingApprovalDoesNotReopenResolved(t *testing.T) {
} }
} }
func TestUpsertPendingApprovalReopensReusedRequestIDForNewContext(t *testing.T) {
ctx := context.Background()
st, err := Open(ctx, filepath.Join(t.TempDir(), "bot.db"))
if err != nil {
t.Fatal(err)
}
defer st.Close()
first, err := st.UpsertPendingApproval(ctx, PendingApproval{
TelegramUserID: 42,
CodexRequestID: "i:0",
CodexThreadID: "thread-1",
TurnID: "turn-1",
ItemID: "item-1",
Kind: "item/commandExecution/requestApproval",
PayloadJSON: `{"command":"go test ./..."}`,
})
if err != nil {
t.Fatal(err)
}
if err := st.UpdatePendingApprovalMessage(ctx, first.ID, 1001, 2002); err != nil {
t.Fatal(err)
}
if err := st.ResolvePendingApproval(ctx, 42, first.ID, "accept"); err != nil {
t.Fatal(err)
}
second, err := st.UpsertPendingApproval(ctx, PendingApproval{
TelegramUserID: 42,
CodexRequestID: "i:0",
CodexThreadID: "thread-2",
TurnID: "turn-2",
ItemID: "item-2",
Kind: "item/commandExecution/requestApproval",
PayloadJSON: `{"command":"git push"}`,
})
if err != nil {
t.Fatal(err)
}
if second.ID != first.ID {
t.Fatalf("reused request id row = %d, want %d", second.ID, first.ID)
}
if second.Status != "pending" {
t.Fatalf("reused request status = %q, want pending", second.Status)
}
if second.CodexThreadID != "thread-2" || second.TurnID != "turn-2" || second.ItemID != "item-2" {
t.Fatalf("reused request context = thread %q turn %q item %q", second.CodexThreadID, second.TurnID, second.ItemID)
}
if second.MessageChatID != 0 || second.MessageID != 0 {
t.Fatalf("reused request kept stale message: chat=%d message=%d", second.MessageChatID, second.MessageID)
}
if second.ResolvedAt != "" {
t.Fatalf("reused request resolved_at = %q, want empty", second.ResolvedAt)
}
}
func TestValidateWorkspacePath(t *testing.T) { func TestValidateWorkspacePath(t *testing.T) {
if _, err := ValidateWorkspacePath("relative/path"); err == nil { if _, err := ValidateWorkspacePath("relative/path"); err == nil {
t.Fatal("relative path should be rejected") t.Fatal("relative path should be rejected")

View File

@@ -50,6 +50,11 @@ func (c *Client) redact(text string) string {
return strings.ReplaceAll(text, c.token, "<telegram-token>") return strings.ReplaceAll(text, c.token, "<telegram-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) { func (c *Client) GetUpdates(ctx context.Context, offset int, timeoutSeconds int) ([]Update, error) {
params := map[string]any{ params := map[string]any{
"offset": offset, "offset": offset,

File diff suppressed because it is too large Load Diff

View File

@@ -87,6 +87,26 @@ func SummaryDetailsRawHTMLLimited(summary, detailsHTML string, limit int) string
return SummaryDetailsRawHTML(summary, EscapeHTML(suffix)) 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 { func CodeBlockHTML(language, text string) string {
text = strings.TrimSpace(text) text = strings.TrimSpace(text)
if text == "" { if text == "" {
@@ -197,7 +217,16 @@ func FitHTMLMessage(htmlText string, limit int) string {
return truncateHTMLText(htmlText, limit) 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 over := len([]rune(htmlText)) - limit
keep := len(contentRunes) - over - 80 keep := len(contentRunes) - over - 80
if keep < 0 { if keep < 0 {
@@ -210,7 +239,10 @@ func FitHTMLMessage(htmlText string, limit int) string {
if keep > 0 { if keep > 0 {
replacementText = strings.TrimSpace(string(contentRunes[:keep])) + "\n" + truncatedQuote 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 { if replacement == content {
return summaryOnlyHTML(htmlText, limit) return summaryOnlyHTML(htmlText, limit)
} }
@@ -219,6 +251,81 @@ func FitHTMLMessage(htmlText string, limit int) string {
return htmlText 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, "<b>") {
return "", content
}
end := strings.Index(content, "</b>")
if end < 0 {
return "", content
}
labelEnd := end + len("</b>")
label := content[:labelEnd]
if !strings.HasSuffix(label, ":</b>") {
return "", content
}
afterLabel := content[labelEnd:]
if strings.HasPrefix(afterLabel, " <pre>") {
return label + " ", strings.TrimLeft(afterLabel, " ")
}
if strings.HasPrefix(afterLabel, "\n<pre>") {
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 = "<pre>"
const preClose = "</pre>"
const codeClose = "</code>"
if !strings.HasPrefix(content, preOpen+"<code ") || !strings.HasSuffix(content, codeClose+preClose) {
return "", "", false
}
codeStart := len(preOpen)
tagEnd := strings.Index(content[codeStart:], ">")
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) { func largestBlockquoteContent(htmlText, open, close string) (int, int, string) {
bestStart := -1 bestStart := -1
bestEnd := -1 bestEnd := -1
@@ -270,15 +377,19 @@ func truncateHTMLText(htmlText string, limit int) string {
limit = TelegramHTMLMessageLimit limit = TelegramHTMLMessageLimit
} }
suffix := "\n...[truncated]" suffix := "\n...[truncated]"
runes := []rune(htmlText) if len([]rune(htmlText)) <= limit {
if len(runes) <= limit {
return htmlText return htmlText
} }
plain := stripSimpleHTML(htmlText)
runes := []rune(plain)
keep := limit - len([]rune(suffix)) keep := limit - len([]rune(suffix))
if keep < 0 { if keep < 0 {
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 { func ChunkText(text string, max int) []string {
@@ -321,9 +432,13 @@ func ParseApprovalCallbackData(data string) (int64, string, bool) {
return 0, "", false return 0, "", false
} }
switch parts[2] { switch parts[2] {
case "accept", "acceptForSession", "decline", "cancel", "details": case "accept", "acceptForSession", "acceptWithExecpolicyAmendment", "decline", "cancel", "details":
return id, parts[2], true return id, parts[2], true
default: 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 return 0, "", false
} }
} }

View File

@@ -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) { func TestApprovalResponseForPermissions(t *testing.T) {
approval := store.PendingApproval{ approval := store.PendingApproval{
Kind: "item/permissions/requestApproval", 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) { func TestParseCommand(t *testing.T) {
name, args, ok := parseCommand("/resume@my_bot 123") name, args, ok := parseCommand("/thread@my_bot 123")
if !ok || name != "resume" || len(args) != 1 || args[0] != "123" { if !ok || name != "thread" || len(args) != 1 || args[0] != "123" {
t.Fatalf("unexpected command parse: %q %#v %v", name, args, ok) t.Fatalf("unexpected command parse: %q %#v %v", name, args, ok)
} }
} }
@@ -199,15 +281,49 @@ func TestRenderCodexCommandExecutionItem(t *testing.T) {
item := codexThreadItemView{ item := codexThreadItemView{
Type: "commandExecution", Type: "commandExecution",
Command: "go test ./...", Command: "go test ./...",
CWD: "/workspace/project",
AggregatedOutput: &output, AggregatedOutput: &output,
ExitCode: &exitCode, ExitCode: &exitCode,
} }
text := renderCodexItemCompleted(item) text := renderCodexItemCompleted(item)
for _, want := range []string{"Tool call: command finished", "<b>Command</b>", "<pre><code class=\"language-bash\">go test ./...</code></pre>", "Exit code: 0", "<pre><code class=\"language-text\">line 1\nline 2</code></pre>"} { for _, want := range []string{"Tool call: command finished", "<b>CWD:</b> /workspace/project", "<b>Command:</b> <pre><code class=\"language-bash\">go test ./...</code></pre>", "<b>Output:</b> <pre><code class=\"language-text\">line 1\nline 2</code></pre>", "<b>Exit code:</b> 0"} {
if !strings.Contains(text, want) { if !strings.Contains(text, want) {
t.Fatalf("rendered command item missing %q in %q", want, text) t.Fatalf("rendered command item missing %q in %q", want, text)
} }
} }
summary, _, _ := strings.Cut(text, "<blockquote expandable>")
if strings.Contains(summary, "Exit code") {
t.Fatalf("exit code should not be in summary: %q", summary)
}
cwdAt := strings.Index(text, "<b>CWD:</b>")
commandAt := strings.Index(text, "<b>Command:</b>")
outputAt := strings.Index(text, "<b>Output:</b>")
exitAt := strings.Index(text, "<b>Exit code:</b>")
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, "<blockquote expandable>"); 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{"<b>Output:</b> " + CodeBlockHTML("text", longOutput)}, 900)
if len([]rune(text)) > 900 {
t.Fatalf("fitted message exceeds limit: %d", len([]rune(text)))
}
for _, tag := range []string{"<blockquote expandable>", "</blockquote>", "<pre>", "</pre>", "<code class=\"language-text\">", "</code>"} {
if !strings.Contains(text, tag) {
t.Fatalf("fitted message missing %q in %q", tag, text)
}
}
if strings.Count(text, "<pre>") != strings.Count(text, "</pre>") || strings.Count(text, "<code") != strings.Count(text, "</code>") || strings.Count(text, "<blockquote expandable>") != strings.Count(text, "</blockquote>") {
t.Fatalf("fitted message has unbalanced HTML tags: %q", text)
}
if strings.Contains(text, "<b>Output:</b>\n") {
t.Fatalf("label should not be separated from code block by an immediate newline: %q", text)
}
} }
func TestRenderCodexStartedItems(t *testing.T) { func TestRenderCodexStartedItems(t *testing.T) {
@@ -226,7 +342,7 @@ func TestRenderDynamicToolDetailsSelectsUsefulArguments(t *testing.T) {
Arguments: json.RawMessage(`{"cmd":"go test ./...","irrelevant":{"large":"object"}}`), Arguments: json.RawMessage(`{"cmd":"go test ./...","irrelevant":{"large":"object"}}`),
} }
text := renderCodexItemCompleted(item) text := renderCodexItemCompleted(item)
for _, want := range []string{"Tool: functions.exec_command", "<b>cmd</b>", "language-bash", "go test ./..."} { for _, want := range []string{"Tool: functions.exec_command", "<b>cmd:</b>", "language-bash", "go test ./..."} {
if !strings.Contains(text, want) { if !strings.Contains(text, want) {
t.Fatalf("rendered tool details missing %q in %q", want, text) t.Fatalf("rendered tool details missing %q in %q", want, text)
} }
@@ -244,20 +360,55 @@ func TestRenderApprovalDetailsAvoidsRawJSONDump(t *testing.T) {
t.Fatalf("approval render missing %q in %q", want, text) t.Fatalf("approval render missing %q in %q", want, text)
} }
} }
summary, _, _ := strings.Cut(text, "<blockquote expandable>")
for _, unwanted := range []string{"go test ./...", "/workspace/project"} {
if strings.Contains(summary, unwanted) {
t.Fatalf("approval summary should not include %q in %q", unwanted, summary)
}
}
if strings.Contains(text, "unused") { if strings.Contains(text, "unused") {
t.Fatalf("approval render should omit unused JSON: %q", text) t.Fatalf("approval render should omit unused JSON: %q", text)
} }
} }
func TestToolMessageAddsEditedAtBeforeDetails(t *testing.T) { func TestApprovalOnlyToolMessageCanReceiveCompletionDetails(t *testing.T) {
exitCode := 0
duration := int64(1234)
output := "done"
tool := toolMessageState{ tool := toolMessageState{
toolHTML: SummaryDetailsHTML("Tool call: command finished\nCommand: go test ./...", "full output"), approvalHTML: SummaryDetailsHTML("Codex requests command approval", "approval details"),
}
tool.toolHTML = renderCodexItemCompleted(codexThreadItemView{
Type: "commandExecution",
Command: "go test ./...",
CWD: "/workspace/project",
ExitCode: &exitCode,
DurationMs: &duration,
AggregatedOutput: &output,
})
text := tool.html()
for _, want := range []string{"Tool call: command finished", "<b>Exit code:</b> 0", "<b>Duration ms:</b>", "1234", "Codex requests command approval"} {
if !strings.Contains(text, want) {
t.Fatalf("combined approval tool message missing %q in %q", want, text)
}
}
}
func TestToolMessageAddsEditedAtInsideDetails(t *testing.T) {
tool := toolMessageState{
toolHTML: SummaryDetailsHTML("Tool call: command finished", "full output"),
editedAt: "2026-05-21 12:34:56 UTC", editedAt: "2026-05-21 12:34:56 UTC",
} }
text := tool.html() text := tool.html()
want := "Command: go test ./...\nEdited at: 2026-05-21 12:34:56 UTC\n<blockquote expandable>" summary, details, ok := strings.Cut(text, "<blockquote expandable>")
if !strings.Contains(text, want) { if !ok {
t.Fatalf("edited timestamp not placed before details: %q", text) 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, "<b>Edited at:</b> 2026-05-21 12:34:56 UTC") {
t.Fatalf("edited timestamp not placed inside details: %q", text)
} }
} }
@@ -273,7 +424,7 @@ func TestToolMessageFitsCombinedApprovalDetails(t *testing.T) {
if len([]rune(text)) > TelegramHTMLMessageLimit { if len([]rune(text)) > TelegramHTMLMessageLimit {
t.Fatalf("tool message exceeds Telegram limit: %d", len([]rune(text))) 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", "<b>Edited at:</b> 2026-05-21 12:34:56 UTC", "...[truncated]"} {
if !strings.Contains(text, want) { if !strings.Contains(text, want) {
t.Fatalf("fitted tool message missing %q in %q", want, text) t.Fatalf("fitted tool message missing %q in %q", want, text)
} }

View File

@@ -74,3 +74,8 @@ type EditMessageTextOptions struct {
ParseMode string `json:"parse_mode,omitempty"` ParseMode string `json:"parse_mode,omitempty"`
ReplyMarkup *InlineKeyboardMarkup `json:"reply_markup,omitempty"` ReplyMarkup *InlineKeyboardMarkup `json:"reply_markup,omitempty"`
} }
type BotCommand struct {
Command string `json:"command"`
Description string `json:"description"`
}