From 200a3be0eb2801dbb43369aa8b0b1ed02b86348b Mon Sep 17 00:00:00 2001 From: jnohclee-rgb <336857373+jnohclee-rgb@users.noreply.github.com> Date: Sat, 31 Oct 2026 15:50:50 +0800 Subject: [PATCH 1/3] slack: reply echoes name the thread's root author --- backlot/routers/slack.py | 13 ++++++++- tests/test_slack.py | 62 +++++++++++++++++++++++++++++++++++++++- 2 files changed, 73 insertions(+), 2 deletions(-) diff --git a/backlot/routers/slack.py b/backlot/routers/slack.py index 9e1a093d6..8b8e74df6 100644 --- a/backlot/routers/slack.py +++ b/backlot/routers/slack.py @@ -2178,6 +2178,7 @@ async def chat_post_message(request: Request): parent = ( store.document_by_key(conn, "slack", (name, wanted), ids) if wanted is not None else None ) + parent_uid = None if parent is not None: # Slack threads a reply under the ROOT, not under whichever message was named: replying to # a reply puts it in the same thread. The corpus stores that as the root's own ts, which is @@ -2190,6 +2191,16 @@ async def chat_post_message(request: Request): # `conversations.replies` answers the root alone and the message reads back with no # thread at all — a thread this API made and cannot show. store.patch_document(conn, "slack", (name, parent["ts"]), "thread_ts", thread_ts) + root_author = parent["author_email"] + elif parent["thread_ts"] == parent["ts"]: + root_author = parent["author_email"] + else: + root = store.document_by_key(conn, "slack", (name, thread_ts), ids) + root_author = root["author_email"] if root else None + # `chat.postMessage` answers a reply with `parent_user_id`: measured against slack.com on + # 2026-10-30 (issue 1162), each answer carries `parent_user_id` as the thread's root author, + # whether replying to the root or to a reply in the thread, matching conversations.replies. + parent_uid = _uid(root_author) if root_author else None now = int(time.time()) ts = store.slack_next_ts(conn, name, now) store.insert_document( @@ -2211,7 +2222,7 @@ async def chat_post_message(request: Request): "ok": True, "channel": synth.slack_channel_id(name), "ts": ts, - "message": _message(row), + "message": _message(row, parent_user_id=parent_uid), } diff --git a/tests/test_slack.py b/tests/test_slack.py index f5b95ae01..94978b9e0 100644 --- a/tests/test_slack.py +++ b/tests/test_slack.py @@ -5830,7 +5830,7 @@ def test_editing_a_corpus_message_leaves_one_search_hit(wclient, tokens, ro_conn assert stale["total"] == len(stale["matches"]) -def test_posting_with_thread_ts_replies_in_that_thread(wclient, tokens, ro_conn): +def test_posting_with_thread_ts_replies_in_that_thread(wclient, tokens, admin_h, ro_conn): # Ignoring `thread_ts` gave an agent told to reply in a thread a top-level message and a # `200 ok` — a wrong answer with no error in it. root_ts = ro_conn.execute( @@ -5875,6 +5875,63 @@ def test_posting_with_thread_ts_replies_in_that_thread(wclient, tokens, ro_conn) assert [m["ts"] for m in thread] == [alone, answered] assert thread[0]["thread_ts"] == alone and thread[0]["reply_count"] == 1 + # `chat_post_message` records the measurement: chat.postMessage answers a reply with + # `parent_user_id` naming the thread's root author, whether replying to the root or to a reply + # in the thread, under a bot token or a user token, and conversations.replies lists it with + # the same parent_user_id. A top-level post carries no parent_user_id. + def post(headers, text, **extra): + return wclient.post( + "/slack/api/chat.postMessage", + headers=headers, + data={"channel": cid, "text": text, **extra}, + ).json() + + cases = [ + (root_writer, reply_to_reply, broadcast) + for root_writer in ("admin", "ava@acme.com") + for reply_to_reply in (False, True) + for broadcast in (None, "", "false", "FALSE", "0") + ] + observed, expected = [], [] + for root_writer, reply_to_reply, broadcast in cases: + root_h = admin_h if root_writer == "admin" else _uh(tokens, root_writer) + reply_h = _uh(tokens, "ava@acme.com") if root_writer == "admin" else admin_h + root = post(root_h, f"root by {root_writer}") + target = root["ts"] + if reply_to_reply: + target = post(reply_h, "intermediate reply", thread_ts=target)["ts"] + flags = {} if broadcast is None else {"reply_broadcast": broadcast} + posted_reply = post(reply_h, "plain reply", thread_ts=target, **flags) + listed = wclient.get( + "/slack/api/conversations.replies", + headers=reply_h, + params={"channel": cid, "ts": root["ts"]}, + ).json()["messages"] + listed_reply = next(m for m in listed if m["ts"] == posted_reply["ts"]) + observed.append( + ( + root["ok"], + "parent_user_id" in root["message"], + posted_reply["ok"], + posted_reply["message"]["user"] != root["message"]["user"], + posted_reply["message"]["thread_ts"] == root["ts"], + posted_reply["message"].get("parent_user_id"), + listed_reply.get("parent_user_id"), + ) + ) + expected.append( + ( + True, + False, + True, + True, + True, + root["message"]["user"], + root["message"]["user"], + ) + ) + assert observed == expected + def test_replying_to_a_reply_lands_in_the_same_thread(wclient, tokens, ro_conn): # Slack threads under the ROOT, not under whichever message was named. @@ -5894,6 +5951,8 @@ def test_replying_to_a_reply_lands_in_the_same_thread(wclient, tokens, ro_conn): "/slack/api/conversations.replies", headers=h, data={"channel": cid, "ts": root_ts} ).json()["messages"] assert posted["ts"] in [m["ts"] for m in thread] + root_user = next(m["user"] for m in thread if m["ts"] == root_ts) + assert posted["message"]["parent_user_id"] == root_user def test_a_thread_ts_naming_nothing_posts_top_level_and_an_unreadable_one_is_refused( @@ -5911,6 +5970,7 @@ def test_a_thread_ts_naming_nothing_posts_top_level_and_an_unreadable_one_is_ref ).json() assert j["ok"] is True, value assert "thread_ts" not in j["message"], value + assert "parent_user_id" not in j["message"], value # Refused outside the window `_thread_ts_error` records, and the message carries the value # as real reads it, in microseconds. import time as _time From 97fc2e35c9d15e96dfd10742c48386d8901b84b9 Mon Sep 17 00:00:00 2001 From: nuriyoo <183389123+nuriyoo@users.noreply.github.com> Date: Mon, 2 Nov 2026 02:35:27 +0900 Subject: [PATCH 2/3] slack: a reply under a deleted root carries USLACKBOT as its parent_user_id `_parent_uid` beside `_uid` answers the `user` a thread's root is served with, which is USLACKBOT once the root is deleted with a reply still under it, and both `chat.postMessage` and `conversations.replies` read it. `chat.postMessage` looks the root up once, by `thread_ts`. The reply test table holds one axis per row plus two deleted-root rows. The return line of `chat_post_message` and one assertion in `test_a_thread_ts_naming_nothing_posts_top_level_and_an_unreadable_one_is_refused` stay as the merge base has them, so `main` merges in without a conflict; the commit after that merge writes both over `main`'s versions. --- backlot/routers/slack.py | 30 ++++++++------ tests/test_slack.py | 85 ++++++++++++++++------------------------ 2 files changed, 51 insertions(+), 64 deletions(-) diff --git a/backlot/routers/slack.py b/backlot/routers/slack.py index 8b8e74df6..fd9d825c4 100644 --- a/backlot/routers/slack.py +++ b/backlot/routers/slack.py @@ -1216,6 +1216,19 @@ def _uid(email: str) -> str: return SERVICE_USER_ID if email == SERVICE_EMAIL else synth.slack_user_id(email) +def _parent_uid(root) -> str: + """The `parent_user_id` a reply in ``root``'s thread carries: the `user` the root is served + with, which is ``USLACKBOT`` once the root is deleted with a reply still under it. + + Measured against slack.com on 2026-10-30 and 2026-10-31, over a user and a bot token: the + `chat.postMessage` answer to a reply carries the root's poster whether `thread_ts` names the + root or a reply under it, and `conversations.replies` lists each reply with the same id. With + the root deleted, a reply posted to the placeholder or to a reply under it, and each reply + `conversations.replies` listed, carried ``USLACKBOT`` instead. + """ + return SLACKBOT_ID if root["subtype"] == "tombstone" else _uid(root["author_email"]) + + def _handle(email: str) -> str: """A corpus email as Slack's handle — the `name` a user object carries and the `user` `auth.test` reports for the same person.""" @@ -1637,7 +1650,7 @@ async def conversations_replies(request: Request): latest = store.slack_latest_reply_ts(conn, name, root["ts"], ids) if rc else None ru = store.slack_reply_authors(conn, name, root["ts"], ids) ruids = [_uid(e) for e in ru[:5]] - parent_uid = _uid(root["author_email"]) + parent_uid = _parent_uid(root) messages = [ _message( x, @@ -2191,16 +2204,9 @@ async def chat_post_message(request: Request): # `conversations.replies` answers the root alone and the message reads back with no # thread at all — a thread this API made and cannot show. store.patch_document(conn, "slack", (name, parent["ts"]), "thread_ts", thread_ts) - root_author = parent["author_email"] - elif parent["thread_ts"] == parent["ts"]: - root_author = parent["author_email"] - else: - root = store.document_by_key(conn, "slack", (name, thread_ts), ids) - root_author = root["author_email"] if root else None - # `chat.postMessage` answers a reply with `parent_user_id`: measured against slack.com on - # 2026-10-30 (issue 1162), each answer carries `parent_user_id` as the thread's root author, - # whether replying to the root or to a reply in the thread, matching conversations.replies. - parent_uid = _uid(root_author) if root_author else None + root = store.document_by_key(conn, "slack", (name, thread_ts), ids) + if root is not None: + parent_uid = _parent_uid(root) now = int(time.time()) ts = store.slack_next_ts(conn, name, now) store.insert_document( @@ -2222,7 +2228,7 @@ async def chat_post_message(request: Request): "ok": True, "channel": synth.slack_channel_id(name), "ts": ts, - "message": _message(row, parent_user_id=parent_uid), + "message": _message(row), } diff --git a/tests/test_slack.py b/tests/test_slack.py index 94978b9e0..2f9a033a4 100644 --- a/tests/test_slack.py +++ b/tests/test_slack.py @@ -5875,62 +5875,44 @@ def test_posting_with_thread_ts_replies_in_that_thread(wclient, tokens, admin_h, assert [m["ts"] for m in thread] == [alone, answered] assert thread[0]["thread_ts"] == alone and thread[0]["reply_count"] == 1 - # `chat_post_message` records the measurement: chat.postMessage answers a reply with - # `parent_user_id` naming the thread's root author, whether replying to the root or to a reply - # in the thread, under a bot token or a user token, and conversations.replies lists it with - # the same parent_user_id. A top-level post carries no parent_user_id. + # `_parent_uid`'s rule, in the answer to the post and on each reply `conversations.replies` + # lists. The root's poster and the replier differ in each row. def post(headers, text, **extra): - return wclient.post( - "/slack/api/chat.postMessage", - headers=headers, - data={"channel": cid, "text": text, **extra}, - ).json() + data = {"channel": cid, "text": text, **extra} + return wclient.post("/slack/api/chat.postMessage", headers=headers, data=data).json() - cases = [ - (root_writer, reply_to_reply, broadcast) - for root_writer in ("admin", "ava@acme.com") - for reply_to_reply in (False, True) - for broadcast in (None, "", "false", "FALSE", "0") + by = {"admin": admin_h, "ava": h} + ava = synth.slack_user_id("ava@acme.com") + rows = [ + # root's poster, replier, what `thread_ts` names, `reply_broadcast`, root deleted first + (("admin", "ava", "root", None, False), "USERVICE0"), + (("ava", "admin", "root", None, False), ava), + (("admin", "ava", "reply", None, False), "USERVICE0"), + (("ava", "admin", "reply", None, False), ava), + (("admin", "ava", "root", "", False), "USERVICE0"), + (("admin", "ava", "root", "false", False), "USERVICE0"), + (("admin", "ava", "root", "FALSE", False), "USERVICE0"), + (("admin", "ava", "root", "0", False), "USERVICE0"), + (("admin", "ava", "root", None, True), slack.SLACKBOT_ID), + (("ava", "admin", "reply", None, True), slack.SLACKBOT_ID), ] - observed, expected = [], [] - for root_writer, reply_to_reply, broadcast in cases: - root_h = admin_h if root_writer == "admin" else _uh(tokens, root_writer) - reply_h = _uh(tokens, "ava@acme.com") if root_writer == "admin" else admin_h - root = post(root_h, f"root by {root_writer}") - target = root["ts"] - if reply_to_reply: - target = post(reply_h, "intermediate reply", thread_ts=target)["ts"] + got = [] + for (root_by, reply_by, names, broadcast, deleted), _ in rows: + root = post(by[root_by], "root")["ts"] + target = root + if names == "reply" or deleted: + first = post(by[reply_by], "first reply", thread_ts=root)["ts"] + target = first if names == "reply" else root + if deleted: + gone = {"channel": cid, "ts": root} + wclient.post("/slack/api/chat.delete", headers=by[root_by], data=gone) flags = {} if broadcast is None else {"reply_broadcast": broadcast} - posted_reply = post(reply_h, "plain reply", thread_ts=target, **flags) - listed = wclient.get( - "/slack/api/conversations.replies", - headers=reply_h, - params={"channel": cid, "ts": root["ts"]}, + echo = post(by[reply_by], "reply", thread_ts=target, **flags)["message"] + listed = wclient.post( + "/slack/api/conversations.replies", headers=h, data={"channel": cid, "ts": root} ).json()["messages"] - listed_reply = next(m for m in listed if m["ts"] == posted_reply["ts"]) - observed.append( - ( - root["ok"], - "parent_user_id" in root["message"], - posted_reply["ok"], - posted_reply["message"]["user"] != root["message"]["user"], - posted_reply["message"]["thread_ts"] == root["ts"], - posted_reply["message"].get("parent_user_id"), - listed_reply.get("parent_user_id"), - ) - ) - expected.append( - ( - True, - False, - True, - True, - True, - root["message"]["user"], - root["message"]["user"], - ) - ) - assert observed == expected + got.append((echo.get("parent_user_id"), {m.get("parent_user_id") for m in listed[1:]})) + assert got == [(uid, {uid}) for _, uid in rows] def test_replying_to_a_reply_lands_in_the_same_thread(wclient, tokens, ro_conn): @@ -5970,7 +5952,6 @@ def test_a_thread_ts_naming_nothing_posts_top_level_and_an_unreadable_one_is_ref ).json() assert j["ok"] is True, value assert "thread_ts" not in j["message"], value - assert "parent_user_id" not in j["message"], value # Refused outside the window `_thread_ts_error` records, and the message carries the value # as real reads it, in microseconds. import time as _time From 285d7985a1677d7dc0c519f04796a85a1df15fa1 Mon Sep 17 00:00:00 2001 From: nuriyoo <183389123+nuriyoo@users.noreply.github.com> Date: Mon, 2 Nov 2026 02:36:14 +0900 Subject: [PATCH 3/3] slack: the chat.postMessage echo of a reply carries parent_user_id The return line wraps the message in `_with_root` as `main` does and builds it with the root's `parent_user_id`; the top-level loop of `test_a_thread_ts_naming_nothing_posts_top_level_and_an_unreadable_one_is_refused` checks that a post outside a thread carries none. --- backlot/routers/slack.py | 4 +++- tests/test_slack.py | 1 + 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/backlot/routers/slack.py b/backlot/routers/slack.py index 785efb8e6..7d7504db6 100644 --- a/backlot/routers/slack.py +++ b/backlot/routers/slack.py @@ -2240,7 +2240,9 @@ async def chat_post_message(request: Request): "ok": True, "channel": synth.slack_channel_id(name), "ts": ts, - "message": _with_root(conn, name, ids, caller, row, _message(row)), + "message": _with_root( + conn, name, ids, caller, row, _message(row, parent_user_id=parent_uid) + ), } diff --git a/tests/test_slack.py b/tests/test_slack.py index c699a890b..db7d89210 100644 --- a/tests/test_slack.py +++ b/tests/test_slack.py @@ -6047,6 +6047,7 @@ def test_a_thread_ts_naming_nothing_posts_top_level_and_an_unreadable_one_is_ref ).json() assert j["ok"] is True, value assert "thread_ts" not in j["message"] and "root" not in j["message"], value + assert "parent_user_id" not in j["message"], value assert (j["message"].get("subtype"), "team" in j["message"]) == ( subtype, subtype is None,