Files
codex-telegram-bot/internal/telegram/render_test.go
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

483 lines
18 KiB
Go

package telegram
import (
"encoding/json"
"fmt"
"path/filepath"
"strings"
"testing"
"codex-telegram-bot/internal/store"
)
func TestEscapeHTML(t *testing.T) {
got := EscapeHTML(`<run & "test">`)
want := "&lt;run &amp; &#34;test&#34;&gt;"
if got != want {
t.Fatalf("EscapeHTML() = %q, want %q", got, want)
}
}
func TestChunkText(t *testing.T) {
text := strings.Repeat("a", 25)
chunks := ChunkText(text, 10)
if len(chunks) != 3 {
t.Fatalf("got %d chunks", len(chunks))
}
for _, chunk := range chunks {
if len([]rune(chunk)) > 10 {
t.Fatalf("chunk too long: %q", chunk)
}
}
}
func TestTelegramClientRedactsToken(t *testing.T) {
client := NewClient("secret-token")
err := client.redactError(fmt.Errorf("Post %q: context canceled", client.baseURL+"/sendMessage"))
if strings.Contains(err.Error(), "secret-token") {
t.Fatalf("token was not redacted: %v", err)
}
if !strings.Contains(err.Error(), "<telegram-token>") {
t.Fatalf("redacted token marker missing: %v", err)
}
}
func TestApprovalCallbackData(t *testing.T) {
data := ApprovalCallbackData(12, "accept")
id, decision, ok := ParseApprovalCallbackData(data)
if !ok || id != 12 || decision != "accept" {
t.Fatalf("unexpected callback parse: id=%d decision=%s ok=%v", id, decision, ok)
}
if _, _, ok := ParseApprovalCallbackData("approval:12:unknown"); ok {
t.Fatal("unknown decisions should be rejected")
}
}
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",
PayloadJSON: `{"permissions":{"network":{"enabled":true}}}`,
}
response, ok := approvalResponse(approval, "accept").(map[string]any)
if !ok {
t.Fatal("approval response should be a map")
}
if response["scope"] != "turn" {
t.Fatalf("scope = %v, want turn", response["scope"])
}
permissions, ok := response["permissions"].(map[string]any)
if !ok {
t.Fatal("permissions should be a map")
}
network, ok := permissions["network"].(map[string]any)
if !ok || network["enabled"] != true {
t.Fatalf("unexpected permissions: %#v", permissions)
}
denied, ok := approvalResponse(approval, "decline").(map[string]any)
if !ok {
t.Fatal("denied response should be a map")
}
deniedPermissions, ok := denied["permissions"].(map[string]any)
if !ok || len(deniedPermissions) != 0 {
t.Fatalf("denied permissions = %#v, want empty map", denied["permissions"])
}
}
func TestApprovalResponseForLegacyApproval(t *testing.T) {
approval := store.PendingApproval{Kind: "execCommandApproval"}
response, ok := approvalResponse(approval, "accept").(map[string]any)
if !ok {
t.Fatal("legacy response should be a map")
}
if response["decision"] != "approved" {
t.Fatalf("legacy accept = %v, want approved", response["decision"])
}
response, ok = approvalResponse(approval, "decline").(map[string]any)
if !ok {
t.Fatal("legacy decline response should be a map")
}
if response["decision"] != "denied" {
t.Fatalf("legacy decline = %v, want denied", response["decision"])
}
}
func TestRenderLegacyApprovalDetails(t *testing.T) {
raw := json.RawMessage(`{"conversationId":"thr_1","callId":"call_1","command":["git","remote","-v"],"cwd":"/workspace/project","reason":"Need remote details"}`)
text := renderApprovalHTML("execCommandApproval", raw, "")
for _, want := range []string{"Codex requests command approval", "Need remote details", "language-bash", "git", "CWD"} {
if !strings.Contains(text, want) {
t.Fatalf("legacy approval render missing %q in %q", want, text)
}
}
}
func TestEditReplyMarkupClearsInlineKeyboard(t *testing.T) {
markup := editReplyMarkup(nil)
if markup == nil {
t.Fatal("nil edit markup would leave Telegram buttons unchanged")
}
raw, err := json.Marshal(markup)
if err != nil {
t.Fatal(err)
}
if string(raw) != `{"inline_keyboard":[]}` {
t.Fatalf("clear markup JSON = %s", raw)
}
existing := approvalMarkup(7)
if editReplyMarkup(existing) != existing {
t.Fatal("non-nil markup should be preserved")
}
}
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("/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)
}
}
func TestPictureGenerationInstruction(t *testing.T) {
instruction := pictureGenerationInstruction("generate a blue cube")
for _, want := range []string{"Telegram /pic command", "built-in image generation", "generate a blue cube"} {
if !strings.Contains(instruction, want) {
t.Fatalf("instruction missing %q in %q", want, instruction)
}
}
for _, unwanted := range []string{"/home", "repo/playground"} {
if strings.Contains(instruction, unwanted) {
t.Fatalf("instruction contains non-portable text %q: %q", unwanted, instruction)
}
}
}
func TestSplitAssistantMessageSegmentsWithPhotoDirective(t *testing.T) {
photoPath := filepath.Join(string(filepath.Separator), "workspace", "photo.jpg")
text := fmt.Sprintf("before\n<!-- telegram-photo {\"path\":%q,\"caption\":\"hello\"} -->\nafter", photoPath)
segments := splitAssistantMessageSegments(text)
if len(segments) != 3 {
t.Fatalf("segments = %d, want 3: %#v", len(segments), segments)
}
if segments[0].Text != "before\n" || segments[0].Photo != nil {
t.Fatalf("unexpected first segment: %#v", segments[0])
}
if segments[1].Photo == nil || segments[1].Photo.Path != photoPath || segments[1].Photo.Caption != "hello" {
t.Fatalf("unexpected photo segment: %#v", segments[1])
}
if segments[2].Text != "after" || segments[2].Photo != nil {
t.Fatalf("unexpected final segment: %#v", segments[2])
}
}
func TestInvalidPhotoDirectiveStaysVisible(t *testing.T) {
text := "<!-- telegram-photo not-json -->"
segments := splitAssistantMessageSegments(text)
if len(segments) != 1 || segments[0].Text != text {
t.Fatalf("invalid directive should stay text: %#v", segments)
}
}
func TestSplitAssistantMessageSegmentsWithThreadDirectives(t *testing.T) {
cwd := filepath.Join(string(filepath.Separator), "workspace", "project")
text := fmt.Sprintf("<!-- codex-thread-rename {\"title\":\" A Better Thread Title \"} -->\n<!-- codex-thread-cwd {\"cwd\":%q} -->", cwd)
segments := splitAssistantMessageSegments(text)
if len(segments) != 2 {
t.Fatalf("segments = %d, want 2: %#v", len(segments), segments)
}
if segments[0].ThreadRename == nil || segments[0].ThreadRename.Title != "A Better Thread Title" {
t.Fatalf("unexpected rename segment: %#v", segments[0])
}
if segments[1].ThreadCWD == nil || segments[1].ThreadCWD.CWD != cwd {
t.Fatalf("unexpected cwd segment: %#v", segments[1])
}
}
func TestRenderCodexCommandExecutionItem(t *testing.T) {
output := "line 1\nline 2"
exitCode := 0
item := codexThreadItemView{
Type: "commandExecution",
Command: "go test ./...",
CWD: "/workspace/project",
AggregatedOutput: &output,
ExitCode: &exitCode,
}
text := renderCodexItemCompleted(item)
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) {
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) {
text := renderCodexItemStarted(codexThreadItemView{Type: "webSearch", Query: "telegram bot api"})
if !strings.Contains(text, "web search started") || !strings.Contains(text, "telegram bot api") {
t.Fatalf("unexpected web search render: %q", text)
}
}
func TestRenderDynamicToolDetailsSelectsUsefulArguments(t *testing.T) {
item := codexThreadItemView{
Type: "dynamicToolCall",
Namespace: "functions",
Tool: "exec_command",
Status: "completed",
Arguments: json.RawMessage(`{"cmd":"go test ./...","irrelevant":{"large":"object"}}`),
}
text := renderCodexItemCompleted(item)
for _, want := range []string{"Tool: functions.exec_command", "<b>cmd:</b>", "language-bash", "go test ./..."} {
if !strings.Contains(text, want) {
t.Fatalf("rendered tool details missing %q in %q", want, text)
}
}
if strings.Contains(text, "irrelevant") {
t.Fatalf("rendered tool details should omit irrelevant argument JSON: %q", text)
}
}
func TestRenderApprovalDetailsAvoidsRawJSONDump(t *testing.T) {
raw := json.RawMessage(`{"command":"go test ./...","cwd":"/workspace/project","unused":{"nested":true}}`)
text := renderApprovalHTML("item/commandExecution/requestApproval", raw, "")
for _, want := range []string{"Codex requests command approval", "language-bash", "go test ./...", "CWD"} {
if !strings.Contains(text, want) {
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") {
t.Fatalf("approval render should omit unused JSON: %q", text)
}
}
func TestApprovalOnlyToolMessageCanReceiveCompletionDetails(t *testing.T) {
exitCode := 0
duration := int64(1234)
output := "done"
tool := toolMessageState{
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",
}
text := tool.html()
summary, details, ok := strings.Cut(text, "<blockquote expandable>")
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, "<b>Edited at:</b> 2026-05-21 12:34:56 UTC") {
t.Fatalf("edited timestamp not placed inside details: %q", text)
}
}
func TestToolMessageFitsCombinedApprovalDetails(t *testing.T) {
largeToolDetails := strings.Repeat("tool output line\n", 600)
largeApprovalDetails := strings.Repeat("approval payload line\n", 600)
tool := toolMessageState{
toolHTML: SummaryDetailsHTML("Tool call: command finished", largeToolDetails),
approvalHTML: SummaryDetailsHTML("Codex requests command approval", largeApprovalDetails),
editedAt: "2026-05-21 12:34:56 UTC",
}
text := tool.html()
if len([]rune(text)) > TelegramHTMLMessageLimit {
t.Fatalf("tool message exceeds Telegram limit: %d", len([]rune(text)))
}
for _, want := range []string{"Tool call: command finished", "Codex requests command approval", "<b>Edited at:</b> 2026-05-21 12:34:56 UTC", "...[truncated]"} {
if !strings.Contains(text, want) {
t.Fatalf("fitted tool message missing %q in %q", want, text)
}
}
}
func TestResumeCallbackData(t *testing.T) {
threadID, ok := ParseResumeThreadCallbackData(ResumeThreadCallbackData(123))
if !ok || threadID != 123 {
t.Fatalf("unexpected resume thread callback: id=%d ok=%v", threadID, ok)
}
page, ok := ParseResumePageCallbackData(ResumePageCallbackData(2))
if !ok || page != 2 {
t.Fatalf("unexpected resume page callback: page=%d ok=%v", page, ok)
}
}
func TestResumeThreadListText(t *testing.T) {
threads := []store.Thread{{ID: 42, Title: "do xyz"}, {ID: 43, Title: "executed xxx command"}}
text := resumeThreadListText(threads, 0)
for _, want := range []string{"Thread ID 42: do xyz", "Thread ID 43: executed xxx command"} {
if !strings.Contains(text, want) {
t.Fatalf("resume list missing %q in %q", want, text)
}
}
markup := resumeThreadMarkup(threads, 0, true)
if len(markup.InlineKeyboard) != 2 || markup.InlineKeyboard[0][0].Text != "ID 42" || markup.InlineKeyboard[0][1].Text != "ID 43" {
t.Fatalf("unexpected resume buttons: %#v", markup.InlineKeyboard)
}
firstID, ok := ParseResumeThreadCallbackData(markup.InlineKeyboard[0][0].CallbackData)
if !ok || firstID != 42 {
t.Fatalf("first resume button targets id=%d ok=%v", firstID, ok)
}
secondID, ok := ParseResumeThreadCallbackData(markup.InlineKeyboard[0][1].CallbackData)
if !ok || secondID != 43 {
t.Fatalf("second resume button targets id=%d ok=%v", secondID, ok)
}
}
func TestModelAndEffortCallbackData(t *testing.T) {
modelID := strings.Join([]string{"server", "model", "id"}, "-")
data, ok := ModelCallbackData(modelID)
if !ok {
t.Fatal("model callback should fit")
}
model, ok := ParseModelCallbackData(data)
if !ok || model != modelID {
t.Fatalf("unexpected model callback parse: model=%q ok=%v", model, ok)
}
effortName := strings.Join([]string{"server", "effort"}, "-")
effort, ok := ParseEffortCallbackData(EffortCallbackData(effortName))
if !ok || effort != effortName {
t.Fatalf("unexpected effort callback parse: effort=%q ok=%v", effort, ok)
}
}