Add thread rename support
This commit is contained in:
28
.codex/skills/thread-naming/SKILL.md
Normal file
28
.codex/skills/thread-naming/SKILL.md
Normal 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`
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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 == "" {
|
||||
|
||||
Reference in New Issue
Block a user