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.
This commit is contained in:
@@ -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
|
||||||
}
|
}
|
||||||
@@ -242,6 +250,7 @@ func (c *Client) StartThread(ctx context.Context, cwd, model, sandbox string) (T
|
|||||||
params := map[string]any{
|
params := map[string]any{
|
||||||
"cwd": cwd,
|
"cwd": cwd,
|
||||||
"approvalPolicy": "on-request",
|
"approvalPolicy": "on-request",
|
||||||
|
"approvalsReviewer": "user",
|
||||||
"sandbox": threadSandbox(sandbox),
|
"sandbox": threadSandbox(sandbox),
|
||||||
"serviceName": "codex_telegram_bot",
|
"serviceName": "codex_telegram_bot",
|
||||||
}
|
}
|
||||||
@@ -330,6 +339,7 @@ func (c *Client) StartTurn(ctx context.Context, threadID, cwd, model, reasoningE
|
|||||||
"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)
|
||||||
|
|||||||
@@ -1266,6 +1266,15 @@ func parseCodexThreadItem(raw json.RawMessage) (codexThreadItemView, error) {
|
|||||||
return item, nil
|
return item, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func renderGuardianReviewHTML(title string, raw json.RawMessage) string {
|
||||||
|
var params map[string]any
|
||||||
|
if err := json.Unmarshal(raw, ¶ms); err != nil {
|
||||||
|
return SummaryDetailsHTML(title, string(raw))
|
||||||
|
}
|
||||||
|
sections := renderSelectedArgumentDetailsHTML(params, []string{"action", "review", "decisionSource", "targetItemId", "reviewId"})
|
||||||
|
return SummaryRawHTMLSectionsLimited(title, sections, TelegramHTMLMessageLimit)
|
||||||
|
}
|
||||||
|
|
||||||
func renderCodexItemStarted(item codexThreadItemView) string {
|
func renderCodexItemStarted(item codexThreadItemView) string {
|
||||||
switch item.Type {
|
switch item.Type {
|
||||||
case "commandExecution":
|
case "commandExecution":
|
||||||
@@ -1702,6 +1711,40 @@ func (b *Bot) handleCodexNotification(ctx context.Context, event codexapp.Event)
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return b.sendImageOutput(ctx, params.ThreadID, item)
|
return b.sendImageOutput(ctx, params.ThreadID, item)
|
||||||
|
case "item/guardianApprovalReview/started", "item/guardianApprovalReview/completed":
|
||||||
|
var params struct {
|
||||||
|
ThreadID string `json:"threadId"`
|
||||||
|
TurnID string `json:"turnId"`
|
||||||
|
ReviewID string `json:"reviewId"`
|
||||||
|
TargetItemID *string `json:"targetItemId"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(event.Params, ¶ms); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
targetItemID := ""
|
||||||
|
if params.TargetItemID != nil {
|
||||||
|
targetItemID = *params.TargetItemID
|
||||||
|
}
|
||||||
|
b.logger.Printf("codex guardian approval review: method=%s thread=%s turn=%s review=%s target=%s", event.Method, params.ThreadID, params.TurnID, params.ReviewID, targetItemID)
|
||||||
|
if params.ThreadID != "" && b.shouldHandleOutputEvent(params.ThreadID, params.TurnID) {
|
||||||
|
title := "Codex approval auto-review started"
|
||||||
|
if event.Method == "item/guardianApprovalReview/completed" {
|
||||||
|
title = "Codex approval auto-review completed"
|
||||||
|
}
|
||||||
|
return b.sendOutputHTMLBlock(ctx, params.ThreadID, renderGuardianReviewHTML(title, event.Params))
|
||||||
|
}
|
||||||
|
case "guardianWarning":
|
||||||
|
var params struct {
|
||||||
|
ThreadID string `json:"threadId"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(event.Params, ¶ms); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
b.logger.Printf("codex guardian warning: thread=%s message=%q", params.ThreadID, truncateForStatus(params.Message))
|
||||||
|
if params.ThreadID != "" && b.hasOutputThread(params.ThreadID) {
|
||||||
|
return b.sendOutputBlock(ctx, params.ThreadID, "Codex warning: "+params.Message)
|
||||||
|
}
|
||||||
case "turn/diff/updated":
|
case "turn/diff/updated":
|
||||||
var params struct {
|
var params struct {
|
||||||
ThreadID string `json:"threadId"`
|
ThreadID string `json:"threadId"`
|
||||||
@@ -1830,6 +1873,7 @@ func (b *Bot) handleCodexServerRequest(ctx context.Context, event codexapp.Event
|
|||||||
return errors.New("approval request missing threadId")
|
return errors.New("approval request missing threadId")
|
||||||
}
|
}
|
||||||
itemID := firstNonEmpty(params.ApprovalID, params.ItemID, params.CallID)
|
itemID := firstNonEmpty(params.ApprovalID, params.ItemID, params.CallID)
|
||||||
|
b.logger.Printf("codex approval request: method=%s request=%s thread=%s turn=%s item=%s approval=%s call=%s", event.Method, event.ID.Key(), threadID, params.TurnID, params.ItemID, params.ApprovalID, params.CallID)
|
||||||
thread, err := b.store.GetThreadByCodexID(ctx, threadID)
|
thread, err := b.store.GetThreadByCodexID(ctx, threadID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -1911,6 +1955,12 @@ func (b *Bot) setOutputTurnID(threadID, turnID string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (b *Bot) hasOutputThread(threadID string) bool {
|
||||||
|
b.mu.Lock()
|
||||||
|
defer b.mu.Unlock()
|
||||||
|
return b.outputs[threadID] != nil
|
||||||
|
}
|
||||||
|
|
||||||
func (b *Bot) hasOutputTurn(threadID, turnID string) bool {
|
func (b *Bot) hasOutputTurn(threadID, turnID string) bool {
|
||||||
b.mu.Lock()
|
b.mu.Lock()
|
||||||
defer b.mu.Unlock()
|
defer b.mu.Unlock()
|
||||||
|
|||||||
Reference in New Issue
Block a user