diff --git a/cmd/bot/main.go b/cmd/bot/main.go index 4516300..08065f0 100644 --- a/cmd/bot/main.go +++ b/cmd/bot/main.go @@ -34,6 +34,7 @@ func main() { tg := telegram.NewClient(cfg.TelegramToken) codex := codexapp.New(cfg.CodexSocketPath, cfg.AppVersion) + codex.SetLogger(logger) defer codex.Close() bot := telegram.NewBot(tg, st, codex, cfg.UploadDir, cfg.CodexHome, cfg.CodexStateDB, cfg.DefaultModel, cfg.DefaultSandbox, cfg.PollTimeout, logger) diff --git a/internal/codexapp/client.go b/internal/codexapp/client.go index 3d741e1..3d15bdc 100644 --- a/internal/codexapp/client.go +++ b/internal/codexapp/client.go @@ -5,6 +5,7 @@ import ( "encoding/json" "errors" "fmt" + "log" "net" "net/http" "strconv" @@ -26,6 +27,7 @@ type Client struct { pending map[string]chan rpcResult events chan Event connected bool + logger *log.Logger } 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 { return c.events } @@ -240,10 +248,11 @@ func (c *Client) StartThread(ctx context.Context, cwd, model, sandbox string) (T return Thread{}, err } params := map[string]any{ - "cwd": cwd, - "approvalPolicy": "on-request", - "sandbox": threadSandbox(sandbox), - "serviceName": "codex_telegram_bot", + "cwd": cwd, + "approvalPolicy": "on-request", + "approvalsReviewer": "user", + "sandbox": threadSandbox(sandbox), + "serviceName": "codex_telegram_bot", } if model != "" { params["model"] = model @@ -327,9 +336,10 @@ func (c *Client) StartTurn(ctx context.Context, threadID, cwd, model, reasoningE return Turn{}, err } params := map[string]any{ - "threadId": threadID, - "input": input, - "approvalPolicy": "on-request", + "threadId": threadID, + "input": input, + "approvalPolicy": "on-request", + "approvalsReviewer": "user", } if strings.TrimSpace(cwd) != "" { params["cwd"] = cwd @@ -463,6 +473,7 @@ func (c *Client) readLoop(conn *websocket.Conn) { return } if env.Method != "" && hasID { + c.logProtocolEvent("server request", env.Method, id.Key(), env.Params) c.events <- Event{ ID: &id, Method: env.Method, @@ -476,6 +487,9 @@ func (c *Client) readLoop(conn *websocket.Conn) { continue } if env.Method != "" { + if shouldLogNotification(env.Method) { + c.logProtocolEvent("notification", env.Method, "", env.Params) + } c.events <- Event{ Method: env.Method, 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) { c.mu.Lock() ch := c.pending[id.Key()] diff --git a/internal/codexapp/client_test.go b/internal/codexapp/client_test.go index 3cb18b0..5886590 100644 --- a/internal/codexapp/client_test.go +++ b/internal/codexapp/client_test.go @@ -77,6 +77,12 @@ func TestClientWebSocketUnixJSONRPC(t *testing.T) { serverDone <- unexpectedMessage("thread/start", start["method"]) 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{ "id": start["id"], "result": map[string]any{ @@ -88,6 +94,26 @@ func TestClientWebSocketUnixJSONRPC(t *testing.T) { 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 if err := conn.ReadJSON(&readThread); err != nil { serverDone <- err @@ -174,6 +200,14 @@ func TestClientWebSocketUnixJSONRPC(t *testing.T) { if thread.ID != "thr_1" || thread.CWD != projectCWD { 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") if err != nil { t.Fatal(err) diff --git a/internal/telegram/bot.go b/internal/telegram/bot.go index d3290b1..4314f9b 100644 --- a/internal/telegram/bot.go +++ b/internal/telegram/bot.go @@ -1266,6 +1266,15 @@ func parseCodexThreadItem(raw json.RawMessage) (codexThreadItemView, error) { 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 { switch item.Type { case "commandExecution": @@ -1702,6 +1711,40 @@ func (b *Bot) handleCodexNotification(ctx context.Context, event codexapp.Event) return err } 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": var params struct { 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") } 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) if err != nil { 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 { b.mu.Lock() defer b.mu.Unlock()