Add thread rename support

This commit is contained in:
Codex
2026-05-21 09:31:47 +00:00
parent ad61f7eeed
commit bc866d1224
7 changed files with 197 additions and 4 deletions

View File

@@ -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`

View File

@@ -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.

View File

@@ -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

View File

@@ -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)
}

View File

@@ -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 (

View File

@@ -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")

View File

@@ -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, &params); 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 == "" {