From c144b35b6c95123e39a5a63a4676b378e6291ba6 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 01:03:18 +0800 Subject: [PATCH 1/2] feat(cli): thread rename, pin, and title search for headless operators MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The HTTP surface and Desktop chat list already support renaming, pinning, and title search for conversations (store/thread_metadata.go + httpapi/thread_metadata.go); the CLI — the documented first-class surface for headless and NDJSON workflows — had no access to any of them. Add 'cyberagent thread rename ', 'cyberagent thread pin <id> [--pinned true|false]', and 'thread list --title-query/--pinned' with the same store semantics (no authority, not-found for deleted threads, monotonic versions). The text listing now shows pinned state per row. Co-Authored-By: Claude <noreply@anthropic.com> --- docs/usage.md | 29 +++++++++ internal/app/app.go | 2 +- internal/app/thread_command.go | 81 +++++++++++++++++++++++-- internal/app/thread_command_test.go | 94 +++++++++++++++++++++++++++++ 4 files changed, 199 insertions(+), 7 deletions(-) diff --git a/docs/usage.md b/docs/usage.md index fb4b42d2c..aa6074974 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -97,6 +97,35 @@ false until that matrix is signed. ## Threads, Runs, and advanced diagnostics / Thread、Run 与高级诊断 +### CLI thread metadata and search / CLI 线程元数据与搜索 + +The Desktop chat list can rename and pin conversations; the CLI now exposes the same +thread metadata plus title search for headless work, against the identical store +semantics (no authority, deleted Threads stay not-found): + +```powershell +cyberagent thread rename <thread-id> "Parser refactoring plan" +cyberagent thread pin <thread-id> +cyberagent thread pin <thread-id> --pinned false +cyberagent thread list --title-query "parser refactoring" --pinned true +cyberagent thread list --json +``` + +`--title-query` is an ASCII case-insensitive literal substring match (other Unicode +characters compare exactly, at most 256 characters); `--pinned true|false` filters on +the pin state, and the text listing shows `pinned=` per row. `thread rename` rejects +empty titles, control characters, and titles over 4096 characters; a missing Thread +returns the standard not-found error. Both commands print the new `version`, which +never decreases and increases on every actual change, so identical retries are safe. + +桌面聊天列表可以重命名与置顶会话;CLI 现在通过同一套存储语义暴露相同的线程元数据 +与标题搜索,供无人值守流程使用(不授予任何权限,已删除线程仍按不存在处理)。 +`--title-query` 为 ASCII 大小写不敏感的字面子串匹配(其余 Unicode 字符按精确比较, +最长 256 字符);`--pinned true|false` 按置顶状态过滤,文本列表每行带 `pinned=` 标记。 +`thread rename` 拒绝空标题、控制字符与超过 4096 字符的标题;线程不存在返回标准 +not-found 错误。两个命令都会输出新的 `version`——它单调不减、每次实际变更都会递增, +因此同样的重试是安全的。 + ### 补充消息与排队 / Follow-up messages and queue 任务运行中,发送的补充消息会排到输入框上方,当前执行结束后依次处理。文字消息可点击 diff --git a/internal/app/app.go b/internal/app/app.go index 9ad249948..4502aaf77 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -384,7 +384,7 @@ func (a *App) printHelp() { fmt.Fprintln(a.out, " cyberagent skill list|show|validate|package|import|installed|remove|candidate|candidates|select|selection|select-external|external-selection") fmt.Fprintln(a.out, " cyberagent context compact|show|instructions|memory") fmt.Fprintln(a.out, " cyberagent context memory create|list|show|edit|enable|disable|delete|export") - fmt.Fprintln(a.out, " cyberagent thread create|list|show|send|history|archive|restore|delete|export") + fmt.Fprintln(a.out, " cyberagent thread create|list|show|send|history|rename|pin|archive|restore|delete|export") fmt.Fprintln(a.out, " cyberagent session create|list|send|history|tree|checkpoint|fork|resume") fmt.Fprintln(a.out, " cyberagent tool schema|invoke|list|show|approve|deny") fmt.Fprintln(a.out, " cyberagent edit propose|list|show|review-approve|review-deny|approve|deny") diff --git a/internal/app/thread_command.go b/internal/app/thread_command.go index 1bd62d488..2c87c161b 100644 --- a/internal/app/thread_command.go +++ b/internal/app/thread_command.go @@ -37,6 +37,10 @@ func (a *App) threadCommand(ctx context.Context, args []string) error { return a.threadCompactions(ctx, args[1:]) case "history": return a.threadHistory(ctx, service, args[1:]) + case "rename": + return a.threadRename(ctx, args[1:]) + case "pin": + return a.threadPin(ctx, args[1:]) case "archive", "restore", "delete": return a.threadLifecycle(ctx, service, args[0], args[1:]) case "export": @@ -128,22 +132,37 @@ func (a *App) threadList(ctx context.Context, service *application.ThreadService fs := newFlagSet("thread list", a.errOut) statusValue := fs.String("status", "", "Thread status") includeDeleted := fs.Bool("include-deleted", false, "include soft-deleted Threads") + titleQuery := fs.String("title-query", "", "case-insensitive literal title substring") + pinnedValue := fs.String("pinned", "", "filter by pinned state: true|false") limit := fs.Int("limit", 100, "maximum rows") jsonOutput := fs.Bool("json", false, "emit JSON") if err := fs.Parse(reorderFlags(args, map[string]bool{ - "status": true, "include-deleted": false, "limit": true, "json": false, + "status": true, "include-deleted": false, "title-query": true, + "pinned": true, "limit": true, "json": false, })); err != nil { return err } if fs.NArg() != 0 || *limit <= 0 || *limit > 1000 { - return errors.New("usage: cyberagent thread list [--status active|archived|deleted] [--include-deleted] [--limit <1..1000>] [--json]") + return errors.New("usage: cyberagent thread list [--status active|archived|deleted] [--include-deleted] [--title-query <text>] [--pinned true|false] [--limit <1..1000>] [--json]") } status := domain.ThreadStatus(strings.TrimSpace(*statusValue)) if status != "" && !domain.ValidThreadStatus(status) { return fmt.Errorf("invalid Thread status %q", status) } - items, err := service.List(ctx, domain.ThreadFilter{Status: status, - IncludeDeleted: *includeDeleted, Limit: *limit}, time.Time{}, "") + pinned := strings.TrimSpace(*pinnedValue) + if pinned != "" && pinned != "true" && pinned != "false" { + return fmt.Errorf("invalid pinned filter %q: use true or false", *pinnedValue) + } + filter := domain.ThreadFilter{Status: status, IncludeDeleted: *includeDeleted, + Limit: *limit} + if strings.TrimSpace(*titleQuery) != "" { + filter.TitleQuery = *titleQuery + } + if pinned != "" { + value := pinned == "true" + filter.Pinned = &value + } + items, err := service.List(ctx, filter, time.Time{}, "") if err != nil { return err } @@ -155,8 +174,8 @@ func (a *App) threadList(ctx context.Context, service *application.ThreadService return nil } for _, item := range items { - fmt.Fprintf(a.out, "%s\t%s\tlast=%s\tactive=%s\t%s\n", item.ID, - item.Status, item.LastRunID, item.ActiveRunID, item.Title) + fmt.Fprintf(a.out, "%s\t%s\tlast=%s\tactive=%s\tpinned=%t\t%s\n", item.ID, + item.Status, item.LastRunID, item.ActiveRunID, item.Pinned, item.Title) } return nil } @@ -261,6 +280,56 @@ func (a *App) threadHistory(ctx context.Context, service *application.ThreadServ return nil } +func (a *App) threadRename(ctx context.Context, args []string) error { + fs := newFlagSet("thread rename", a.errOut) + jsonOutput := fs.Bool("json", false, "emit JSON") + if err := fs.Parse(reorderFlags(args, map[string]bool{"json": false})); err != nil { + return err + } + if fs.NArg() < 2 { + return errors.New(`usage: cyberagent thread rename <thread-id> "new title" [--json]`) + } + threadRecord, err := a.store.RenameThread(ctx, fs.Arg(0), + strings.Join(fs.Args()[1:], " ")) + if err != nil { + return err + } + if *jsonOutput { + return writeContextJSON(a, threadRecord) + } + fmt.Fprintf(a.out, "thread: %s\ntitle: %s\nversion: %d\ncapability_grant: false\n", + threadRecord.ID, threadRecord.Title, threadRecord.Version) + return nil +} + +func (a *App) threadPin(ctx context.Context, args []string) error { + fs := newFlagSet("thread pin", a.errOut) + pinnedValue := fs.String("pinned", "true", "pin state: true|false") + jsonOutput := fs.Bool("json", false, "emit JSON") + if err := fs.Parse(reorderFlags(args, map[string]bool{ + "pinned": true, "json": false, + })); err != nil { + return err + } + if fs.NArg() != 1 { + return errors.New("usage: cyberagent thread pin <thread-id> [--pinned true|false] [--json]") + } + pinned := strings.TrimSpace(*pinnedValue) + if pinned != "true" && pinned != "false" { + return fmt.Errorf("invalid pinned state %q: use true or false", *pinnedValue) + } + threadRecord, err := a.store.PinThread(ctx, fs.Arg(0), pinned == "true") + if err != nil { + return err + } + if *jsonOutput { + return writeContextJSON(a, threadRecord) + } + fmt.Fprintf(a.out, "thread: %s\npinned: %t\nversion: %d\ncapability_grant: false\n", + threadRecord.ID, threadRecord.Pinned, threadRecord.Version) + return nil +} + func (a *App) threadLifecycle(ctx context.Context, service *application.ThreadService, action string, args []string, ) error { diff --git a/internal/app/thread_command_test.go b/internal/app/thread_command_test.go index 46afa037a..73fa305db 100644 --- a/internal/app/thread_command_test.go +++ b/internal/app/thread_command_test.go @@ -98,3 +98,97 @@ func TestThreadCLIUsesStableIdentityAcrossRunSuccessionAndLifecycle(t *testing.T t.Fatalf("lossless CLI export=%#v", bundle) } } + +func TestThreadCLIRenameAndTitleSearch(t *testing.T) { + t.Setenv("CYBERAGENT_HOME", t.TempDir()) + created, stderr, code := executeTestCommand(t, "thread", "create", + "refactor parser module", "--profile", "review", "--max-turns", "4") + if code != 0 || stderr != "" { + t.Fatalf("create stdout=%s stderr=%s code=%d", created, stderr, code) + } + threadID := threadIDPattern.FindString(created) + if threadID == "" { + t.Fatalf("thread identity missing from %s", created) + } + renamed, stderr, code := executeTestCommand(t, "thread", "rename", threadID, + "Parser refactoring plan") + if code != 0 || stderr != "" || !strings.Contains(renamed, "title: Parser refactoring plan") { + t.Fatalf("rename stdout=%s stderr=%s code=%d", renamed, stderr, code) + } + shown, stderr, code := executeTestCommand(t, "thread", "show", threadID, "--json") + if code != 0 || stderr != "" || !strings.Contains(shown, "Parser refactoring plan") { + t.Fatalf("show stdout=%s stderr=%s code=%d", shown, stderr, code) + } + matched, stderr, code := executeTestCommand(t, "thread", "list", "--title-query", + "parser REFACTORING") + if code != 0 || stderr != "" || !strings.Contains(matched, threadID) { + t.Fatalf("title search stdout=%s stderr=%s code=%d", matched, stderr, code) + } + missed, stderr, code := executeTestCommand(t, "thread", "list", "--title-query", + "no such title anywhere") + if code != 0 || stderr != "" || !strings.Contains(missed, "no threads") { + t.Fatalf("empty title search stdout=%s stderr=%s code=%d", missed, stderr, code) + } + if _, stderr, code = executeTestCommand(t, "thread", "rename", threadID); code == 0 { + t.Fatalf("rename without a title must fail: stderr=%s", stderr) + } + if _, stderr, code = executeTestCommand(t, "thread", "rename", threadID, "bad\ntitle"); code == 0 { + t.Fatalf("rename with a control character must fail: stderr=%s", stderr) + } + if _, stderr, code = executeTestCommand(t, "thread", "rename", + "thread-run-000000000000-ffffffffffff", "unrelated"); code == 0 { + t.Fatalf("rename of a missing thread must fail: stderr=%s", stderr) + } +} + +func TestThreadCLIPinAndPinnedFilter(t *testing.T) { + t.Setenv("CYBERAGENT_HOME", t.TempDir()) + first, stderr, code := executeTestCommand(t, "thread", "create", + "pinned CLI task", "--profile", "review", "--max-turns", "4") + if code != 0 || stderr != "" { + t.Fatalf("create first stdout=%s stderr=%s code=%d", first, stderr, code) + } + second, stderr, code := executeTestCommand(t, "thread", "create", + "background CLI task", "--profile", "review", "--max-turns", "4") + if code != 0 || stderr != "" { + t.Fatalf("create second stdout=%s stderr=%s code=%d", second, stderr, code) + } + pinnedID := threadIDPattern.FindString(first) + unpinnedID := threadIDPattern.FindString(second) + pinned, stderr, code := executeTestCommand(t, "thread", "pin", pinnedID) + if code != 0 || stderr != "" || !strings.Contains(pinned, "pinned: true") { + t.Fatalf("pin stdout=%s stderr=%s code=%d", pinned, stderr, code) + } + pinnedList, stderr, code := executeTestCommand(t, "thread", "list", "--pinned", "true", + "--json") + if code != 0 || stderr != "" { + t.Fatalf("pinned list stdout=%s stderr=%s code=%d", pinnedList, stderr, code) + } + var threads []domain.Thread + if err := json.Unmarshal([]byte(pinnedList), &threads); err != nil { + t.Fatal(err) + } + if len(threads) != 1 || threads[0].ID != pinnedID || !threads[0].Pinned { + t.Fatalf("pinned filter returned %+v", threads) + } + unpinnedList, stderr, code := executeTestCommand(t, "thread", "list", "--pinned", "false", + "--json") + if code != 0 || stderr != "" { + t.Fatalf("unpinned list stdout=%s stderr=%s code=%d", unpinnedList, stderr, code) + } + threads = nil + if err := json.Unmarshal([]byte(unpinnedList), &threads); err != nil { + t.Fatal(err) + } + if len(threads) != 1 || threads[0].ID != unpinnedID || threads[0].Pinned { + t.Fatalf("unpinned filter returned %+v", threads) + } + unpinned, stderr, code := executeTestCommand(t, "thread", "pin", pinnedID, + "--pinned", "false") + if code != 0 || stderr != "" || !strings.Contains(unpinned, "pinned: false") { + t.Fatalf("unpin stdout=%s stderr=%s code=%d", unpinned, stderr, code) + } + if _, stderr, code = executeTestCommand(t, "thread", "list", "--pinned", "maybe"); code == 0 { + t.Fatalf("invalid pinned filter must fail: stderr=%s", stderr) + } +} From 22b7f470c6d6eb85a948c9630684cd231ee84f6c Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Fri, 30 Oct 2026 01:12:42 +0800 Subject: [PATCH 2/2] docs(reviews): record OCR delegate review for thread CLI metadata Co-Authored-By: Claude <noreply@anthropic.com> --- docs/reviews/thread-cli-metadata/README.md | 53 +++++++++++++++++++ .../reviews/thread-cli-metadata/coverage.json | 30 +++++++++++ 2 files changed, 83 insertions(+) create mode 100644 docs/reviews/thread-cli-metadata/README.md create mode 100644 docs/reviews/thread-cli-metadata/coverage.json diff --git a/docs/reviews/thread-cli-metadata/README.md b/docs/reviews/thread-cli-metadata/README.md new file mode 100644 index 000000000..c2146108e --- /dev/null +++ b/docs/reviews/thread-cli-metadata/README.md @@ -0,0 +1,53 @@ +# CLI 线程元数据与搜索:OCR delegate 复审 + +日期:2026-10-30。基线:`b58f8981db87dd983e221839283e43f145b92b03`(origin/main)。 +主题:把桌面端已有的会话重命名、置顶与标题搜索补齐到 CLI(`thread rename`、 +`thread pin`、`thread list --title-query/--pinned`),与 HTTP 面 +(`internal/httpapi/thread_metadata.go` 的 PUT /threads/{id}/name|pin)走完全相同的 +store 语义(无乐观版本、无 operation-key、无审计事件),供 headless/NDJSON 工作流使用。 + +OCR 负责文件选择(`ocr delegate preview --from origin/main --to HEAD`:2 个可审生产文件) +与规则解析(`ocr delegate rule`:Rule Group 1 system Go), +主 agent 与独立评审子代理实际阅读判断。评审子代理全文核验 2 个生产文件、 +1 个人工补入的测试文件与全量 diff,并交叉核对了 store/HTTP/domain 三层的既有语义。 + +## 覆盖范围 + +共修改 4 个文件;评审记录(本 README 与 coverage.json)不计入审查范围,纳入审查 4 个: +- 生产代码(2 个,OCR 检出):`internal/app/thread_command.go`、`internal/app/app.go`。 +- 测试(1 个,人工补入):`internal/app/thread_command_test.go`。 +- 文档(1 个,OCR 标记 unsupported_ext,主 agent 全文核对):`docs/usage.md`。 + +具体明细见 [coverage.json](coverage.json)。 + +```text +ocr delegate preview --from b58f8981db87dd983e221839283e43f145b92b03 --to HEAD +ocr delegate rule internal/app/thread_command.go +``` + +## 发现与修复(评审子代理报告,全部已修复并复测) + +| 发现 | 具体问题 | 最终处理 | +| --- | --- | --- | +| F1,P3 | 新小节称 `--title-query` 为 "case-insensitive",但 SQLite `lower()` 只折叠 ASCII;与 openapi 及 domain 既有措辞("ASCII case-insensitive, other Unicode exact")相比表述过强,可能误导非 ASCII 标题的无人值守调用方。 | 中英文均改为 "ASCII 大小写不敏感(其余 Unicode 字符按精确比较)"。 | +| F2,P3 | "version 单调递增" 与 "可安全重试" 并列过强:同值 no-op 时 `updateThreadMetadata` 的 `field<>?` 守卫不递增 version。 | 改为 "version 单调不减、每次实际变更都会递增,因此同样的重试是安全的"。 | + +评审确认的无缺陷事实(节选):rename/pin 与 HTTP 面走同一 store 路径,无权限授予; +不存在/已删除线程统一映射为标准 not-found,错误经 `apperror.Normalize` 无内部细节泄露; +`--pinned=false` 与 `--pinned false` 两种写法均可达 store(Go `flag.Bool` 不消费下一参数, +故两处 `--pinned` 均为 String 旗标);空/纯空白 `--title-query` 不加过滤;行输出新增 +`pinned=` 前全仓无既有消费方断言旧格式,机器消费方走 `--json`(`Pinned` omitempty 反序列化 +正确);两个新测试各自以 `t.TempDir()` 隔离 `CYBERAGENT_HOME`、断言覆盖成功路径、传播、 +大小写命中/未命中、拒绝路径与双向过滤。 + +## 验证 + +- `gofmt -l internal/app/` 无输出;`go vet ./internal/app/` 无告警。 +- `go test ./internal/app/ -run "TestThreadCLI|TestPresentationVocabulary"` 通过; + 完整 `go test ./internal/app/` 中与本 PR 无关的两个失败 + (`TestRunCommandExecuteIsConfirmedAuditedAndExactlyOnce` 需要本机不可用的 approved + executable;词表测试需要稀疏检出补齐的 `docs/openapi.json`)均在干净 main 上同样复现/ + 与改动无关。 +- 端到端冒烟(真实构建的 `cmd/cyberagent`):rename 多词标题(含不带引号的裸多词)、 + `--pinned=false` 与 `--pinned false`、`--pinned maybe` 明确报错、纯空白 + `--title-query` 不过滤、大小写混合查询命中、`--pinned false` 过滤出未置顶集合。 diff --git a/docs/reviews/thread-cli-metadata/coverage.json b/docs/reviews/thread-cli-metadata/coverage.json new file mode 100644 index 000000000..555b48019 --- /dev/null +++ b/docs/reviews/thread-cli-metadata/coverage.json @@ -0,0 +1,30 @@ +{ + "schema_version": "1", + "mode": "ocr_delegate", + "base": "b58f8981db87dd983e221839283e43f145b92b03", + "reviewed_head": "HEAD", + "review_metadata_excluded": [ + "docs/reviews/thread-cli-metadata/README.md", + "docs/reviews/thread-cli-metadata/coverage.json" + ], + "reviewers": [ + "主 agent:CLI 子命令实现、测试、文档与端到端冒烟", + "review_delegate:OCR delegate 规则检查(Rule Group 1 system Go)与独立全文复审,含 store/HTTP/domain 三层语义交叉核对" + ], + "summary": { + "total_git_changed_files": 6, + "review_scope_files": 4, + "ocr_selected_production": 2, + "manually_added_tests": 1, + "docs_verified_by_primary": 1, + "review_metadata_excluded_count": 2, + "unread_in_scope": 0, + "coverage_rate": 1.0 + }, + "files": [ + { "path": "internal/app/thread_command.go", "coverage": "full", "reviewer": "primary_and_delegate" }, + { "path": "internal/app/app.go", "coverage": "full", "reviewer": "primary_and_delegate" }, + { "path": "internal/app/thread_command_test.go", "coverage": "full", "reviewer": "primary_and_delegate" }, + { "path": "docs/usage.md", "coverage": "full", "reviewer": "primary" } + ] +}