From bc866d122428393745e5e3f86e403206fc3afe26 Mon Sep 17 00:00:00 2001 From: Codex Date: Thu, 21 May 2026 09:31:47 +0000 Subject: [PATCH] Add thread rename support --- .codex/skills/thread-naming/SKILL.md | 28 +++++++++ README.md | 2 +- internal/codexapp/client.go | 11 ++++ internal/codexapp/client_test.go | 23 ++++++++ internal/store/store.go | 12 ++++ internal/store/store_test.go | 38 ++++++++++++ internal/telegram/bot.go | 87 +++++++++++++++++++++++++++- 7 files changed, 197 insertions(+), 4 deletions(-) create mode 100644 .codex/skills/thread-naming/SKILL.md diff --git a/.codex/skills/thread-naming/SKILL.md b/.codex/skills/thread-naming/SKILL.md new file mode 100644 index 0000000..a2e6a44 --- /dev/null +++ b/.codex/skills/thread-naming/SKILL.md @@ -0,0 +1,28 @@ +--- +name: thread-naming +description: Use when a Codex agent should keep the current thread title short, rename a thread after the user asks for it, or update the title when the task goal becomes clear. +--- + +# Thread Naming + +Keep thread titles short and useful for later resume lists. + +## Rules + +- Use 3 to 6 words when possible. +- Prefer concrete nouns and verbs from the current task. +- Do not include dates, user names, punctuation-heavy labels, or generic words like "chat" unless needed. +- Rename once the task goal is clear, and again only if the goal materially changes. + +## How to Rename + +- If the client exposes a thread rename or `thread/setName` capability, use it with the concise title. +- If only Telegram commands are available, tell the user the exact `/rename TITLE` command to run. +- If no rename mechanism is available, include a short suggested title in the final answer. + +## Examples + +- `Fix Resume Buttons` +- `Publish Gitea Repo` +- `Format Tool Output` +- `Add Rename Command` diff --git a/README.md b/README.md index fff687e..d256f19 100644 --- a/README.md +++ b/README.md @@ -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. -Supported commands: `/start`, `/help`, `/new`, `/threads`, `/resume`, `/fork`, `/archive`, `/status`, `/cancel`, `/workspaces`, `/workspace`, `/model`, `/sandbox`, `/diff`. `/model` lists available Codex models as inline buttons, then shows reasoning-effort buttons for the selected model. +Supported commands: `/start`, `/help`, `/new`, `/threads`, `/resume`, `/rename`, `/fork`, `/archive`, `/status`, `/cancel`, `/workspaces`, `/workspace`, `/model`, `/sandbox`, `/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. 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. diff --git a/internal/codexapp/client.go b/internal/codexapp/client.go index 5648766..0e6d6c3 100644 --- a/internal/codexapp/client.go +++ b/internal/codexapp/client.go @@ -224,6 +224,17 @@ func (c *Client) ArchiveThread(ctx context.Context, threadID string) error { return c.call(ctx, "thread/archive", map[string]any{"threadId": threadID}, &ignored) } +func (c *Client) SetThreadName(ctx context.Context, threadID, name string) error { + if err := c.EnsureConnected(ctx); err != nil { + return err + } + var ignored json.RawMessage + return c.call(ctx, "thread/setName", map[string]any{ + "threadId": threadID, + "name": name, + }, &ignored) +} + func (c *Client) StartTurn(ctx context.Context, threadID, cwd, model, reasoningEffort, sandbox string, input []InputItem) (Turn, error) { if err := c.EnsureConnected(ctx); err != nil { return Turn{}, err diff --git a/internal/codexapp/client_test.go b/internal/codexapp/client_test.go index d5b1385..0fad9f3 100644 --- a/internal/codexapp/client_test.go +++ b/internal/codexapp/client_test.go @@ -86,6 +86,26 @@ func TestClientWebSocketUnixJSONRPC(t *testing.T) { return } + var setName map[string]any + if err := conn.ReadJSON(&setName); err != nil { + serverDone <- err + return + } + if setName["method"] != "thread/setName" { + serverDone <- unexpectedMessage("thread/setName", setName["method"]) + return + } + params := setName["params"].(map[string]any) + if params["threadId"] != "thr_1" || params["name"] != "Short title" { + payload, _ := json.Marshal(params) + serverDone <- unexpectedMessage("thread/setName params", string(payload)) + return + } + if err := conn.WriteJSON(map[string]any{"id": setName["id"], "result": map[string]any{}}); err != nil { + serverDone <- err + return + } + var response map[string]any if err := conn.ReadJSON(&response); err != nil { serverDone <- err @@ -125,6 +145,9 @@ func TestClientWebSocketUnixJSONRPC(t *testing.T) { if thread.ID != "thr_1" { t.Fatalf("unexpected thread: %+v", thread) } + if err := client.SetThreadName(ctx, "thr_1", "Short title"); err != nil { + t.Fatal(err) + } if err := client.RespondServerRequest(ctx, 99, "accept"); err != nil { t.Fatal(err) } diff --git a/internal/store/store.go b/internal/store/store.go index 8a5e7f4..fe0a80f 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -421,6 +421,18 @@ func (s *Store) TouchThread(ctx context.Context, codexThreadID string) error { return err } +func (s *Store) RenameThread(ctx context.Context, telegramUserID, id int64, title string) error { + _, err := s.db.ExecContext(ctx, ` +UPDATE threads SET title = ?, updated_at = datetime('now') +WHERE telegram_user_id = ? AND id = ?`, title, telegramUserID, id) + return err +} + +func (s *Store) RenameThreadByCodexID(ctx context.Context, codexThreadID, title string) error { + _, err := s.db.ExecContext(ctx, "UPDATE threads SET title = ?, updated_at = datetime('now') 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 e985654..d7975fa 100644 --- a/internal/store/store_test.go +++ b/internal/store/store_test.go @@ -120,6 +120,44 @@ func TestListThreadsPage(t *testing.T) { } } +func TestRenameThread(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() + + ws, err := st.AddWorkspace(ctx, t.TempDir(), "repo", true) + if err != nil { + t.Fatal(err) + } + thread, err := st.CreateThread(ctx, 42, "codex-thread", ws.ID, "old title") + if err != nil { + t.Fatal(err) + } + if err := st.RenameThread(ctx, 42, thread.ID, "new title"); err != nil { + t.Fatal(err) + } + thread, err = st.GetThreadByID(ctx, 42, thread.ID) + if err != nil { + t.Fatal(err) + } + if thread.Title != "new title" { + t.Fatalf("title = %q", thread.Title) + } + if err := st.RenameThreadByCodexID(ctx, "codex-thread", "codex title"); err != nil { + t.Fatal(err) + } + thread, err = st.GetThreadByID(ctx, 42, thread.ID) + if err != nil { + t.Fatal(err) + } + if thread.Title != "codex title" { + t.Fatalf("title = %q", thread.Title) + } +} + func TestValidateWorkspacePath(t *testing.T) { if _, err := ValidateWorkspacePath("relative/path"); err == nil { t.Fatal("relative path should be rejected") diff --git a/internal/telegram/bot.go b/internal/telegram/bot.go index aa3a314..ebc68f5 100644 --- a/internal/telegram/bot.go +++ b/internal/telegram/bot.go @@ -180,6 +180,8 @@ func (b *Bot) handleCommand(ctx context.Context, message *Message, session store return true, b.sendThreads(ctx, userID, chatID) case "resume": return true, b.resumeThread(ctx, userID, chatID, args) + case "rename": + return true, b.renameThread(ctx, userID, chatID, session, args) case "fork": return true, b.forkThread(ctx, userID, chatID, session) case "archive": @@ -212,6 +214,7 @@ func (b *Bot) sendHelp(ctx context.Context, chatID int64) error { "/thread or /threads - list recent threads", "/resume - choose a recent thread", "/resume ID - resume a thread", + "/rename TITLE or /rename ID TITLE - rename a thread", "/fork - fork the active thread", "/archive [ID] - archive a thread", "/status - show active settings", @@ -295,9 +298,16 @@ func (b *Bot) resumeThreadByID(ctx context.Context, userID, chatID int64, id int } return err } - if _, err := b.codex.ResumeThread(ctx, thread.CodexThreadID); err != nil { + resumed, err := b.codex.ResumeThread(ctx, thread.CodexThreadID) + 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 + } + } if err := b.store.SetActiveThread(ctx, userID, thread.ID); err != nil { return err } @@ -310,6 +320,50 @@ func (b *Bot) resumeThreadByID(ctx context.Context, userID, chatID int64, id int return err } +func (b *Bot) renameThread(ctx context.Context, userID, chatID int64, session store.Session, args []string) error { + if len(args) == 0 { + _, err := b.tg.SendMessage(ctx, chatID, "Use /rename TITLE for the active thread, or /rename THREAD_ID TITLE.", SendMessageOptions{}) + return err + } + + var thread store.Thread + titleArgs := args + var err error + if len(args) > 1 { + if id, parseErr := strconv.ParseInt(args[0], 10, 64); parseErr == nil { + thread, err = b.store.GetThreadByID(ctx, userID, id) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + _, sendErr := b.tg.SendMessage(ctx, chatID, "Thread not found.", SendMessageOptions{}) + return sendErr + } + return err + } + titleArgs = args[1:] + } + } + if thread.ID == 0 { + thread, err = b.activeThread(ctx, userID, session) + } + if err != nil { + return b.sendNoActiveThread(ctx, chatID, err) + } + + title := normalizeThreadTitle(strings.Join(titleArgs, " ")) + if title == "" { + _, err := b.tg.SendMessage(ctx, chatID, "Thread title cannot be empty.", SendMessageOptions{}) + return err + } + 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 + } + _, err = b.tg.SendMessage(ctx, chatID, fmt.Sprintf("Renamed thread ID %d: %s", thread.ID, title), SendMessageOptions{}) + return err +} + func (b *Bot) forkThread(ctx context.Context, userID, chatID int64, session store.Session) error { thread, err := b.activeThread(ctx, userID, session) if err != nil { @@ -319,7 +373,11 @@ 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) } - local, err := b.store.CreateThread(ctx, userID, forked.ID, thread.WorkspaceID, "fork of #"+strconv.FormatInt(thread.ID, 10)) + title := forked.Name + if title == "" { + title = "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 } @@ -654,7 +712,10 @@ 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.Preview + title := codexThread.Name + if title == "" { + title = codexThread.Preview + } if title == "" { title = workspace.Label } @@ -1167,6 +1228,17 @@ func (b *Bot) handleCodexNotification(ctx context.Context, event codexapp.Event) } return b.completeTurnOutput(ctx, params.ThreadID) } + case "thread/name/updated": + var params struct { + ThreadID string `json:"threadId"` + ThreadName *string `json:"threadName"` + } + 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)) + } case "serverRequest/resolved": var params struct { ThreadID string `json:"threadId"` @@ -1476,6 +1548,15 @@ func resumeThreadMarkup(threads []store.Thread, page int, hasNext bool) *InlineK return &InlineKeyboardMarkup{InlineKeyboard: keyboard} } +func normalizeThreadTitle(title string) string { + title = strings.Join(strings.Fields(title), " ") + runes := []rune(title) + if len(runes) > 80 { + title = string(runes[:80]) + } + return title +} + func threadDisplayTitle(thread store.Thread) string { title := strings.Join(strings.Fields(thread.Title), " ") if title == "" {