Compare commits
4 Commits
ab5cc4fbfe
...
34e909f9cf
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
34e909f9cf | ||
|
|
c00ffb42f2 | ||
|
|
2b0da9f508 | ||
|
|
e9dd840111 |
4
PLAN.md
4
PLAN.md
@@ -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:
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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()]
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"`
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user