From 1d433038ab9c3011131588b199b283a9f5999c70 Mon Sep 17 00:00:00 2001 From: Codex Date: Thu, 21 May 2026 09:47:05 +0000 Subject: [PATCH] Sync thread names from Codex --- internal/codexapp/client.go | 16 +++++ internal/codexapp/client_test.go | 32 ++++++++++ internal/store/store.go | 12 ++++ internal/store/store_test.go | 20 ++++++ internal/telegram/bot.go | 103 ++++++++++++++++++++++++------- 5 files changed, 162 insertions(+), 21 deletions(-) diff --git a/internal/codexapp/client.go b/internal/codexapp/client.go index 9ec7299..b65c601 100644 --- a/internal/codexapp/client.go +++ b/internal/codexapp/client.go @@ -203,6 +203,22 @@ func (c *Client) ResumeThread(ctx context.Context, threadID string) (Thread, err return result.Thread, nil } +func (c *Client) ReadThread(ctx context.Context, threadID string) (Thread, error) { + if err := c.EnsureConnected(ctx); err != nil { + return Thread{}, err + } + var result struct { + Thread Thread `json:"thread"` + } + if err := c.call(ctx, "thread/read", map[string]any{ + "threadId": threadID, + "includeTurns": false, + }, &result); err != nil { + return Thread{}, err + } + return result.Thread, nil +} + func (c *Client) ForkThread(ctx context.Context, threadID string) (Thread, error) { if err := c.EnsureConnected(ctx); err != nil { return Thread{}, err diff --git a/internal/codexapp/client_test.go b/internal/codexapp/client_test.go index 290513b..f4bc85b 100644 --- a/internal/codexapp/client_test.go +++ b/internal/codexapp/client_test.go @@ -86,6 +86,31 @@ func TestClientWebSocketUnixJSONRPC(t *testing.T) { return } + var readThread map[string]any + if err := conn.ReadJSON(&readThread); err != nil { + serverDone <- err + return + } + if readThread["method"] != "thread/read" { + serverDone <- unexpectedMessage("thread/read", readThread["method"]) + return + } + readParams := readThread["params"].(map[string]any) + if readParams["threadId"] != "thr_1" || readParams["includeTurns"] != false { + payload, _ := json.Marshal(readParams) + serverDone <- unexpectedMessage("thread/read params", string(payload)) + return + } + if err := conn.WriteJSON(map[string]any{ + "id": readThread["id"], + "result": map[string]any{ + "thread": map[string]any{"id": "thr_1", "name": "Read title", "preview": "test"}, + }, + }); err != nil { + serverDone <- err + return + } + var setName map[string]any if err := conn.ReadJSON(&setName); err != nil { serverDone <- err @@ -145,6 +170,13 @@ func TestClientWebSocketUnixJSONRPC(t *testing.T) { if thread.ID != "thr_1" { t.Fatalf("unexpected thread: %+v", thread) } + readThread, err := client.ReadThread(ctx, "thr_1") + if err != nil { + t.Fatal(err) + } + if readThread.ID != "thr_1" || readThread.Name != "Read title" { + t.Fatalf("unexpected read thread: %+v", readThread) + } if err := client.SetThreadName(ctx, "thr_1", "Short title"); err != nil { t.Fatal(err) } diff --git a/internal/store/store.go b/internal/store/store.go index fe0a80f..24ada99 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -433,6 +433,18 @@ func (s *Store) RenameThreadByCodexID(ctx context.Context, codexThreadID, title return err } +func (s *Store) SyncThreadTitle(ctx context.Context, telegramUserID, id int64, title string) error { + _, err := s.db.ExecContext(ctx, ` +UPDATE threads SET title = ? +WHERE telegram_user_id = ? AND id = ?`, title, telegramUserID, id) + return err +} + +func (s *Store) SyncThreadTitleByCodexID(ctx context.Context, codexThreadID, title string) error { + _, err := s.db.ExecContext(ctx, "UPDATE threads SET title = ? WHERE codex_thread_id = ?", title, codexThreadID) + return err +} + func (s *Store) UpsertPendingApproval(ctx context.Context, approval PendingApproval) (PendingApproval, error) { _, err := s.db.ExecContext(ctx, ` INSERT INTO pending_approvals ( diff --git a/internal/store/store_test.go b/internal/store/store_test.go index d7975fa..aba02a3 100644 --- a/internal/store/store_test.go +++ b/internal/store/store_test.go @@ -156,6 +156,26 @@ func TestRenameThread(t *testing.T) { if thread.Title != "codex title" { t.Fatalf("title = %q", thread.Title) } + if err := st.SyncThreadTitle(ctx, 42, thread.ID, "synced title"); err != nil { + t.Fatal(err) + } + thread, err = st.GetThreadByID(ctx, 42, thread.ID) + if err != nil { + t.Fatal(err) + } + if thread.Title != "synced title" { + t.Fatalf("title = %q", thread.Title) + } + if err := st.SyncThreadTitleByCodexID(ctx, "codex-thread", "synced codex title"); err != nil { + t.Fatal(err) + } + thread, err = st.GetThreadByID(ctx, 42, thread.ID) + if err != nil { + t.Fatal(err) + } + if thread.Title != "synced codex title" { + t.Fatalf("title = %q", thread.Title) + } } func TestValidateWorkspacePath(t *testing.T) { diff --git a/internal/telegram/bot.go b/internal/telegram/bot.go index ebc68f5..a4adc70 100644 --- a/internal/telegram/bot.go +++ b/internal/telegram/bot.go @@ -270,6 +270,7 @@ func (b *Bot) sendResumeChoices(ctx context.Context, userID, chatID int64, page _, err := b.tg.SendMessage(ctx, chatID, text, SendMessageOptions{}) return err } + threads = b.syncThreadTitles(ctx, threads) hasNext := len(threads) > resumeThreadPageSize if hasNext { threads = threads[:resumeThreadPageSize] @@ -302,11 +303,9 @@ func (b *Bot) resumeThreadByID(ctx context.Context, userID, chatID int64, id int if err != nil { return b.sendError(ctx, chatID, "Could not resume Codex thread", err) } - if resumed.Name != "" && normalizeThreadTitle(resumed.Name) != thread.Title { - thread.Title = normalizeThreadTitle(resumed.Name) - if err := b.store.RenameThread(ctx, userID, thread.ID, thread.Title); err != nil { - return err - } + thread, err = b.applyCodexThreadTitle(ctx, thread, resumed) + if err != nil { + return err } if err := b.store.SetActiveThread(ctx, userID, thread.ID); err != nil { return err @@ -357,8 +356,17 @@ func (b *Bot) renameThread(ctx context.Context, userID, chatID int64, session st if err := b.codex.SetThreadName(ctx, thread.CodexThreadID, title); err != nil { return b.sendError(ctx, chatID, "Could not rename Codex thread", err) } - if err := b.store.RenameThread(ctx, userID, thread.ID, title); err != nil { - return err + if codexThread, readErr := b.codex.ReadThread(ctx, thread.CodexThreadID); readErr == nil { + thread, err = b.applyCodexThreadTitle(ctx, thread, codexThread) + if err != nil { + return err + } + title = thread.Title + } else { + b.logger.Printf("read renamed thread %s: %v", thread.CodexThreadID, readErr) + if err := b.store.SyncThreadTitle(ctx, userID, thread.ID, title); err != nil { + return err + } } _, err = b.tg.SendMessage(ctx, chatID, fmt.Sprintf("Renamed thread ID %d: %s", thread.ID, title), SendMessageOptions{}) return err @@ -373,10 +381,7 @@ func (b *Bot) forkThread(ctx context.Context, userID, chatID int64, session stor if err != nil { return b.sendError(ctx, chatID, "Could not fork Codex thread", err) } - title := forked.Name - if title == "" { - title = "fork of ID " + strconv.FormatInt(thread.ID, 10) - } + title := codexThreadTitle(forked, "fork of ID "+strconv.FormatInt(thread.ID, 10)) local, err := b.store.CreateThread(ctx, userID, forked.ID, thread.WorkspaceID, title) if err != nil { return err @@ -423,7 +428,15 @@ func (b *Bot) sendStatus(ctx context.Context, userID, chatID int64, session stor } thread := "(none)" if session.ActiveThreadID != 0 { - thread = fmt.Sprintf("#%d", session.ActiveThreadID) + thread = fmt.Sprintf("ID %d", session.ActiveThreadID) + if active, err := b.store.GetThreadByID(ctx, userID, session.ActiveThreadID); err == nil { + if synced, syncErr := b.syncThreadTitle(ctx, active); syncErr == nil { + active = synced + } else { + b.logger.Printf("sync status thread title %s: %v", active.CodexThreadID, syncErr) + } + thread = fmt.Sprintf("ID %d: %s", active.ID, threadDisplayTitle(active)) + } } model := session.Model if model == "" { @@ -703,6 +716,51 @@ func (b *Bot) continueThread(ctx context.Context, message *Message, session stor return nil } +func codexThreadTitle(thread codexapp.Thread, fallback string) string { + if title := normalizeThreadTitle(thread.Name); title != "" { + return title + } + if title := normalizeThreadTitle(thread.Preview); title != "" { + return title + } + return normalizeThreadTitle(fallback) +} + +func (b *Bot) applyCodexThreadTitle(ctx context.Context, thread store.Thread, codexThread codexapp.Thread) (store.Thread, error) { + title := codexThreadTitle(codexThread, "") + if title == thread.Title { + return thread, nil + } + if err := b.store.SyncThreadTitle(ctx, thread.TelegramUserID, thread.ID, title); err != nil { + return thread, err + } + thread.Title = title + return thread, nil +} + +func (b *Bot) syncThreadTitle(ctx context.Context, thread store.Thread) (store.Thread, error) { + if thread.CodexThreadID == "" { + return thread, nil + } + codexThread, err := b.codex.ReadThread(ctx, thread.CodexThreadID) + if err != nil { + return thread, err + } + return b.applyCodexThreadTitle(ctx, thread, codexThread) +} + +func (b *Bot) syncThreadTitles(ctx context.Context, threads []store.Thread) []store.Thread { + for i := range threads { + synced, err := b.syncThreadTitle(ctx, threads[i]) + if err != nil { + b.logger.Printf("sync thread title %s: %v", threads[i].CodexThreadID, err) + continue + } + threads[i] = synced + } + return threads +} + func (b *Bot) createNewThread(ctx context.Context, userID, chatID int64, session store.Session) (store.Thread, store.Workspace, error) { workspace, err := b.resolveWorkspace(ctx, userID, session) if err != nil { @@ -712,13 +770,7 @@ func (b *Bot) createNewThread(ctx context.Context, userID, chatID int64, session if err != nil { return store.Thread{}, store.Workspace{}, b.sendError(ctx, chatID, "Could not start Codex thread", err) } - title := codexThread.Name - if title == "" { - title = codexThread.Preview - } - if title == "" { - title = workspace.Label - } + title := codexThreadTitle(codexThread, workspace.Label) thread, err := b.store.CreateThread(ctx, userID, codexThread.ID, workspace.ID, title) if err != nil { return store.Thread{}, store.Workspace{}, err @@ -734,6 +786,11 @@ func (b *Bot) ensureThread(ctx context.Context, userID, chatID int64, session st if session.ActiveThreadID != 0 { thread, err := b.store.GetThreadByID(ctx, userID, session.ActiveThreadID) if err == nil && !thread.Archived { + if synced, syncErr := b.syncThreadTitle(ctx, thread); syncErr == nil { + thread = synced + } else { + b.logger.Printf("sync active thread title %s: %v", thread.CodexThreadID, syncErr) + } workspace, err := b.store.GetWorkspaceByID(ctx, thread.WorkspaceID) return thread, workspace, err } @@ -1236,8 +1293,12 @@ func (b *Bot) handleCodexNotification(ctx context.Context, event codexapp.Event) if err := json.Unmarshal(event.Params, ¶ms); err != nil { return err } - if params.ThreadID != "" && params.ThreadName != nil { - return b.store.RenameThreadByCodexID(ctx, params.ThreadID, normalizeThreadTitle(*params.ThreadName)) + if params.ThreadID != "" { + title := "" + if params.ThreadName != nil { + title = normalizeThreadTitle(*params.ThreadName) + } + return b.store.SyncThreadTitleByCodexID(ctx, params.ThreadID, title) } case "serverRequest/resolved": var params struct {