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.
|
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.
|
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)
|
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) {
|
func (c *Client) StartTurn(ctx context.Context, threadID, cwd, model, reasoningEffort, sandbox string, input []InputItem) (Turn, error) {
|
||||||
if err := c.EnsureConnected(ctx); err != nil {
|
if err := c.EnsureConnected(ctx); err != nil {
|
||||||
return Turn{}, err
|
return Turn{}, err
|
||||||
|
|||||||
@@ -86,6 +86,26 @@ func TestClientWebSocketUnixJSONRPC(t *testing.T) {
|
|||||||
return
|
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
|
var response map[string]any
|
||||||
if err := conn.ReadJSON(&response); err != nil {
|
if err := conn.ReadJSON(&response); err != nil {
|
||||||
serverDone <- err
|
serverDone <- err
|
||||||
@@ -125,6 +145,9 @@ func TestClientWebSocketUnixJSONRPC(t *testing.T) {
|
|||||||
if thread.ID != "thr_1" {
|
if thread.ID != "thr_1" {
|
||||||
t.Fatalf("unexpected thread: %+v", thread)
|
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 {
|
if err := client.RespondServerRequest(ctx, 99, "accept"); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -421,6 +421,18 @@ func (s *Store) TouchThread(ctx context.Context, codexThreadID string) error {
|
|||||||
return err
|
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) {
|
func (s *Store) UpsertPendingApproval(ctx context.Context, approval PendingApproval) (PendingApproval, error) {
|
||||||
_, err := s.db.ExecContext(ctx, `
|
_, err := s.db.ExecContext(ctx, `
|
||||||
INSERT INTO pending_approvals (
|
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) {
|
func TestValidateWorkspacePath(t *testing.T) {
|
||||||
if _, err := ValidateWorkspacePath("relative/path"); err == nil {
|
if _, err := ValidateWorkspacePath("relative/path"); err == nil {
|
||||||
t.Fatal("relative path should be rejected")
|
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)
|
return true, b.sendThreads(ctx, userID, chatID)
|
||||||
case "resume":
|
case "resume":
|
||||||
return true, b.resumeThread(ctx, userID, chatID, args)
|
return true, b.resumeThread(ctx, userID, chatID, args)
|
||||||
|
case "rename":
|
||||||
|
return true, b.renameThread(ctx, userID, chatID, session, args)
|
||||||
case "fork":
|
case "fork":
|
||||||
return true, b.forkThread(ctx, userID, chatID, session)
|
return true, b.forkThread(ctx, userID, chatID, session)
|
||||||
case "archive":
|
case "archive":
|
||||||
@@ -212,6 +214,7 @@ func (b *Bot) sendHelp(ctx context.Context, chatID int64) error {
|
|||||||
"/thread or /threads - list recent threads",
|
"/thread or /threads - list recent threads",
|
||||||
"/resume - choose a recent thread",
|
"/resume - choose a recent thread",
|
||||||
"/resume ID - resume a thread",
|
"/resume ID - resume a thread",
|
||||||
|
"/rename TITLE or /rename ID TITLE - rename a thread",
|
||||||
"/fork - fork the active thread",
|
"/fork - fork the active thread",
|
||||||
"/archive [ID] - archive a thread",
|
"/archive [ID] - archive a thread",
|
||||||
"/status - show active settings",
|
"/status - show active settings",
|
||||||
@@ -295,9 +298,16 @@ func (b *Bot) resumeThreadByID(ctx context.Context, userID, chatID int64, id int
|
|||||||
}
|
}
|
||||||
return err
|
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)
|
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 {
|
if err := b.store.SetActiveThread(ctx, userID, thread.ID); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -310,6 +320,50 @@ func (b *Bot) resumeThreadByID(ctx context.Context, userID, chatID int64, id int
|
|||||||
return err
|
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 {
|
func (b *Bot) forkThread(ctx context.Context, userID, chatID int64, session store.Session) error {
|
||||||
thread, err := b.activeThread(ctx, userID, session)
|
thread, err := b.activeThread(ctx, userID, session)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -319,7 +373,11 @@ func (b *Bot) forkThread(ctx context.Context, userID, chatID int64, session stor
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return b.sendError(ctx, chatID, "Could not fork Codex thread", err)
|
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 {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -654,7 +712,10 @@ func (b *Bot) createNewThread(ctx context.Context, userID, chatID int64, session
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return store.Thread{}, store.Workspace{}, b.sendError(ctx, chatID, "Could not start Codex thread", err)
|
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 == "" {
|
if title == "" {
|
||||||
title = workspace.Label
|
title = workspace.Label
|
||||||
}
|
}
|
||||||
@@ -1167,6 +1228,17 @@ func (b *Bot) handleCodexNotification(ctx context.Context, event codexapp.Event)
|
|||||||
}
|
}
|
||||||
return b.completeTurnOutput(ctx, params.ThreadID)
|
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":
|
case "serverRequest/resolved":
|
||||||
var params struct {
|
var params struct {
|
||||||
ThreadID string `json:"threadId"`
|
ThreadID string `json:"threadId"`
|
||||||
@@ -1476,6 +1548,15 @@ func resumeThreadMarkup(threads []store.Thread, page int, hasNext bool) *InlineK
|
|||||||
return &InlineKeyboardMarkup{InlineKeyboard: keyboard}
|
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 {
|
func threadDisplayTitle(thread store.Thread) string {
|
||||||
title := strings.Join(strings.Fields(thread.Title), " ")
|
title := strings.Join(strings.Fields(thread.Title), " ")
|
||||||
if title == "" {
|
if title == "" {
|
||||||
|
|||||||
Reference in New Issue
Block a user