Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 53 additions & 0 deletions docs/reviews/thread-cli-metadata/README.md
Original file line number Diff line number Diff line change
@@ -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` 过滤出未置顶集合。
30 changes: 30 additions & 0 deletions docs/reviews/thread-cli-metadata/coverage.json
Original file line number Diff line number Diff line change
@@ -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" }
]
}
29 changes: 29 additions & 0 deletions docs/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

任务运行中,发送的补充消息会排到输入框上方,当前执行结束后依次处理。文字消息可点击
Expand Down
2 changes: 1 addition & 1 deletion internal/app/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
81 changes: 75 additions & 6 deletions internal/app/thread_command.go
Original file line number Diff line number Diff line change
Expand Up @@ -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":
Expand Down Expand Up @@ -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
}
Expand All @@ -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
}
Expand Down Expand Up @@ -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 {
Expand Down
94 changes: 94 additions & 0 deletions internal/app/thread_command_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
Loading