From 12deeab31689bb9b8ac93b2b166e830a25423584 Mon Sep 17 00:00:00 2001 From: Ivan Kuznetsov Date: Fri, 11 Sep 2026 00:34:39 +0100 Subject: [PATCH] Resolve thread-list guidance conflicts and strengthen smoke coverage --- skills/hey/SKILL.md | 6 ++++-- tests/smoke/topic_views_test.go | 18 +++++++++++++++++- 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/skills/hey/SKILL.md b/skills/hey/SKILL.md index fca1c3f3..6c9c0010 100644 --- a/skills/hey/SKILL.md +++ b/skills/hey/SKILL.md @@ -348,7 +348,7 @@ hey box view imbox --page next-cursor --json # Continue from an earlier listing Box names: `imbox`, `feedbox`, `trailbox`, `asidebox`, `laterbox`, `bubblebox` -**Response format:** `hey box view --json` returns the box itself — `id`, `kind`, `name`, `app_url`, `next_history_url`, `next_page` — with a `postings` array of the email threads in it. Each posting has: `id` (box item ID), `topic_id` (thread ID), `name` (subject), `seen` (read status), `created_at`, `contacts`, `summary`, `app_url`, `visible_entry_count`. Use `id` for `hey seen`, `hey unseen`, `hey move`, `hey label add`, `hey label remove`, `hey trash`, `hey spam`, `hey ignore`, and `hey stop-ignoring`, and `topic_id` for `hey thread read`, `hey reply`, `hey forward`, `hey share` and `hey attachment list`. A box item `id` passed to `hey thread read` answers `not_found`, and so does a `topic_id` passed to `hey move`. +**Response format:** `hey box view --json` returns the box itself — `id`, `kind`, `name`, `app_url`, `next_history_url`, `next_page` — with a `postings` array of the email threads in it. Each posting has: `id` (box item ID), `topic_id` (thread ID), `name` (subject), `seen` (read status), `created_at`, `contacts`, `summary`, `app_url`, `visible_entry_count`. Use `id` for `hey seen`, `hey unseen`, `hey move`, `hey label add`, `hey label remove`, `hey trash`, `hey spam`, `hey ignore`, and `hey stop-ignoring`, and `topic_id` for `hey thread read`, `hey reply`, `hey forward`, `hey share` and `hey attachment list`. A box item `id` passed to `hey thread read` answers `not_found`. The reverse is not caught yet: `hey seen`, `hey unseen` and `hey move` silently ignore any id that is not one of your box items — a `topic_id`, a typo — and still answer success counting every id given, because HEY's endpoints do not report a non-match. Every id in the same call that *is* one of your box items is changed, so a mixed batch is a partial success reported as a whole one, and a `topic_id` that happens to equal one of your other box item ids marks or moves that unrelated thread. Confirm rather than trust the envelope: `hey box view --json --all --jq '{notice, next_page: .data.next_page, match: [.data.postings[] | select(.id == ) | {id, topic_id, seen}]}'` for a mark, or the destination box for a move. `--all` reads every page up to the command's cap of 101; an empty `match` with `next_page` still set means the box is larger than that, so continue with `--page ` rather than calling it a non-match, and `topic_id` in the match says which thread an id actually named. `hey trash`, `hey spam` and the label commands do answer `not_found`. A posting that bundles a contact's mail into one row can **omit `topic_id`**: a bundle names its sender rather than a thread, and its `name` joins the bundled subjects with `•`. A bundle that does carry a `topic_id` opens as that thread — its one unseen thread — and `hey threads` reads it as usual. For a bundle without one, never substitute the box item `id` (`hey threads ` answers `not_found`); there is no command that lists the threads inside a bundle, so run `hey contacts unbundle ` — the contact is in the posting's `contacts` — to list that sender's mail as separate rows, or direct the user to open the bundle in HEY. @@ -464,7 +464,9 @@ on an entry; use `hey reply`, which works the addressing out itself. `hey share` returns a URL that shows the entire thread and future emails or replies sent to it. Anyone with the link can open it. `hey unshare` turns off the sharing link. -**ID note:** A thread listed through a box, label, collection, or search normally has two IDs: an `id` (its box item ID) and a `topic_id` (its thread ID). `hey seen`, `hey unseen`, `hey move`, `hey label add`, `hey label remove`, `hey trash`, `hey spam`, `hey ignore`, and `hey stop-ignoring` expect the box item `id`. `hey thread read`, `hey share`, `hey unshare`, `hey attachment list`, `hey reply`, `hey forward`, `hey collection add`, and `hey collection remove` expect `topic_id`. A `hey thread list` row comes directly from a topic, so its `id` is also a thread ID and `topic_id` repeats it explicitly. Passing a box item ID where a thread ID is expected, or the reverse, answers `not_found`, not a redirect. +**ID note:** Every email thread has two IDs: an `id` (its box item ID) and a `topic_id` (its thread ID). `hey seen`, `hey unseen`, `hey move`, `hey label add`, `hey label remove`, `hey trash`, `hey spam`, `hey ignore`, and `hey stop-ignoring` expect `id`. `hey thread read`, `hey share`, `hey unshare`, `hey attachment list`, `hey reply`, `hey forward`, `hey collection add`, and `hey collection remove` expect `topic_id`. Passing the wrong one answers `not_found`, not a redirect — except `hey seen`, `hey unseen` and `hey move`, which ignore an unmatched id, act on every id that does match, and answer success either way (confirm with `hey box view --json --all`, as in the note above). + +A `hey thread list` row comes directly from a topic, so its `id` is also a thread ID and `topic_id` repeats it explicitly. Neither field is a box item ID for organization commands. `hey thread list --json`, `hey box view --json`, `hey label view --json`, `hey collection view --json` and `hey search --json` all carry `topic_id`; a bundle posting can omit it (see the Boxes section). diff --git a/tests/smoke/topic_views_test.go b/tests/smoke/topic_views_test.go index 18e5e257..1ed18ab1 100644 --- a/tests/smoke/topic_views_test.go +++ b/tests/smoke/topic_views_test.go @@ -33,7 +33,7 @@ func TestThreadListPage(t *testing.T) { first := heyJSON(t, "thread", "list", "--in", "sent") nextPage, _ := first.Meta["next_page"].(string) if nextPage == "" { - t.Skip("Sent has no next page in this fixture") + skipf(t, "Sent has no next page in this fixture") } response := heyJSON(t, "thread", "list", "--in", "sent", "--page", nextPage) @@ -43,4 +43,20 @@ func TestThreadListPage(t *testing.T) { if string(response.Data) == "null" { t.Fatal("expected an array, got null") } + firstIDs := make(map[int64]bool) + for _, topic := range dataAs[[]threadListItem](t, first) { + firstIDs[topic.TopicID] = true + } + nextTopics := dataAs[[]threadListItem](t, response) + if len(nextTopics) == 0 { + t.Fatal("next page returned no threads") + } + for _, topic := range nextTopics { + if topic.ID <= 0 || topic.TopicID != topic.ID { + t.Errorf("expected matching positive id and topic_id, got %#v", topic) + } + if firstIDs[topic.TopicID] { + t.Errorf("next page repeated thread %d from the first page", topic.TopicID) + } + } }