fix(slack): answer in a thread, and make the manifest readable - #121

Merged
khaliqgant merged 6 commits into
mainfrom
fix/askable-gtm-slack-reply-ux
Sep 1, 2026
Merged

fix(slack): answer in a thread, and make the manifest readable#121
khaliqgant merged 6 commits into
mainfrom
fix/askable-gtm-slack-reply-ux

Conversation

@khaliqgant

@khaliqgantkhaliqgant commented Sep 1, 2026

Copy link
Copy Markdown
Member

Both problems observed on the first live @mention after cloud#3231 deployed.

1. Replies went to the channel, not a thread

postReply only threaded when the incoming message was already in one:

constresult=msg.threadTs
? awaitslack.reply(chanId,msg.threadTs,text)
: awaitslack.post(chanId,text);// ← top-level mention dumps into the channel

That is worst exactly where agents are useful. Every agent shares one Slack identity, so a single @Agent Relay … wakes every agent watching that channel — the production dispatch for one message matched 8 deployments — and they all replied into the main channel. Now the reply threads under the incoming message, and stays in the existing thread when there is one.

2. capabilities --json was an unreadable wall

~4KB of minified JSON posted as plain text. Now pretty-printed inside a ```json fence and led by one line naming the agent and pointing at plain-language questions — nobody should have to parse JSON to learn they can just ask.

Blast radius, deliberately

The threading change is in shared/slack.ts, so it applies to every agent using that helper (hn-monitor, joke-bot, inbox-buddy, review, …). That is the intent: an agent answering a channel mention should thread. Calling it out because it is broader than the askable-gtm title suggests.

The existing test asserting a channel-level post encoded the old behaviour; it now asserts the thread, with the reasoning in a comment so it is not "fixed" back later.

Validation

  • npm run typecheck: pass
  • tests/askable-gtm.test.mjs: 46 pass, 2 new — fenced/parseable manifest, and an existing thread answered in that thread rather than a new one
  • full suite: 322 pass / 0 fail

Not fixed here

One @Agent Relay mention still wakes every agent watching the channel, because they share a Slack identity — cloud#3234 gates on the bot being mentioned, which does not disambiguate which agent is being addressed. Threading makes that liveable, not solved. Worth a follow-up.

🤖 Generated with Claude Code


Summary by cubic

Fixes four problems from the first live @mention: replies thread under the message instead of landing in the channel, capabilities --json stays parseable for machines while Slack gets a readable fenced view, cited evidence is an excerpt rather than a whole post, and answers lead with results instead of a preamble.

  • Replies thread under the incoming message, and replies to a message already in a thread stay in that thread.
  • Threading is opt-in via startThread in postReply/conversationKeyForSlack; only askable-gtm enables it because it keeps no cross-turn Slack context.
  • capabilities --json stays standalone parseable JSON over relay; the Slack actor fences and pretty-prints it via a new presentJson hook.
  • Evidence lines are excerpted to 180 characters on a word boundary, and LinkedIn rows are attributed by author handle.
  • Answers drop the question restatement and coverage preamble, keeping only a compact source-failure note and the disclosure for metered managed access.
  • Unsolicited watch deliveries get a compact header naming the query, cadence, and watch id so the reader knows which one fired and can unwatch it.
  • Tests drive both transport halves through the real handlers, so the relay/Slack split can't silently regress.
  • One @Agent Relay mention still wakes every agent watching the channel since they share a Slack identity; threading makes that liveable, not solved.

Written for commit 55f8d85. Summary will update on new commits.

Review in cubic

Two problems seen in production on the first live @mention.
Replies landed in the channel, not a thread. `postReply` only threaded when the
incoming message was ALREADY in one, so a top-level `@Agent Relay …` got a
channel-level answer. That is worst exactly where agents are useful: every
agent shares one Slack identity, so one mention draws a reply from each agent
watching the channel and they all pile into the main channel. Now the reply
threads under the incoming message, and stays in the existing thread when there
is one.
`capabilities --json` dumped ~4KB of minified JSON as plain text, which Slack
renders as an unreadable wall. Pretty-print it inside a fenced block and lead
with one line naming the agent and saying you can just ask a question in plain
language — nobody should have to parse JSON to discover that.
The threading change is in shared/slack.ts, so it applies to every agent using
that helper. That is intended: an agent answering a channel mention should
thread. The existing test asserting a channel-level `post` encoded the old
behaviour and now asserts the thread.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SQpuAYyuSG3MWSZYnw5o1x
@chatgpt-codex-connector

chatgpt-codex-connectorBot commented Sep 1, 2026

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

ReviewStatusCommitReview trigger
📝 Code ReviewCompleted2026-09-01T10:45:10.460193Z8d65ea9Manual request
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@coderabbitai

coderabbitaiBot commented Sep 1, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

Next included review available in 11 minutes.

Check out review usage here.

View limit details

Limit details: You’ve used the included review currently available.

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

Learn how review limits work.

Review configuration:

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Team

Run ID: 91a9e59a-20ff-4bc8-ab8c-a74c68244ed7

📥 Commits

Reviewing files that changed from the base of the PR and between 2cf284a and 55f8d85.

📒 Files selected for processing (3)
  • askable-gtm/agent.ts
  • shared/slack.ts
  • tests/askable-gtm.test.mjs

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Team

Run ID: d0a8e943-72fc-4d71-bfc9-327e0382122a

📥 Commits

Reviewing files that changed from the base of the PR and between 82d88be and 2cf284a.

📒 Files selected for processing (3)
  • askable-gtm/agent.ts
  • shared/slack.ts
  • tests/askable-gtm.test.mjs

Included review availability: Your plan provides up to 1 included review per hour; 0 remain after this review.


📝 Walkthrough

Walkthrough

The capabilities command now delegates formatted JSON output to an exported renderer. Slack replies now always use a thread root, including for top-level messages.

Changes

Capabilities JSON rendering

Layer / File(s)Summary
Render and validate capability manifests
askable-gtm/agent.ts, tests/askable-gtm.test.mjs
The command uses renderCapabilitiesJson. The helper emits explanatory text, fenced pretty-printed JSON, and runtime access status. Tests validate the output.

Slack threaded replies

Layer / File(s)Summary
Route replies to message threads
shared/slack.ts, tests/askable-gtm.test.mjs
postReply uses the incoming timestamp for top-level messages and preserves existing thread timestamps. Tests cover both reply paths.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Merge Risk:⚪ Minimal · up to 2cf28

Replies now stay under the relevant Slack message and capability output is easier to read without changing its content; no actionable merge-blocking risk remains after normal checks and review.

Poem

A rabbit formats JSON bright,
With fences neat and spaces right.
Slack hops into threads anew,
Keeping every reply in view.
Two tidy paths now serve the queue.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check nameStatusExplanationResolution
Docstring Coverage⚠️ WarningDocstring coverage is 40.00% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 5 functions across 3 files.Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check nameStatusExplanation
Title check✅ PassedThe title clearly summarizes both primary changes: threaded Slack replies and readable capability manifests.
Description check✅ PassedThe description directly explains the Slack threading change, manifest formatting change, scope, tests, validation, and known limitation.
Linked Issues check✅ PassedCheck skipped because no linked issues were found for this pull request.
Out of Scope Changes check✅ PassedCheck skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/askable-gtm-slack-reply-ux

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@chatgpt-codex-connectorchatgpt-codex-connectorBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit:2cf284a9cc

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment threadshared/slack.ts Outdated
Comment on lines +138 to +140
const threadTs = msg.threadTs ?? msg.ts;
const result = threadTs
? await slack.reply(chanId, threadTs, text)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve the conversation key when starting a reply thread

When Inbox Buddy handles a top-level message, conversationKeyForSlack stores that turn under the channel key, but this change posts the answer beneath msg.ts; a user's natural follow-up in that new thread then arrives with threadTs and is loaded under channel:threadTs. Consequently, the initial question and answer are absent from the follow-up prompt, breaking the multi-turn continuity implemented in inbox-buddy/agent.ts. Ensure the initial turn and its resulting thread use the same conversation key.

Useful? React with 👍 / 👎.

Comment threadaskable-gtm/agent.ts
Comment on lines +1007 to +1010
'GTM Signal Scout — machine-readable capability manifest.',
'You can also just ask a GTM question in plain language, or send'
+ ' \u201cwhat can you tell me?\u201d for the short version.',
'```json',

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Keep relay capability responses valid JSON

When an agent or catalog invokes capabilities --json over Relay, handleRelayMessage now returns these prose and fence lines around the payload, so parsing the response directly with JSON.parse fails. This affects Relay as well as Slack because both transports share handleInteractiveCommand, despite the documented contract in ASKABLE_AGENTS.md describing this command as a machine-readable relay response; apply Slack-only presentation formatting or leave the relay response as standalone JSON.

Useful? React with 👍 / 👎.

@cubic-dev-aicubic-dev-aiBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1 issue found across 3 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="shared/slack.ts">
<violation number="1" location="shared/slack.ts:138">
P3: The `postReply` JSDoc now understates the behavior: this line also threads top-level messages under their own timestamp. Update the docstring to describe both top-level and existing-thread replies.</violation>
</file>

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment threadaskable-gtm/agent.ts Outdated
Comment threadshared/slack.ts Outdated
Comment threadshared/slack.ts Outdated
// buries the conversation in the main channel, which is worst exactly where
// agents are useful — several agents share one Slack identity, so a single
// `@Agent Relay` mention can draw a reply from each of them.
const threadTs = msg.threadTs ?? msg.ts;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: The postReply JSDoc now understates the behavior: this line also threads top-level messages under their own timestamp. Update the docstring to describe both top-level and existing-thread replies.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At shared/slack.ts, line 138:
<comment>The `postReply` JSDoc now understates the behavior: this line also threads top-level messages under their own timestamp. Update the docstring to describe both top-level and existing-thread replies.</comment>
<file context>
@@ -130,10 +130,16 @@ export async function postReply(
+ // buries the conversation in the main channel, which is worst exactly where
+ // agents are useful — several agents share one Slack identity, so a single
+ // `@Agent Relay` mention can draw a reply from each of them.
+ const threadTs = msg.threadTs ?? msg.ts;
+ const result = threadTs
+ ? await slack.reply(chanId, threadTs, text)
</file context>

Both P1s from review, each flagged independently by both reviewers.
Threading was applied in shared/slack.ts for every agent, which would have
broken inbox-buddy's multi-turn context: `conversationKeyForSlack` keys a
top-level message on the CHANNEL, so answering it in a new thread stranded the
opening turn under `channel` while the follow-up loaded under `channel:ts` —
the prompt would lose the question it was answering.
Threading is now opt-in via `postReply(..., { startThread: true })`, and
`conversationKeyForSlack` takes the same option so an agent that opts in keys
continuity on the thread it created. Every existing caller passes nothing and
is byte-for-byte unchanged; only askable-gtm opts in, and it keeps no
cross-turn Slack context so moving the conversation unit costs nothing.
`capabilities --json` is documented in ASKABLE_AGENTS.md as a MACHINE-readable
relay response, and both transports share `handleInteractiveCommand` — so
fencing the payload broke `JSON.parse` for any agent or catalog calling it over
relay. The payload is standalone JSON again; presentation moved to the
transport via an optional `presentJson` hook that only the Slack actor supplies.
Tests pin both: relay receives raw parseable JSON with no fences, Slack
receives the fenced form whose payload still round-trips, and an opted-in
top-level mention threads under its own ts.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SQpuAYyuSG3MWSZYnw5o1x
@khaliqgant

Copy link
Copy Markdown
MemberAuthor

Both P1s addressed in 8d65ea9. Both were correct, and the first would have shipped a real regression.

P1 — "Preserve the conversation key when starting a reply thread" (@chatgpt-codex-connector, @cubic-dev-ai)

Correct. conversationKeyForSlack keys a top-level message on the channel, so threading it in shared/slack.ts for every agent would have stranded inbox-buddy's opening turn under channel while the follow-up loaded under channel:threadTs — the prompt would lose the question it was answering. Exactly the blast radius I flagged in the description and then didn't actually contain.

Rather than patch the key, threading is now opt-in: postReply(..., { startThread: true }), with conversationKeyForSlack taking the same option so an agent that opts in keys continuity on the thread it created. Every existing caller passes nothing and is byte-for-byte unchanged — inbox-buddy included. Only askable-gtm opts in, and it keeps no cross-turn Slack context, so moving its conversation unit to the thread costs nothing.

P1 — "Keep relay capability responses valid JSON" (@chatgpt-codex-connector, @cubic-dev-ai)

Correct, and I should have caught it: ASKABLE_AGENTS.md documents this command as a machine-readable relay response, and the manifest's own discovery.machine field advertises it. Both transports share handleInteractiveCommand, so fencing the payload broke JSON.parse for any agent or catalog calling it.

renderCapabilitiesJson returns standalone parseable JSON again. Presentation moved to the transport via an optional presentJson hook that only the Slack actor supplies — the human surface fences, the machine surface does not. I took the shape of @cubic-dev-ai's suggestion rather than the literal diff, so Slack keeps the readable rendering that motivated the PR.

P3 — postReply JSDoc understates the behavior

Fixed; the docstring now describes both cases and says why threading is opt-in rather than default.

Validation: 48 tests in askable-gtm.test.mjs (3 new — relay gets raw fence-free JSON, Slack's fenced payload round-trips, an existing thread stays in that thread), full suite 324 pass / 0 fail, typecheck clean.

@codex review

@cubic-dev-ai

Copy link
Copy Markdown

Both P1s addressed in 8d65ea9. Both were correct, and the first would have shipped a real regression.

P1 — "Preserve the conversation key when starting a reply thread" (@chatgpt-codex-connector, @cubic-dev-ai)

Correct. conversationKeyForSlack keys a top-level message on the channel, so threading it in shared/slack.ts for every agent would have stranded inbox-buddy's opening turn under channel while the follow-up loaded under channel:threadTs — the prompt would lose the question it was answering. Exactly the blast radius I flagged in the description and then didn't actually contain.
...

@khaliqgant Both P1s check out against the diff — good fixes.

P1 #1 (conversation key / opt-in threading). The shape you chose is right. Making startThread opt-in in postReply and mirroring the same option in conversationKeyForSlack closes the regression cleanly: every existing caller (inbox-buddy included) passes nothing and keeps keying top-level continuity on the channel, while askable-gtm, which keeps no cross-turn Slack context, is the only opt-in and loses nothing by moving its unit to the thread. The threadTs = msg.threadTs ?? (options.startThread ? msg.ts : undefined) logic and matching key derivation also keep the "reply stays in an existing thread" path untouched. That's the right containment — the blast radius you flagged in the description is now explicit rather than accidental.

P1 #2 (relay JSON). Correct call. Fencing in handleInteractiveCommand (shared by both transports) would have broken any agent or catalog doing JSON.parse over relay — and worse, it would break silently, since a ```json prefix parses without a hard throw depending on the caller. Moving presentation behind the optional presentJson hook so only the Slack actor fences, while renderCapabilitiesJson stays standalone parseable JSON, is the right separation. The relay test asserting no fences plus the Slack round-trip test pin exactly the contract that was at risk.

P3 (JSDoc). The updated docstring on postReply/SlackReplyOptions now describes both the thread-stays case and why top-level threading is opt-in rather than default — that was the gap.

One small thing worth a second glance: presentJsonForSlack returns the modifier text outside the fence. If a future caller feeds that string back into something that tries to parse it as the payload, the lead line would trip it — but that's presentation-only and the tests correctly round-trip only the fenced slice, so it's fine as-is. No changes needed.

Nice catch on both — the threading one especially would have shipped a real inbox-buddy regression.

Tip: get faster answers by chatting with cubic’s review copilot in the review UI

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. 🚀

Reviewed commit:8d65ea9914

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@cubic-dev-aicubic-dev-aiBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 3 files (changes from recent commits).

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment threadtests/askable-gtm.test.mjs
khaliqgantand others added 2 commits September 1, 2026 12:54
The first live answer in Slack was a ~4,300-character wall. Reddit rows carry a
`title`, LinkedIn rows do not, so the renderer fell back to `body_text` — and
pasted five entire LinkedIn posts into one thread. `oneLine` collapses
whitespace but never truncates.
A citation is a pointer to a source, not a copy of it. Excerpt to 180
characters on a word boundary, and let the link carry the rest. The same answer
is now ~1,200 characters and scannable.
Also attribute LinkedIn rows by public author. Reddit rows show their
community; LinkedIn has none, so those lines carried no indication of who was
speaking — while `author.handle` was already parsed and is evidence the
capability manifest promises to surface.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SQpuAYyuSG3MWSZYnw5o1x
The test named "relay receives raw JSON while Slack receives the fenced form"
only asserted the relay half. Removing `presentJson` from the Slack actor left
it green — the exact regression it was written to catch.
Now drives `handleSlackMessage` as well and asserts the reply is threaded,
fenced, and carries byte-for-byte the same document relay received. Verified by
ablation: deleting `presentJson: presentJsonForSlack` fails this test.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SQpuAYyuSG3MWSZYnw5o1x
@khaliqgant

Copy link
Copy Markdown
MemberAuthor

@cubic-dev-ai P2 addressed in `e0a2b0f` — correct, and a fair hit.

The test asserted a claim its own name made twice and only proved once: it captured relay.dm output but never the Slack side, so removing presentJson from the Slack actor left it green. That is precisely the regression it existed to catch.

It now drives handleSlackMessage too, and asserts the Slack reply is threaded, fenced, and carries byte-for-byte the same document relay received — so the transport split is pinned from both ends rather than one.

Verified by ablation rather than assertion: deleting presentJson: presentJsonForSlack from the Slack actor now fails it (not ok 47), and restoring it passes. 50 tests in this file, 326 in the full suite, 0 failures.

Worth noting this is the third leaf-only test gap caught by review on this branch and its siblings today, all the same shape: a helper tested directly while the wiring that reaches it went unasserted. I've started ablating rather than trusting green.

@cubic-dev-ai

Copy link
Copy Markdown

@cubic-dev-ai P2 addressed in `e0a2b0f` — correct, and a fair hit.

The test asserted a claim its own name made twice and only proved once: it captured relay.dm output but never the Slack side, so removing presentJson from the Slack actor left it green. That is precisely the regression it existed to catch.

It now drives handleSlackMessage too, and asserts the Slack reply is threaded, fenced, and carries byte-for-byte the same document relay received — so the transport split is pinned from both ends rather than one.
...

@khaliqgant Agreed, that's a fair hit and e0a2b0f closes it properly. The old test only pinned the relay half of "relay gets raw JSON while Slack gets fenced" — the name promised both, the assertion proved one, and removing presentJson from the Slack actor slid right through green. That's exactly the leaf-only gap you describe.

The fix lands the right way: driving handleSlackMessage through the real handler and asserting the Slack reply is threaded, fenced, and byte-for-byte the same document relay received pins the transport split from both ends. Ablation-verified too, which is the honest way to prove a test guards a regression — deleting presentJson now fails, restoring passes. Nothing to add on the diff itself.

The pattern you're calling out is worth a habit, not just a patch: a test that names two sides and asserts one is a latent hole. Your "ablate rather than trust green" instinct is the right correction for this branch's whole cluster of same-shaped gaps.

Tip: get faster answers by chatting with cubic’s review copilot in the review UI

@cubic-dev-aicubic-dev-aiBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1 issue found across 2 files (changes from recent commits).

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="askable-gtm/agent.ts">
<violation number="1" location="askable-gtm/agent.ts:1510">
P3: When a post has a non-BMP character at the excerpt boundary, `truncateEvidence` splits its surrogate pair and sends malformed text to Slack. Count and slice Unicode code points instead of UTF-16 code units.</violation>
</file>

Tip: Review your code locally with the cubic CLI to iterate faster.

Re-trigger cubic

Comment threadaskable-gtm/agent.ts
Comment on lines +1510 to +1517
export function truncateEvidence(value: string, max = EVIDENCE_EXCERPT_MAX): string {
const single = oneLine(value);
if (single.length <= max) return single;
const clipped = single.slice(0, max);
const lastSpace = clipped.lastIndexOf(' ');
// Only honour the word boundary if it does not gut the excerpt.
const body = lastSpace > max * 0.6 ? clipped.slice(0, lastSpace) : clipped;
return `${body.replace(/[\s.,;:!?—-]+$/u, '')}…`;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: When a post has a non-BMP character at the excerpt boundary, truncateEvidence splits its surrogate pair and sends malformed text to Slack. Count and slice Unicode code points instead of UTF-16 code units.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At askable-gtm/agent.ts, line 1510:
<comment>When a post has a non-BMP character at the excerpt boundary, `truncateEvidence` splits its surrogate pair and sends malformed text to Slack. Count and slice Unicode code points instead of UTF-16 code units.</comment>
<file context>
@@ -1488,6 +1499,24 @@ function oneLine(value: string): string {
+ * to a source, not a copy of it: pasting five full LinkedIn posts into a Slack
+ * thread buries the very signal the answer exists to surface.
+ */
+export function truncateEvidence(value: string, max = EVIDENCE_EXCERPT_MAX): string {
+ const single = oneLine(value);
+ if (single.length <= max) return single;
</file context>
Suggested change
exportfunctiontruncateEvidence(value: string,max=EVIDENCE_EXCERPT_MAX): string{
constsingle=oneLine(value);
if(single.length<=max)returnsingle;
constclipped=single.slice(0,max);
constlastSpace=clipped.lastIndexOf(' ');
// Only honour the word boundary if it does not gut the excerpt.
constbody=lastSpace>max*0.6 ? clipped.slice(0,lastSpace) : clipped;
return`${body.replace(/[\s.,;:!?-]+$/u,'')}…`;
exportfunctiontruncateEvidence(value: string,max=EVIDENCE_EXCERPT_MAX): string{
constcharacters=[...oneLine(value)];
if(characters.length<=max)returncharacters.join('');
constclipped=characters.slice(0,max);
constlastSpace=clipped.lastIndexOf(' ');
// Only honour the word boundary if it does not gut the excerpt.
constbody=lastSpace>max*0.6
? clipped.slice(0,lastSpace).join('')
: clipped.join('');
return`${body.replace(/[\s.,;:!?-]+$/u,'')}…`;
}

Four lines of ceremony sat above every result: the question restated, the fetch
timestamp and raw coverage map, the credential/endpoint disclosure, and a
standing caveat about inference. All of it was either something the reader had
just typed or something they could see, and it pushed the actual findings
below the fold.
Two things survive the trim, because dropping them makes the answer dishonest
rather than merely terse:
- a missing source, now stated as a compact `(reddit unavailable this
request)` rather than a sentence. Returning LinkedIn-only results while
Reddit is 403 and saying nothing presents a partial view as a complete one,
which is the failure this persona exists to avoid.
- the access disclosure, but only when the credential is NOT the user's own.
The manifest marks managed access `disclosureRequired` because it is
metered and billable, and `unknown` discloses too — the gateway did not say
which path it used, and staying quiet about a possible charge is the wrong
way to be wrong.
Same answer as the screenshot that prompted this: ~4,300 chars → ~680.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SQpuAYyuSG3MWSZYnw5o1x

@cubic-dev-aicubic-dev-aiBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 2 files (changes from recent commits).

Tip: Review your code locally with the cubic CLI to iterate faster.

Re-trigger cubic

Comment threadaskable-gtm/agent.ts
Dropping the query header was right for the interactive path — the reader just
typed the question — but the renderer serves two callers, and I only reasoned
about one. `runWatchSweep` sends the same output as an unsolicited DM that can
land hours later beside other watches, so with the header gone the user could
not tell which saved query fired, or which id to unwatch.
The header belongs at the delivery site rather than in the renderer: the sweep
knows it is delivering unsolicited, the interactive path knows it is not.
Compact by design — query, cadence, id — so it identifies the update without
reintroducing the preamble that buried the results.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SQpuAYyuSG3MWSZYnw5o1x
@khaliqgant

Copy link
Copy Markdown
MemberAuthor

@cubic-dev-ai P2 addressed in db3b1e6 — correct, and a gap in my reasoning rather than a detail.

Dropping the query header was right for the interactive path (the reader just typed the question), but renderListenAnswer serves two callers and I only reasoned about one. runWatchSweep sends the same output as an unsolicited DM that can arrive hours later beside other watches — with the header gone, there was nothing to say which saved query fired or which id to unwatch.

Fixed at the delivery site rather than in the renderer, since that is where the distinction actually lives: the sweep knows it is delivering unsolicited, the interactive path knows it is not. The label is compact by design — New for “…” · every 6h · <id> — so it identifies the update without reintroducing the four lines of preamble that buried the results in the first place.

New test asserts both halves: a swept delivery carries query, cadence and id and is followed immediately by results; the interactive answer stays bare. 51 tests in this file, 327 in the full suite, 0 failures.

@cubic-dev-ai

Copy link
Copy Markdown

@cubic-dev-ai P2 addressed in db3b1e6 — correct, and a gap in my reasoning rather than a detail.

Dropping the query header was right for the interactive path (the reader just typed the question), but renderListenAnswer serves two callers and I only reasoned about one. runWatchSweep sends the same output as an unsolicited DM that can arrive hours later beside other watches — with the header gone, there was nothing to say which saved query fired or which id to unwatch.

Fixed at the delivery site rather than in the renderer, since that is where the distinction actually lives: the sweep knows it is delivering unsolicited, the interactive path knows it is not. The label is compact by design — New for “…” · every 6h · <id> — so it identifies the update without reintroducing the four lines of preamble that buried the results in the first place.
...

@khaliqgant Confirmed — db3b1e6 lands that one where it belongs. Putting the identifier at the delivery site is the right containment: the sweep is the only caller that knows it's unsolicited, so coupling it to renderListenAnswer (which serves the interactive path too) would have dragged the ceremony back into every answer. Keeping the renderer bare and letting runWatchSweep prepend renderWatchHeader keeps each path honest about its own context.

The shape reads well against the diff:

  • renderWatchHeader stays compact — query, cadence, id — so it names the watch without resurrecting the four-line preamble that motivated the trim.
  • Header is only prepended on fresh deliveries (fresh.length > 0), which is correct: a sweep with nothing new shouldn't announce itself.
  • renderListenAnswer dropping the query restatement (now _query) is safe for the interactive path, and the new test pins both halves — swept delivery carries query/cadence/id followed immediately by results, interactive stays bare.

Good distinction between "who is reading this" and "does this renderer know that" — the sweep knows, the renderer doesn't, so the label lives at the delivery point. Nothing to add.

Tip: get faster answers by chatting with cubic’s review copilot in the review UI

@khaliqgant
khaliqgant merged commit 2febc91 into mainSep 1, 2026
3 checks passed
@khaliqgant
khaliqgant deleted the fix/askable-gtm-slack-reply-ux branch September 1, 2026 11:28
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@khaliqgant
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all \u003cpre\u003e\u003ccode\u003e blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks"); } } catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); } })(); (function(){ try { var __m = "github.com"; var __re = new RegExp('^' + "github\\.com" + '
Skip to content

fix(slack): answer in a thread, and make the manifest readable - #121

Merged
khaliqgant merged 6 commits into
mainfrom
fix/askable-gtm-slack-reply-ux
Sep 1, 2026
Merged

fix(slack): answer in a thread, and make the manifest readable#121
khaliqgant merged 6 commits into
mainfrom
fix/askable-gtm-slack-reply-ux

Conversation

@khaliqgant

@khaliqgantkhaliqgant commented Sep 1, 2026

Copy link
Copy Markdown
Member

Both problems observed on the first live @mention after cloud#3231 deployed.

1. Replies went to the channel, not a thread

postReply only threaded when the incoming message was already in one:

constresult=msg.threadTs
? awaitslack.reply(chanId,msg.threadTs,text)
: awaitslack.post(chanId,text);// ← top-level mention dumps into the channel

That is worst exactly where agents are useful. Every agent shares one Slack identity, so a single @Agent Relay … wakes every agent watching that channel — the production dispatch for one message matched 8 deployments — and they all replied into the main channel. Now the reply threads under the incoming message, and stays in the existing thread when there is one.

2. capabilities --json was an unreadable wall

~4KB of minified JSON posted as plain text. Now pretty-printed inside a ```json fence and led by one line naming the agent and pointing at plain-language questions — nobody should have to parse JSON to learn they can just ask.

Blast radius, deliberately

The threading change is in shared/slack.ts, so it applies to every agent using that helper (hn-monitor, joke-bot, inbox-buddy, review, …). That is the intent: an agent answering a channel mention should thread. Calling it out because it is broader than the askable-gtm title suggests.

The existing test asserting a channel-level post encoded the old behaviour; it now asserts the thread, with the reasoning in a comment so it is not "fixed" back later.

Validation

  • npm run typecheck: pass
  • tests/askable-gtm.test.mjs: 46 pass, 2 new — fenced/parseable manifest, and an existing thread answered in that thread rather than a new one
  • full suite: 322 pass / 0 fail

Not fixed here

One @Agent Relay mention still wakes every agent watching the channel, because they share a Slack identity — cloud#3234 gates on the bot being mentioned, which does not disambiguate which agent is being addressed. Threading makes that liveable, not solved. Worth a follow-up.

🤖 Generated with Claude Code


Summary by cubic

Fixes four problems from the first live @mention: replies thread under the message instead of landing in the channel, capabilities --json stays parseable for machines while Slack gets a readable fenced view, cited evidence is an excerpt rather than a whole post, and answers lead with results instead of a preamble.

  • Replies thread under the incoming message, and replies to a message already in a thread stay in that thread.
  • Threading is opt-in via startThread in postReply/conversationKeyForSlack; only askable-gtm enables it because it keeps no cross-turn Slack context.
  • capabilities --json stays standalone parseable JSON over relay; the Slack actor fences and pretty-prints it via a new presentJson hook.
  • Evidence lines are excerpted to 180 characters on a word boundary, and LinkedIn rows are attributed by author handle.
  • Answers drop the question restatement and coverage preamble, keeping only a compact source-failure note and the disclosure for metered managed access.
  • Unsolicited watch deliveries get a compact header naming the query, cadence, and watch id so the reader knows which one fired and can unwatch it.
  • Tests drive both transport halves through the real handlers, so the relay/Slack split can't silently regress.
  • One @Agent Relay mention still wakes every agent watching the channel since they share a Slack identity; threading makes that liveable, not solved.

Written for commit 55f8d85. Summary will update on new commits.

Review in cubic

Two problems seen in production on the first live @mention.
Replies landed in the channel, not a thread. `postReply` only threaded when the
incoming message was ALREADY in one, so a top-level `@Agent Relay …` got a
channel-level answer. That is worst exactly where agents are useful: every
agent shares one Slack identity, so one mention draws a reply from each agent
watching the channel and they all pile into the main channel. Now the reply
threads under the incoming message, and stays in the existing thread when there
is one.
`capabilities --json` dumped ~4KB of minified JSON as plain text, which Slack
renders as an unreadable wall. Pretty-print it inside a fenced block and lead
with one line naming the agent and saying you can just ask a question in plain
language — nobody should have to parse JSON to discover that.
The threading change is in shared/slack.ts, so it applies to every agent using
that helper. That is intended: an agent answering a channel mention should
thread. The existing test asserting a channel-level `post` encoded the old
behaviour and now asserts the thread.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SQpuAYyuSG3MWSZYnw5o1x
@chatgpt-codex-connector

chatgpt-codex-connectorBot commented Sep 1, 2026

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

ReviewStatusCommitReview trigger
📝 Code ReviewCompleted2026-09-01T10:45:10.460193Z8d65ea9Manual request
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@coderabbitai

coderabbitaiBot commented Sep 1, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

Next included review available in 11 minutes.

Check out review usage here.

View limit details

Limit details: You’ve used the included review currently available.

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

Learn how review limits work.

Review configuration:

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Team

Run ID: 91a9e59a-20ff-4bc8-ab8c-a74c68244ed7

📥 Commits

Reviewing files that changed from the base of the PR and between 2cf284a and 55f8d85.

📒 Files selected for processing (3)
  • askable-gtm/agent.ts
  • shared/slack.ts
  • tests/askable-gtm.test.mjs

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Team

Run ID: d0a8e943-72fc-4d71-bfc9-327e0382122a

📥 Commits

Reviewing files that changed from the base of the PR and between 82d88be and 2cf284a.

📒 Files selected for processing (3)
  • askable-gtm/agent.ts
  • shared/slack.ts
  • tests/askable-gtm.test.mjs

Included review availability: Your plan provides up to 1 included review per hour; 0 remain after this review.


📝 Walkthrough

Walkthrough

The capabilities command now delegates formatted JSON output to an exported renderer. Slack replies now always use a thread root, including for top-level messages.

Changes

Capabilities JSON rendering

Layer / File(s)Summary
Render and validate capability manifests
askable-gtm/agent.ts, tests/askable-gtm.test.mjs
The command uses renderCapabilitiesJson. The helper emits explanatory text, fenced pretty-printed JSON, and runtime access status. Tests validate the output.

Slack threaded replies

Layer / File(s)Summary
Route replies to message threads
shared/slack.ts, tests/askable-gtm.test.mjs
postReply uses the incoming timestamp for top-level messages and preserves existing thread timestamps. Tests cover both reply paths.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Merge Risk:⚪ Minimal · up to 2cf28

Replies now stay under the relevant Slack message and capability output is easier to read without changing its content; no actionable merge-blocking risk remains after normal checks and review.

Poem

A rabbit formats JSON bright,
With fences neat and spaces right.
Slack hops into threads anew,
Keeping every reply in view.
Two tidy paths now serve the queue.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check nameStatusExplanationResolution
Docstring Coverage⚠️ WarningDocstring coverage is 40.00% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 5 functions across 3 files.Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check nameStatusExplanation
Title check✅ PassedThe title clearly summarizes both primary changes: threaded Slack replies and readable capability manifests.
Description check✅ PassedThe description directly explains the Slack threading change, manifest formatting change, scope, tests, validation, and known limitation.
Linked Issues check✅ PassedCheck skipped because no linked issues were found for this pull request.
Out of Scope Changes check✅ PassedCheck skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/askable-gtm-slack-reply-ux

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@chatgpt-codex-connectorchatgpt-codex-connectorBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit:2cf284a9cc

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment threadshared/slack.ts Outdated
Comment on lines +138 to +140
const threadTs = msg.threadTs ?? msg.ts;
const result = threadTs
? await slack.reply(chanId, threadTs, text)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve the conversation key when starting a reply thread

When Inbox Buddy handles a top-level message, conversationKeyForSlack stores that turn under the channel key, but this change posts the answer beneath msg.ts; a user's natural follow-up in that new thread then arrives with threadTs and is loaded under channel:threadTs. Consequently, the initial question and answer are absent from the follow-up prompt, breaking the multi-turn continuity implemented in inbox-buddy/agent.ts. Ensure the initial turn and its resulting thread use the same conversation key.

Useful? React with 👍 / 👎.

Comment threadaskable-gtm/agent.ts
Comment on lines +1007 to +1010
'GTM Signal Scout — machine-readable capability manifest.',
'You can also just ask a GTM question in plain language, or send'
+ ' \u201cwhat can you tell me?\u201d for the short version.',
'```json',

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Keep relay capability responses valid JSON

When an agent or catalog invokes capabilities --json over Relay, handleRelayMessage now returns these prose and fence lines around the payload, so parsing the response directly with JSON.parse fails. This affects Relay as well as Slack because both transports share handleInteractiveCommand, despite the documented contract in ASKABLE_AGENTS.md describing this command as a machine-readable relay response; apply Slack-only presentation formatting or leave the relay response as standalone JSON.

Useful? React with 👍 / 👎.

@cubic-dev-aicubic-dev-aiBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1 issue found across 3 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="shared/slack.ts">
<violation number="1" location="shared/slack.ts:138">
P3: The `postReply` JSDoc now understates the behavior: this line also threads top-level messages under their own timestamp. Update the docstring to describe both top-level and existing-thread replies.</violation>
</file>

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment threadaskable-gtm/agent.ts Outdated
Comment threadshared/slack.ts Outdated
Comment threadshared/slack.ts Outdated
// buries the conversation in the main channel, which is worst exactly where
// agents are useful — several agents share one Slack identity, so a single
// `@Agent Relay` mention can draw a reply from each of them.
const threadTs = msg.threadTs ?? msg.ts;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: The postReply JSDoc now understates the behavior: this line also threads top-level messages under their own timestamp. Update the docstring to describe both top-level and existing-thread replies.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At shared/slack.ts, line 138:
<comment>The `postReply` JSDoc now understates the behavior: this line also threads top-level messages under their own timestamp. Update the docstring to describe both top-level and existing-thread replies.</comment>
<file context>
@@ -130,10 +130,16 @@ export async function postReply(
+ // buries the conversation in the main channel, which is worst exactly where
+ // agents are useful — several agents share one Slack identity, so a single
+ // `@Agent Relay` mention can draw a reply from each of them.
+ const threadTs = msg.threadTs ?? msg.ts;
+ const result = threadTs
+ ? await slack.reply(chanId, threadTs, text)
</file context>

Both P1s from review, each flagged independently by both reviewers.
Threading was applied in shared/slack.ts for every agent, which would have
broken inbox-buddy's multi-turn context: `conversationKeyForSlack` keys a
top-level message on the CHANNEL, so answering it in a new thread stranded the
opening turn under `channel` while the follow-up loaded under `channel:ts` —
the prompt would lose the question it was answering.
Threading is now opt-in via `postReply(..., { startThread: true })`, and
`conversationKeyForSlack` takes the same option so an agent that opts in keys
continuity on the thread it created. Every existing caller passes nothing and
is byte-for-byte unchanged; only askable-gtm opts in, and it keeps no
cross-turn Slack context so moving the conversation unit costs nothing.
`capabilities --json` is documented in ASKABLE_AGENTS.md as a MACHINE-readable
relay response, and both transports share `handleInteractiveCommand` — so
fencing the payload broke `JSON.parse` for any agent or catalog calling it over
relay. The payload is standalone JSON again; presentation moved to the
transport via an optional `presentJson` hook that only the Slack actor supplies.
Tests pin both: relay receives raw parseable JSON with no fences, Slack
receives the fenced form whose payload still round-trips, and an opted-in
top-level mention threads under its own ts.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SQpuAYyuSG3MWSZYnw5o1x
@khaliqgant

Copy link
Copy Markdown
MemberAuthor

Both P1s addressed in 8d65ea9. Both were correct, and the first would have shipped a real regression.

P1 — "Preserve the conversation key when starting a reply thread" (@chatgpt-codex-connector, @cubic-dev-ai)

Correct. conversationKeyForSlack keys a top-level message on the channel, so threading it in shared/slack.ts for every agent would have stranded inbox-buddy's opening turn under channel while the follow-up loaded under channel:threadTs — the prompt would lose the question it was answering. Exactly the blast radius I flagged in the description and then didn't actually contain.

Rather than patch the key, threading is now opt-in: postReply(..., { startThread: true }), with conversationKeyForSlack taking the same option so an agent that opts in keys continuity on the thread it created. Every existing caller passes nothing and is byte-for-byte unchanged — inbox-buddy included. Only askable-gtm opts in, and it keeps no cross-turn Slack context, so moving its conversation unit to the thread costs nothing.

P1 — "Keep relay capability responses valid JSON" (@chatgpt-codex-connector, @cubic-dev-ai)

Correct, and I should have caught it: ASKABLE_AGENTS.md documents this command as a machine-readable relay response, and the manifest's own discovery.machine field advertises it. Both transports share handleInteractiveCommand, so fencing the payload broke JSON.parse for any agent or catalog calling it.

renderCapabilitiesJson returns standalone parseable JSON again. Presentation moved to the transport via an optional presentJson hook that only the Slack actor supplies — the human surface fences, the machine surface does not. I took the shape of @cubic-dev-ai's suggestion rather than the literal diff, so Slack keeps the readable rendering that motivated the PR.

P3 — postReply JSDoc understates the behavior

Fixed; the docstring now describes both cases and says why threading is opt-in rather than default.

Validation: 48 tests in askable-gtm.test.mjs (3 new — relay gets raw fence-free JSON, Slack's fenced payload round-trips, an existing thread stays in that thread), full suite 324 pass / 0 fail, typecheck clean.

@codex review

@cubic-dev-ai

Copy link
Copy Markdown

Both P1s addressed in 8d65ea9. Both were correct, and the first would have shipped a real regression.

P1 — "Preserve the conversation key when starting a reply thread" (@chatgpt-codex-connector, @cubic-dev-ai)

Correct. conversationKeyForSlack keys a top-level message on the channel, so threading it in shared/slack.ts for every agent would have stranded inbox-buddy's opening turn under channel while the follow-up loaded under channel:threadTs — the prompt would lose the question it was answering. Exactly the blast radius I flagged in the description and then didn't actually contain.
...

@khaliqgant Both P1s check out against the diff — good fixes.

P1 #1 (conversation key / opt-in threading). The shape you chose is right. Making startThread opt-in in postReply and mirroring the same option in conversationKeyForSlack closes the regression cleanly: every existing caller (inbox-buddy included) passes nothing and keeps keying top-level continuity on the channel, while askable-gtm, which keeps no cross-turn Slack context, is the only opt-in and loses nothing by moving its unit to the thread. The threadTs = msg.threadTs ?? (options.startThread ? msg.ts : undefined) logic and matching key derivation also keep the "reply stays in an existing thread" path untouched. That's the right containment — the blast radius you flagged in the description is now explicit rather than accidental.

P1 #2 (relay JSON). Correct call. Fencing in handleInteractiveCommand (shared by both transports) would have broken any agent or catalog doing JSON.parse over relay — and worse, it would break silently, since a ```json prefix parses without a hard throw depending on the caller. Moving presentation behind the optional presentJson hook so only the Slack actor fences, while renderCapabilitiesJson stays standalone parseable JSON, is the right separation. The relay test asserting no fences plus the Slack round-trip test pin exactly the contract that was at risk.

P3 (JSDoc). The updated docstring on postReply/SlackReplyOptions now describes both the thread-stays case and why top-level threading is opt-in rather than default — that was the gap.

One small thing worth a second glance: presentJsonForSlack returns the modifier text outside the fence. If a future caller feeds that string back into something that tries to parse it as the payload, the lead line would trip it — but that's presentation-only and the tests correctly round-trip only the fenced slice, so it's fine as-is. No changes needed.

Nice catch on both — the threading one especially would have shipped a real inbox-buddy regression.

Tip: get faster answers by chatting with cubic’s review copilot in the review UI

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. 🚀

Reviewed commit:8d65ea9914

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@cubic-dev-aicubic-dev-aiBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 3 files (changes from recent commits).

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment threadtests/askable-gtm.test.mjs
khaliqgantand others added 2 commits September 1, 2026 12:54
The first live answer in Slack was a ~4,300-character wall. Reddit rows carry a
`title`, LinkedIn rows do not, so the renderer fell back to `body_text` — and
pasted five entire LinkedIn posts into one thread. `oneLine` collapses
whitespace but never truncates.
A citation is a pointer to a source, not a copy of it. Excerpt to 180
characters on a word boundary, and let the link carry the rest. The same answer
is now ~1,200 characters and scannable.
Also attribute LinkedIn rows by public author. Reddit rows show their
community; LinkedIn has none, so those lines carried no indication of who was
speaking — while `author.handle` was already parsed and is evidence the
capability manifest promises to surface.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SQpuAYyuSG3MWSZYnw5o1x
The test named "relay receives raw JSON while Slack receives the fenced form"
only asserted the relay half. Removing `presentJson` from the Slack actor left
it green — the exact regression it was written to catch.
Now drives `handleSlackMessage` as well and asserts the reply is threaded,
fenced, and carries byte-for-byte the same document relay received. Verified by
ablation: deleting `presentJson: presentJsonForSlack` fails this test.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SQpuAYyuSG3MWSZYnw5o1x
@khaliqgant

Copy link
Copy Markdown
MemberAuthor

@cubic-dev-ai P2 addressed in `e0a2b0f` — correct, and a fair hit.

The test asserted a claim its own name made twice and only proved once: it captured relay.dm output but never the Slack side, so removing presentJson from the Slack actor left it green. That is precisely the regression it existed to catch.

It now drives handleSlackMessage too, and asserts the Slack reply is threaded, fenced, and carries byte-for-byte the same document relay received — so the transport split is pinned from both ends rather than one.

Verified by ablation rather than assertion: deleting presentJson: presentJsonForSlack from the Slack actor now fails it (not ok 47), and restoring it passes. 50 tests in this file, 326 in the full suite, 0 failures.

Worth noting this is the third leaf-only test gap caught by review on this branch and its siblings today, all the same shape: a helper tested directly while the wiring that reaches it went unasserted. I've started ablating rather than trusting green.

@cubic-dev-ai

Copy link
Copy Markdown

@cubic-dev-ai P2 addressed in `e0a2b0f` — correct, and a fair hit.

The test asserted a claim its own name made twice and only proved once: it captured relay.dm output but never the Slack side, so removing presentJson from the Slack actor left it green. That is precisely the regression it existed to catch.

It now drives handleSlackMessage too, and asserts the Slack reply is threaded, fenced, and carries byte-for-byte the same document relay received — so the transport split is pinned from both ends rather than one.
...

@khaliqgant Agreed, that's a fair hit and e0a2b0f closes it properly. The old test only pinned the relay half of "relay gets raw JSON while Slack gets fenced" — the name promised both, the assertion proved one, and removing presentJson from the Slack actor slid right through green. That's exactly the leaf-only gap you describe.

The fix lands the right way: driving handleSlackMessage through the real handler and asserting the Slack reply is threaded, fenced, and byte-for-byte the same document relay received pins the transport split from both ends. Ablation-verified too, which is the honest way to prove a test guards a regression — deleting presentJson now fails, restoring passes. Nothing to add on the diff itself.

The pattern you're calling out is worth a habit, not just a patch: a test that names two sides and asserts one is a latent hole. Your "ablate rather than trust green" instinct is the right correction for this branch's whole cluster of same-shaped gaps.

Tip: get faster answers by chatting with cubic’s review copilot in the review UI

@cubic-dev-aicubic-dev-aiBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1 issue found across 2 files (changes from recent commits).

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="askable-gtm/agent.ts">
<violation number="1" location="askable-gtm/agent.ts:1510">
P3: When a post has a non-BMP character at the excerpt boundary, `truncateEvidence` splits its surrogate pair and sends malformed text to Slack. Count and slice Unicode code points instead of UTF-16 code units.</violation>
</file>

Tip: Review your code locally with the cubic CLI to iterate faster.

Re-trigger cubic

Comment threadaskable-gtm/agent.ts
Comment on lines +1510 to +1517
export function truncateEvidence(value: string, max = EVIDENCE_EXCERPT_MAX): string {
const single = oneLine(value);
if (single.length <= max) return single;
const clipped = single.slice(0, max);
const lastSpace = clipped.lastIndexOf(' ');
// Only honour the word boundary if it does not gut the excerpt.
const body = lastSpace > max * 0.6 ? clipped.slice(0, lastSpace) : clipped;
return `${body.replace(/[\s.,;:!?—-]+$/u, '')}…`;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: When a post has a non-BMP character at the excerpt boundary, truncateEvidence splits its surrogate pair and sends malformed text to Slack. Count and slice Unicode code points instead of UTF-16 code units.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At askable-gtm/agent.ts, line 1510:
<comment>When a post has a non-BMP character at the excerpt boundary, `truncateEvidence` splits its surrogate pair and sends malformed text to Slack. Count and slice Unicode code points instead of UTF-16 code units.</comment>
<file context>
@@ -1488,6 +1499,24 @@ function oneLine(value: string): string {
+ * to a source, not a copy of it: pasting five full LinkedIn posts into a Slack
+ * thread buries the very signal the answer exists to surface.
+ */
+export function truncateEvidence(value: string, max = EVIDENCE_EXCERPT_MAX): string {
+ const single = oneLine(value);
+ if (single.length <= max) return single;
</file context>
Suggested change
exportfunctiontruncateEvidence(value: string,max=EVIDENCE_EXCERPT_MAX): string{
constsingle=oneLine(value);
if(single.length<=max)returnsingle;
constclipped=single.slice(0,max);
constlastSpace=clipped.lastIndexOf(' ');
// Only honour the word boundary if it does not gut the excerpt.
constbody=lastSpace>max*0.6 ? clipped.slice(0,lastSpace) : clipped;
return`${body.replace(/[\s.,;:!?-]+$/u,'')}…`;
exportfunctiontruncateEvidence(value: string,max=EVIDENCE_EXCERPT_MAX): string{
constcharacters=[...oneLine(value)];
if(characters.length<=max)returncharacters.join('');
constclipped=characters.slice(0,max);
constlastSpace=clipped.lastIndexOf(' ');
// Only honour the word boundary if it does not gut the excerpt.
constbody=lastSpace>max*0.6
? clipped.slice(0,lastSpace).join('')
: clipped.join('');
return`${body.replace(/[\s.,;:!?-]+$/u,'')}…`;
}

Four lines of ceremony sat above every result: the question restated, the fetch
timestamp and raw coverage map, the credential/endpoint disclosure, and a
standing caveat about inference. All of it was either something the reader had
just typed or something they could see, and it pushed the actual findings
below the fold.
Two things survive the trim, because dropping them makes the answer dishonest
rather than merely terse:
- a missing source, now stated as a compact `(reddit unavailable this
request)` rather than a sentence. Returning LinkedIn-only results while
Reddit is 403 and saying nothing presents a partial view as a complete one,
which is the failure this persona exists to avoid.
- the access disclosure, but only when the credential is NOT the user's own.
The manifest marks managed access `disclosureRequired` because it is
metered and billable, and `unknown` discloses too — the gateway did not say
which path it used, and staying quiet about a possible charge is the wrong
way to be wrong.
Same answer as the screenshot that prompted this: ~4,300 chars → ~680.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SQpuAYyuSG3MWSZYnw5o1x

@cubic-dev-aicubic-dev-aiBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 2 files (changes from recent commits).

Tip: Review your code locally with the cubic CLI to iterate faster.

Re-trigger cubic

Comment threadaskable-gtm/agent.ts
Dropping the query header was right for the interactive path — the reader just
typed the question — but the renderer serves two callers, and I only reasoned
about one. `runWatchSweep` sends the same output as an unsolicited DM that can
land hours later beside other watches, so with the header gone the user could
not tell which saved query fired, or which id to unwatch.
The header belongs at the delivery site rather than in the renderer: the sweep
knows it is delivering unsolicited, the interactive path knows it is not.
Compact by design — query, cadence, id — so it identifies the update without
reintroducing the preamble that buried the results.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SQpuAYyuSG3MWSZYnw5o1x
@khaliqgant

Copy link
Copy Markdown
MemberAuthor

@cubic-dev-ai P2 addressed in db3b1e6 — correct, and a gap in my reasoning rather than a detail.

Dropping the query header was right for the interactive path (the reader just typed the question), but renderListenAnswer serves two callers and I only reasoned about one. runWatchSweep sends the same output as an unsolicited DM that can arrive hours later beside other watches — with the header gone, there was nothing to say which saved query fired or which id to unwatch.

Fixed at the delivery site rather than in the renderer, since that is where the distinction actually lives: the sweep knows it is delivering unsolicited, the interactive path knows it is not. The label is compact by design — New for “…” · every 6h · <id> — so it identifies the update without reintroducing the four lines of preamble that buried the results in the first place.

New test asserts both halves: a swept delivery carries query, cadence and id and is followed immediately by results; the interactive answer stays bare. 51 tests in this file, 327 in the full suite, 0 failures.

@cubic-dev-ai

Copy link
Copy Markdown

@cubic-dev-ai P2 addressed in db3b1e6 — correct, and a gap in my reasoning rather than a detail.

Dropping the query header was right for the interactive path (the reader just typed the question), but renderListenAnswer serves two callers and I only reasoned about one. runWatchSweep sends the same output as an unsolicited DM that can arrive hours later beside other watches — with the header gone, there was nothing to say which saved query fired or which id to unwatch.

Fixed at the delivery site rather than in the renderer, since that is where the distinction actually lives: the sweep knows it is delivering unsolicited, the interactive path knows it is not. The label is compact by design — New for “…” · every 6h · <id> — so it identifies the update without reintroducing the four lines of preamble that buried the results in the first place.
...

@khaliqgant Confirmed — db3b1e6 lands that one where it belongs. Putting the identifier at the delivery site is the right containment: the sweep is the only caller that knows it's unsolicited, so coupling it to renderListenAnswer (which serves the interactive path too) would have dragged the ceremony back into every answer. Keeping the renderer bare and letting runWatchSweep prepend renderWatchHeader keeps each path honest about its own context.

The shape reads well against the diff:

  • renderWatchHeader stays compact — query, cadence, id — so it names the watch without resurrecting the four-line preamble that motivated the trim.
  • Header is only prepended on fresh deliveries (fresh.length > 0), which is correct: a sweep with nothing new shouldn't announce itself.
  • renderListenAnswer dropping the query restatement (now _query) is safe for the interactive path, and the new test pins both halves — swept delivery carries query/cadence/id followed immediately by results, interactive stays bare.

Good distinction between "who is reading this" and "does this renderer know that" — the sweep knows, the renderer doesn't, so the label lives at the delivery point. Nothing to add.

Tip: get faster answers by chatting with cubic’s review copilot in the review UI

@khaliqgant
khaliqgant merged commit 2febc91 into mainSep 1, 2026
3 checks passed
@khaliqgant
khaliqgant deleted the fix/askable-gtm-slack-reply-ux branch September 1, 2026 11:28
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@khaliqgant
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix(slack): answer in a thread, and make the manifest readable - #121

Merged
khaliqgant merged 6 commits into
mainfrom
fix/askable-gtm-slack-reply-ux
Sep 1, 2026
Merged

fix(slack): answer in a thread, and make the manifest readable#121
khaliqgant merged 6 commits into
mainfrom
fix/askable-gtm-slack-reply-ux

Conversation

@khaliqgant

@khaliqgantkhaliqgant commented Sep 1, 2026

Copy link
Copy Markdown
Member

Both problems observed on the first live @mention after cloud#3231 deployed.

1. Replies went to the channel, not a thread

postReply only threaded when the incoming message was already in one:

constresult=msg.threadTs
? awaitslack.reply(chanId,msg.threadTs,text)
: awaitslack.post(chanId,text);// ← top-level mention dumps into the channel

That is worst exactly where agents are useful. Every agent shares one Slack identity, so a single @Agent Relay … wakes every agent watching that channel — the production dispatch for one message matched 8 deployments — and they all replied into the main channel. Now the reply threads under the incoming message, and stays in the existing thread when there is one.

2. capabilities --json was an unreadable wall

~4KB of minified JSON posted as plain text. Now pretty-printed inside a ```json fence and led by one line naming the agent and pointing at plain-language questions — nobody should have to parse JSON to learn they can just ask.

Blast radius, deliberately

The threading change is in shared/slack.ts, so it applies to every agent using that helper (hn-monitor, joke-bot, inbox-buddy, review, …). That is the intent: an agent answering a channel mention should thread. Calling it out because it is broader than the askable-gtm title suggests.

The existing test asserting a channel-level post encoded the old behaviour; it now asserts the thread, with the reasoning in a comment so it is not "fixed" back later.

Validation

  • npm run typecheck: pass
  • tests/askable-gtm.test.mjs: 46 pass, 2 new — fenced/parseable manifest, and an existing thread answered in that thread rather than a new one
  • full suite: 322 pass / 0 fail

Not fixed here

One @Agent Relay mention still wakes every agent watching the channel, because they share a Slack identity — cloud#3234 gates on the bot being mentioned, which does not disambiguate which agent is being addressed. Threading makes that liveable, not solved. Worth a follow-up.

🤖 Generated with Claude Code


Summary by cubic

Fixes four problems from the first live @mention: replies thread under the message instead of landing in the channel, capabilities --json stays parseable for machines while Slack gets a readable fenced view, cited evidence is an excerpt rather than a whole post, and answers lead with results instead of a preamble.

  • Replies thread under the incoming message, and replies to a message already in a thread stay in that thread.
  • Threading is opt-in via startThread in postReply/conversationKeyForSlack; only askable-gtm enables it because it keeps no cross-turn Slack context.
  • capabilities --json stays standalone parseable JSON over relay; the Slack actor fences and pretty-prints it via a new presentJson hook.
  • Evidence lines are excerpted to 180 characters on a word boundary, and LinkedIn rows are attributed by author handle.
  • Answers drop the question restatement and coverage preamble, keeping only a compact source-failure note and the disclosure for metered managed access.
  • Unsolicited watch deliveries get a compact header naming the query, cadence, and watch id so the reader knows which one fired and can unwatch it.
  • Tests drive both transport halves through the real handlers, so the relay/Slack split can't silently regress.
  • One @Agent Relay mention still wakes every agent watching the channel since they share a Slack identity; threading makes that liveable, not solved.

Written for commit 55f8d85. Summary will update on new commits.

Review in cubic

Two problems seen in production on the first live @mention.
Replies landed in the channel, not a thread. `postReply` only threaded when the
incoming message was ALREADY in one, so a top-level `@Agent Relay …` got a
channel-level answer. That is worst exactly where agents are useful: every
agent shares one Slack identity, so one mention draws a reply from each agent
watching the channel and they all pile into the main channel. Now the reply
threads under the incoming message, and stays in the existing thread when there
is one.
`capabilities --json` dumped ~4KB of minified JSON as plain text, which Slack
renders as an unreadable wall. Pretty-print it inside a fenced block and lead
with one line naming the agent and saying you can just ask a question in plain
language — nobody should have to parse JSON to discover that.
The threading change is in shared/slack.ts, so it applies to every agent using
that helper. That is intended: an agent answering a channel mention should
thread. The existing test asserting a channel-level `post` encoded the old
behaviour and now asserts the thread.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SQpuAYyuSG3MWSZYnw5o1x
@chatgpt-codex-connector

chatgpt-codex-connectorBot commented Sep 1, 2026

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

ReviewStatusCommitReview trigger
📝 Code ReviewCompleted2026-09-01T10:45:10.460193Z8d65ea9Manual request
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@coderabbitai

coderabbitaiBot commented Sep 1, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

Next included review available in 11 minutes.

Check out review usage here.

View limit details

Limit details: You’ve used the included review currently available.

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

Learn how review limits work.

Review configuration:

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Team

Run ID: 91a9e59a-20ff-4bc8-ab8c-a74c68244ed7

📥 Commits

Reviewing files that changed from the base of the PR and between 2cf284a and 55f8d85.

📒 Files selected for processing (3)
  • askable-gtm/agent.ts
  • shared/slack.ts
  • tests/askable-gtm.test.mjs

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Team

Run ID: d0a8e943-72fc-4d71-bfc9-327e0382122a

📥 Commits

Reviewing files that changed from the base of the PR and between 82d88be and 2cf284a.

📒 Files selected for processing (3)
  • askable-gtm/agent.ts
  • shared/slack.ts
  • tests/askable-gtm.test.mjs

Included review availability: Your plan provides up to 1 included review per hour; 0 remain after this review.


📝 Walkthrough

Walkthrough

The capabilities command now delegates formatted JSON output to an exported renderer. Slack replies now always use a thread root, including for top-level messages.

Changes

Capabilities JSON rendering

Layer / File(s)Summary
Render and validate capability manifests
askable-gtm/agent.ts, tests/askable-gtm.test.mjs
The command uses renderCapabilitiesJson. The helper emits explanatory text, fenced pretty-printed JSON, and runtime access status. Tests validate the output.

Slack threaded replies

Layer / File(s)Summary
Route replies to message threads
shared/slack.ts, tests/askable-gtm.test.mjs
postReply uses the incoming timestamp for top-level messages and preserves existing thread timestamps. Tests cover both reply paths.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Merge Risk:⚪ Minimal · up to 2cf28

Replies now stay under the relevant Slack message and capability output is easier to read without changing its content; no actionable merge-blocking risk remains after normal checks and review.

Poem

A rabbit formats JSON bright,
With fences neat and spaces right.
Slack hops into threads anew,
Keeping every reply in view.
Two tidy paths now serve the queue.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check nameStatusExplanationResolution
Docstring Coverage⚠️ WarningDocstring coverage is 40.00% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 5 functions across 3 files.Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check nameStatusExplanation
Title check✅ PassedThe title clearly summarizes both primary changes: threaded Slack replies and readable capability manifests.
Description check✅ PassedThe description directly explains the Slack threading change, manifest formatting change, scope, tests, validation, and known limitation.
Linked Issues check✅ PassedCheck skipped because no linked issues were found for this pull request.
Out of Scope Changes check✅ PassedCheck skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/askable-gtm-slack-reply-ux

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@chatgpt-codex-connectorchatgpt-codex-connectorBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit:2cf284a9cc

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment threadshared/slack.ts Outdated
Comment on lines +138 to +140
const threadTs = msg.threadTs ?? msg.ts;
const result = threadTs
? await slack.reply(chanId, threadTs, text)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve the conversation key when starting a reply thread

When Inbox Buddy handles a top-level message, conversationKeyForSlack stores that turn under the channel key, but this change posts the answer beneath msg.ts; a user's natural follow-up in that new thread then arrives with threadTs and is loaded under channel:threadTs. Consequently, the initial question and answer are absent from the follow-up prompt, breaking the multi-turn continuity implemented in inbox-buddy/agent.ts. Ensure the initial turn and its resulting thread use the same conversation key.

Useful? React with 👍 / 👎.

Comment threadaskable-gtm/agent.ts
Comment on lines +1007 to +1010
'GTM Signal Scout — machine-readable capability manifest.',
'You can also just ask a GTM question in plain language, or send'
+ ' \u201cwhat can you tell me?\u201d for the short version.',
'```json',

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Keep relay capability responses valid JSON

When an agent or catalog invokes capabilities --json over Relay, handleRelayMessage now returns these prose and fence lines around the payload, so parsing the response directly with JSON.parse fails. This affects Relay as well as Slack because both transports share handleInteractiveCommand, despite the documented contract in ASKABLE_AGENTS.md describing this command as a machine-readable relay response; apply Slack-only presentation formatting or leave the relay response as standalone JSON.

Useful? React with 👍 / 👎.

@cubic-dev-aicubic-dev-aiBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1 issue found across 3 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="shared/slack.ts">
<violation number="1" location="shared/slack.ts:138">
P3: The `postReply` JSDoc now understates the behavior: this line also threads top-level messages under their own timestamp. Update the docstring to describe both top-level and existing-thread replies.</violation>
</file>

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment threadaskable-gtm/agent.ts Outdated
Comment threadshared/slack.ts Outdated
Comment threadshared/slack.ts Outdated
// buries the conversation in the main channel, which is worst exactly where
// agents are useful — several agents share one Slack identity, so a single
// `@Agent Relay` mention can draw a reply from each of them.
const threadTs = msg.threadTs ?? msg.ts;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: The postReply JSDoc now understates the behavior: this line also threads top-level messages under their own timestamp. Update the docstring to describe both top-level and existing-thread replies.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At shared/slack.ts, line 138:
<comment>The `postReply` JSDoc now understates the behavior: this line also threads top-level messages under their own timestamp. Update the docstring to describe both top-level and existing-thread replies.</comment>
<file context>
@@ -130,10 +130,16 @@ export async function postReply(
+ // buries the conversation in the main channel, which is worst exactly where
+ // agents are useful — several agents share one Slack identity, so a single
+ // `@Agent Relay` mention can draw a reply from each of them.
+ const threadTs = msg.threadTs ?? msg.ts;
+ const result = threadTs
+ ? await slack.reply(chanId, threadTs, text)
</file context>

Both P1s from review, each flagged independently by both reviewers.
Threading was applied in shared/slack.ts for every agent, which would have
broken inbox-buddy's multi-turn context: `conversationKeyForSlack` keys a
top-level message on the CHANNEL, so answering it in a new thread stranded the
opening turn under `channel` while the follow-up loaded under `channel:ts` —
the prompt would lose the question it was answering.
Threading is now opt-in via `postReply(..., { startThread: true })`, and
`conversationKeyForSlack` takes the same option so an agent that opts in keys
continuity on the thread it created. Every existing caller passes nothing and
is byte-for-byte unchanged; only askable-gtm opts in, and it keeps no
cross-turn Slack context so moving the conversation unit costs nothing.
`capabilities --json` is documented in ASKABLE_AGENTS.md as a MACHINE-readable
relay response, and both transports share `handleInteractiveCommand` — so
fencing the payload broke `JSON.parse` for any agent or catalog calling it over
relay. The payload is standalone JSON again; presentation moved to the
transport via an optional `presentJson` hook that only the Slack actor supplies.
Tests pin both: relay receives raw parseable JSON with no fences, Slack
receives the fenced form whose payload still round-trips, and an opted-in
top-level mention threads under its own ts.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SQpuAYyuSG3MWSZYnw5o1x
@khaliqgant

Copy link
Copy Markdown
MemberAuthor

Both P1s addressed in 8d65ea9. Both were correct, and the first would have shipped a real regression.

P1 — "Preserve the conversation key when starting a reply thread" (@chatgpt-codex-connector, @cubic-dev-ai)

Correct. conversationKeyForSlack keys a top-level message on the channel, so threading it in shared/slack.ts for every agent would have stranded inbox-buddy's opening turn under channel while the follow-up loaded under channel:threadTs — the prompt would lose the question it was answering. Exactly the blast radius I flagged in the description and then didn't actually contain.

Rather than patch the key, threading is now opt-in: postReply(..., { startThread: true }), with conversationKeyForSlack taking the same option so an agent that opts in keys continuity on the thread it created. Every existing caller passes nothing and is byte-for-byte unchanged — inbox-buddy included. Only askable-gtm opts in, and it keeps no cross-turn Slack context, so moving its conversation unit to the thread costs nothing.

P1 — "Keep relay capability responses valid JSON" (@chatgpt-codex-connector, @cubic-dev-ai)

Correct, and I should have caught it: ASKABLE_AGENTS.md documents this command as a machine-readable relay response, and the manifest's own discovery.machine field advertises it. Both transports share handleInteractiveCommand, so fencing the payload broke JSON.parse for any agent or catalog calling it.

renderCapabilitiesJson returns standalone parseable JSON again. Presentation moved to the transport via an optional presentJson hook that only the Slack actor supplies — the human surface fences, the machine surface does not. I took the shape of @cubic-dev-ai's suggestion rather than the literal diff, so Slack keeps the readable rendering that motivated the PR.

P3 — postReply JSDoc understates the behavior

Fixed; the docstring now describes both cases and says why threading is opt-in rather than default.

Validation: 48 tests in askable-gtm.test.mjs (3 new — relay gets raw fence-free JSON, Slack's fenced payload round-trips, an existing thread stays in that thread), full suite 324 pass / 0 fail, typecheck clean.

@codex review

@cubic-dev-ai

Copy link
Copy Markdown

Both P1s addressed in 8d65ea9. Both were correct, and the first would have shipped a real regression.

P1 — "Preserve the conversation key when starting a reply thread" (@chatgpt-codex-connector, @cubic-dev-ai)

Correct. conversationKeyForSlack keys a top-level message on the channel, so threading it in shared/slack.ts for every agent would have stranded inbox-buddy's opening turn under channel while the follow-up loaded under channel:threadTs — the prompt would lose the question it was answering. Exactly the blast radius I flagged in the description and then didn't actually contain.
...

@khaliqgant Both P1s check out against the diff — good fixes.

P1 #1 (conversation key / opt-in threading). The shape you chose is right. Making startThread opt-in in postReply and mirroring the same option in conversationKeyForSlack closes the regression cleanly: every existing caller (inbox-buddy included) passes nothing and keeps keying top-level continuity on the channel, while askable-gtm, which keeps no cross-turn Slack context, is the only opt-in and loses nothing by moving its unit to the thread. The threadTs = msg.threadTs ?? (options.startThread ? msg.ts : undefined) logic and matching key derivation also keep the "reply stays in an existing thread" path untouched. That's the right containment — the blast radius you flagged in the description is now explicit rather than accidental.

P1 #2 (relay JSON). Correct call. Fencing in handleInteractiveCommand (shared by both transports) would have broken any agent or catalog doing JSON.parse over relay — and worse, it would break silently, since a ```json prefix parses without a hard throw depending on the caller. Moving presentation behind the optional presentJson hook so only the Slack actor fences, while renderCapabilitiesJson stays standalone parseable JSON, is the right separation. The relay test asserting no fences plus the Slack round-trip test pin exactly the contract that was at risk.

P3 (JSDoc). The updated docstring on postReply/SlackReplyOptions now describes both the thread-stays case and why top-level threading is opt-in rather than default — that was the gap.

One small thing worth a second glance: presentJsonForSlack returns the modifier text outside the fence. If a future caller feeds that string back into something that tries to parse it as the payload, the lead line would trip it — but that's presentation-only and the tests correctly round-trip only the fenced slice, so it's fine as-is. No changes needed.

Nice catch on both — the threading one especially would have shipped a real inbox-buddy regression.

Tip: get faster answers by chatting with cubic’s review copilot in the review UI

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. 🚀

Reviewed commit:8d65ea9914

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@cubic-dev-aicubic-dev-aiBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 3 files (changes from recent commits).

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment threadtests/askable-gtm.test.mjs
khaliqgantand others added 2 commits September 1, 2026 12:54
The first live answer in Slack was a ~4,300-character wall. Reddit rows carry a
`title`, LinkedIn rows do not, so the renderer fell back to `body_text` — and
pasted five entire LinkedIn posts into one thread. `oneLine` collapses
whitespace but never truncates.
A citation is a pointer to a source, not a copy of it. Excerpt to 180
characters on a word boundary, and let the link carry the rest. The same answer
is now ~1,200 characters and scannable.
Also attribute LinkedIn rows by public author. Reddit rows show their
community; LinkedIn has none, so those lines carried no indication of who was
speaking — while `author.handle` was already parsed and is evidence the
capability manifest promises to surface.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SQpuAYyuSG3MWSZYnw5o1x
The test named "relay receives raw JSON while Slack receives the fenced form"
only asserted the relay half. Removing `presentJson` from the Slack actor left
it green — the exact regression it was written to catch.
Now drives `handleSlackMessage` as well and asserts the reply is threaded,
fenced, and carries byte-for-byte the same document relay received. Verified by
ablation: deleting `presentJson: presentJsonForSlack` fails this test.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SQpuAYyuSG3MWSZYnw5o1x
@khaliqgant

Copy link
Copy Markdown
MemberAuthor

@cubic-dev-ai P2 addressed in `e0a2b0f` — correct, and a fair hit.

The test asserted a claim its own name made twice and only proved once: it captured relay.dm output but never the Slack side, so removing presentJson from the Slack actor left it green. That is precisely the regression it existed to catch.

It now drives handleSlackMessage too, and asserts the Slack reply is threaded, fenced, and carries byte-for-byte the same document relay received — so the transport split is pinned from both ends rather than one.

Verified by ablation rather than assertion: deleting presentJson: presentJsonForSlack from the Slack actor now fails it (not ok 47), and restoring it passes. 50 tests in this file, 326 in the full suite, 0 failures.

Worth noting this is the third leaf-only test gap caught by review on this branch and its siblings today, all the same shape: a helper tested directly while the wiring that reaches it went unasserted. I've started ablating rather than trusting green.

@cubic-dev-ai

Copy link
Copy Markdown

@cubic-dev-ai P2 addressed in `e0a2b0f` — correct, and a fair hit.

The test asserted a claim its own name made twice and only proved once: it captured relay.dm output but never the Slack side, so removing presentJson from the Slack actor left it green. That is precisely the regression it existed to catch.

It now drives handleSlackMessage too, and asserts the Slack reply is threaded, fenced, and carries byte-for-byte the same document relay received — so the transport split is pinned from both ends rather than one.
...

@khaliqgant Agreed, that's a fair hit and e0a2b0f closes it properly. The old test only pinned the relay half of "relay gets raw JSON while Slack gets fenced" — the name promised both, the assertion proved one, and removing presentJson from the Slack actor slid right through green. That's exactly the leaf-only gap you describe.

The fix lands the right way: driving handleSlackMessage through the real handler and asserting the Slack reply is threaded, fenced, and byte-for-byte the same document relay received pins the transport split from both ends. Ablation-verified too, which is the honest way to prove a test guards a regression — deleting presentJson now fails, restoring passes. Nothing to add on the diff itself.

The pattern you're calling out is worth a habit, not just a patch: a test that names two sides and asserts one is a latent hole. Your "ablate rather than trust green" instinct is the right correction for this branch's whole cluster of same-shaped gaps.

Tip: get faster answers by chatting with cubic’s review copilot in the review UI

@cubic-dev-aicubic-dev-aiBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1 issue found across 2 files (changes from recent commits).

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="askable-gtm/agent.ts">
<violation number="1" location="askable-gtm/agent.ts:1510">
P3: When a post has a non-BMP character at the excerpt boundary, `truncateEvidence` splits its surrogate pair and sends malformed text to Slack. Count and slice Unicode code points instead of UTF-16 code units.</violation>
</file>

Tip: Review your code locally with the cubic CLI to iterate faster.

Re-trigger cubic

Comment threadaskable-gtm/agent.ts
Comment on lines +1510 to +1517
export function truncateEvidence(value: string, max = EVIDENCE_EXCERPT_MAX): string {
const single = oneLine(value);
if (single.length <= max) return single;
const clipped = single.slice(0, max);
const lastSpace = clipped.lastIndexOf(' ');
// Only honour the word boundary if it does not gut the excerpt.
const body = lastSpace > max * 0.6 ? clipped.slice(0, lastSpace) : clipped;
return `${body.replace(/[\s.,;:!?—-]+$/u, '')}…`;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: When a post has a non-BMP character at the excerpt boundary, truncateEvidence splits its surrogate pair and sends malformed text to Slack. Count and slice Unicode code points instead of UTF-16 code units.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At askable-gtm/agent.ts, line 1510:
<comment>When a post has a non-BMP character at the excerpt boundary, `truncateEvidence` splits its surrogate pair and sends malformed text to Slack. Count and slice Unicode code points instead of UTF-16 code units.</comment>
<file context>
@@ -1488,6 +1499,24 @@ function oneLine(value: string): string {
+ * to a source, not a copy of it: pasting five full LinkedIn posts into a Slack
+ * thread buries the very signal the answer exists to surface.
+ */
+export function truncateEvidence(value: string, max = EVIDENCE_EXCERPT_MAX): string {
+ const single = oneLine(value);
+ if (single.length <= max) return single;
</file context>
Suggested change
exportfunctiontruncateEvidence(value: string,max=EVIDENCE_EXCERPT_MAX): string{
constsingle=oneLine(value);
if(single.length<=max)returnsingle;
constclipped=single.slice(0,max);
constlastSpace=clipped.lastIndexOf(' ');
// Only honour the word boundary if it does not gut the excerpt.
constbody=lastSpace>max*0.6 ? clipped.slice(0,lastSpace) : clipped;
return`${body.replace(/[\s.,;:!?-]+$/u,'')}…`;
exportfunctiontruncateEvidence(value: string,max=EVIDENCE_EXCERPT_MAX): string{
constcharacters=[...oneLine(value)];
if(characters.length<=max)returncharacters.join('');
constclipped=characters.slice(0,max);
constlastSpace=clipped.lastIndexOf(' ');
// Only honour the word boundary if it does not gut the excerpt.
constbody=lastSpace>max*0.6
? clipped.slice(0,lastSpace).join('')
: clipped.join('');
return`${body.replace(/[\s.,;:!?-]+$/u,'')}…`;
}

Four lines of ceremony sat above every result: the question restated, the fetch
timestamp and raw coverage map, the credential/endpoint disclosure, and a
standing caveat about inference. All of it was either something the reader had
just typed or something they could see, and it pushed the actual findings
below the fold.
Two things survive the trim, because dropping them makes the answer dishonest
rather than merely terse:
- a missing source, now stated as a compact `(reddit unavailable this
request)` rather than a sentence. Returning LinkedIn-only results while
Reddit is 403 and saying nothing presents a partial view as a complete one,
which is the failure this persona exists to avoid.
- the access disclosure, but only when the credential is NOT the user's own.
The manifest marks managed access `disclosureRequired` because it is
metered and billable, and `unknown` discloses too — the gateway did not say
which path it used, and staying quiet about a possible charge is the wrong
way to be wrong.
Same answer as the screenshot that prompted this: ~4,300 chars → ~680.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SQpuAYyuSG3MWSZYnw5o1x

@cubic-dev-aicubic-dev-aiBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 2 files (changes from recent commits).

Tip: Review your code locally with the cubic CLI to iterate faster.

Re-trigger cubic

Comment threadaskable-gtm/agent.ts
Dropping the query header was right for the interactive path — the reader just
typed the question — but the renderer serves two callers, and I only reasoned
about one. `runWatchSweep` sends the same output as an unsolicited DM that can
land hours later beside other watches, so with the header gone the user could
not tell which saved query fired, or which id to unwatch.
The header belongs at the delivery site rather than in the renderer: the sweep
knows it is delivering unsolicited, the interactive path knows it is not.
Compact by design — query, cadence, id — so it identifies the update without
reintroducing the preamble that buried the results.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SQpuAYyuSG3MWSZYnw5o1x
@khaliqgant

Copy link
Copy Markdown
MemberAuthor

@cubic-dev-ai P2 addressed in db3b1e6 — correct, and a gap in my reasoning rather than a detail.

Dropping the query header was right for the interactive path (the reader just typed the question), but renderListenAnswer serves two callers and I only reasoned about one. runWatchSweep sends the same output as an unsolicited DM that can arrive hours later beside other watches — with the header gone, there was nothing to say which saved query fired or which id to unwatch.

Fixed at the delivery site rather than in the renderer, since that is where the distinction actually lives: the sweep knows it is delivering unsolicited, the interactive path knows it is not. The label is compact by design — New for “…” · every 6h · <id> — so it identifies the update without reintroducing the four lines of preamble that buried the results in the first place.

New test asserts both halves: a swept delivery carries query, cadence and id and is followed immediately by results; the interactive answer stays bare. 51 tests in this file, 327 in the full suite, 0 failures.

@cubic-dev-ai

Copy link
Copy Markdown

@cubic-dev-ai P2 addressed in db3b1e6 — correct, and a gap in my reasoning rather than a detail.

Dropping the query header was right for the interactive path (the reader just typed the question), but renderListenAnswer serves two callers and I only reasoned about one. runWatchSweep sends the same output as an unsolicited DM that can arrive hours later beside other watches — with the header gone, there was nothing to say which saved query fired or which id to unwatch.

Fixed at the delivery site rather than in the renderer, since that is where the distinction actually lives: the sweep knows it is delivering unsolicited, the interactive path knows it is not. The label is compact by design — New for “…” · every 6h · <id> — so it identifies the update without reintroducing the four lines of preamble that buried the results in the first place.
...

@khaliqgant Confirmed — db3b1e6 lands that one where it belongs. Putting the identifier at the delivery site is the right containment: the sweep is the only caller that knows it's unsolicited, so coupling it to renderListenAnswer (which serves the interactive path too) would have dragged the ceremony back into every answer. Keeping the renderer bare and letting runWatchSweep prepend renderWatchHeader keeps each path honest about its own context.

The shape reads well against the diff:

  • renderWatchHeader stays compact — query, cadence, id — so it names the watch without resurrecting the four-line preamble that motivated the trim.
  • Header is only prepended on fresh deliveries (fresh.length > 0), which is correct: a sweep with nothing new shouldn't announce itself.
  • renderListenAnswer dropping the query restatement (now _query) is safe for the interactive path, and the new test pins both halves — swept delivery carries query/cadence/id followed immediately by results, interactive stays bare.

Good distinction between "who is reading this" and "does this renderer know that" — the sweep knows, the renderer doesn't, so the label lives at the delivery point. Nothing to add.

Tip: get faster answers by chatting with cubic’s review copilot in the review UI

@khaliqgant
khaliqgant merged commit 2febc91 into mainSep 1, 2026
3 checks passed
@khaliqgant
khaliqgant deleted the fix/askable-gtm-slack-reply-ux branch September 1, 2026 11:28
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@khaliqgant
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length \u003e 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix(slack): answer in a thread, and make the manifest readable - #121

Merged
khaliqgant merged 6 commits into
mainfrom
fix/askable-gtm-slack-reply-ux
Sep 1, 2026
Merged

fix(slack): answer in a thread, and make the manifest readable#121
khaliqgant merged 6 commits into
mainfrom
fix/askable-gtm-slack-reply-ux

Conversation

@khaliqgant

@khaliqgantkhaliqgant commented Sep 1, 2026

Copy link
Copy Markdown
Member

Both problems observed on the first live @mention after cloud#3231 deployed.

1. Replies went to the channel, not a thread

postReply only threaded when the incoming message was already in one:

constresult=msg.threadTs
? awaitslack.reply(chanId,msg.threadTs,text)
: awaitslack.post(chanId,text);// ← top-level mention dumps into the channel

That is worst exactly where agents are useful. Every agent shares one Slack identity, so a single @Agent Relay … wakes every agent watching that channel — the production dispatch for one message matched 8 deployments — and they all replied into the main channel. Now the reply threads under the incoming message, and stays in the existing thread when there is one.

2. capabilities --json was an unreadable wall

~4KB of minified JSON posted as plain text. Now pretty-printed inside a ```json fence and led by one line naming the agent and pointing at plain-language questions — nobody should have to parse JSON to learn they can just ask.

Blast radius, deliberately

The threading change is in shared/slack.ts, so it applies to every agent using that helper (hn-monitor, joke-bot, inbox-buddy, review, …). That is the intent: an agent answering a channel mention should thread. Calling it out because it is broader than the askable-gtm title suggests.

The existing test asserting a channel-level post encoded the old behaviour; it now asserts the thread, with the reasoning in a comment so it is not "fixed" back later.

Validation

  • npm run typecheck: pass
  • tests/askable-gtm.test.mjs: 46 pass, 2 new — fenced/parseable manifest, and an existing thread answered in that thread rather than a new one
  • full suite: 322 pass / 0 fail

Not fixed here

One @Agent Relay mention still wakes every agent watching the channel, because they share a Slack identity — cloud#3234 gates on the bot being mentioned, which does not disambiguate which agent is being addressed. Threading makes that liveable, not solved. Worth a follow-up.

🤖 Generated with Claude Code


Summary by cubic

Fixes four problems from the first live @mention: replies thread under the message instead of landing in the channel, capabilities --json stays parseable for machines while Slack gets a readable fenced view, cited evidence is an excerpt rather than a whole post, and answers lead with results instead of a preamble.

  • Replies thread under the incoming message, and replies to a message already in a thread stay in that thread.
  • Threading is opt-in via startThread in postReply/conversationKeyForSlack; only askable-gtm enables it because it keeps no cross-turn Slack context.
  • capabilities --json stays standalone parseable JSON over relay; the Slack actor fences and pretty-prints it via a new presentJson hook.
  • Evidence lines are excerpted to 180 characters on a word boundary, and LinkedIn rows are attributed by author handle.
  • Answers drop the question restatement and coverage preamble, keeping only a compact source-failure note and the disclosure for metered managed access.
  • Unsolicited watch deliveries get a compact header naming the query, cadence, and watch id so the reader knows which one fired and can unwatch it.
  • Tests drive both transport halves through the real handlers, so the relay/Slack split can't silently regress.
  • One @Agent Relay mention still wakes every agent watching the channel since they share a Slack identity; threading makes that liveable, not solved.

Written for commit 55f8d85. Summary will update on new commits.

Review in cubic

Two problems seen in production on the first live @mention.
Replies landed in the channel, not a thread. `postReply` only threaded when the
incoming message was ALREADY in one, so a top-level `@Agent Relay …` got a
channel-level answer. That is worst exactly where agents are useful: every
agent shares one Slack identity, so one mention draws a reply from each agent
watching the channel and they all pile into the main channel. Now the reply
threads under the incoming message, and stays in the existing thread when there
is one.
`capabilities --json` dumped ~4KB of minified JSON as plain text, which Slack
renders as an unreadable wall. Pretty-print it inside a fenced block and lead
with one line naming the agent and saying you can just ask a question in plain
language — nobody should have to parse JSON to discover that.
The threading change is in shared/slack.ts, so it applies to every agent using
that helper. That is intended: an agent answering a channel mention should
thread. The existing test asserting a channel-level `post` encoded the old
behaviour and now asserts the thread.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SQpuAYyuSG3MWSZYnw5o1x
@chatgpt-codex-connector

chatgpt-codex-connectorBot commented Sep 1, 2026

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

ReviewStatusCommitReview trigger
📝 Code ReviewCompleted2026-09-01T10:45:10.460193Z8d65ea9Manual request
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@coderabbitai

coderabbitaiBot commented Sep 1, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

Next included review available in 11 minutes.

Check out review usage here.

View limit details

Limit details: You’ve used the included review currently available.

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

Learn how review limits work.

Review configuration:

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Team

Run ID: 91a9e59a-20ff-4bc8-ab8c-a74c68244ed7

📥 Commits

Reviewing files that changed from the base of the PR and between 2cf284a and 55f8d85.

📒 Files selected for processing (3)
  • askable-gtm/agent.ts
  • shared/slack.ts
  • tests/askable-gtm.test.mjs

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Team

Run ID: d0a8e943-72fc-4d71-bfc9-327e0382122a

📥 Commits

Reviewing files that changed from the base of the PR and between 82d88be and 2cf284a.

📒 Files selected for processing (3)
  • askable-gtm/agent.ts
  • shared/slack.ts
  • tests/askable-gtm.test.mjs

Included review availability: Your plan provides up to 1 included review per hour; 0 remain after this review.


📝 Walkthrough

Walkthrough

The capabilities command now delegates formatted JSON output to an exported renderer. Slack replies now always use a thread root, including for top-level messages.

Changes

Capabilities JSON rendering

Layer / File(s)Summary
Render and validate capability manifests
askable-gtm/agent.ts, tests/askable-gtm.test.mjs
The command uses renderCapabilitiesJson. The helper emits explanatory text, fenced pretty-printed JSON, and runtime access status. Tests validate the output.

Slack threaded replies

Layer / File(s)Summary
Route replies to message threads
shared/slack.ts, tests/askable-gtm.test.mjs
postReply uses the incoming timestamp for top-level messages and preserves existing thread timestamps. Tests cover both reply paths.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Merge Risk:⚪ Minimal · up to 2cf28

Replies now stay under the relevant Slack message and capability output is easier to read without changing its content; no actionable merge-blocking risk remains after normal checks and review.

Poem

A rabbit formats JSON bright,
With fences neat and spaces right.
Slack hops into threads anew,
Keeping every reply in view.
Two tidy paths now serve the queue.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check nameStatusExplanationResolution
Docstring Coverage⚠️ WarningDocstring coverage is 40.00% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 5 functions across 3 files.Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check nameStatusExplanation
Title check✅ PassedThe title clearly summarizes both primary changes: threaded Slack replies and readable capability manifests.
Description check✅ PassedThe description directly explains the Slack threading change, manifest formatting change, scope, tests, validation, and known limitation.
Linked Issues check✅ PassedCheck skipped because no linked issues were found for this pull request.
Out of Scope Changes check✅ PassedCheck skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/askable-gtm-slack-reply-ux

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@chatgpt-codex-connectorchatgpt-codex-connectorBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit:2cf284a9cc

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment threadshared/slack.ts Outdated
Comment on lines +138 to +140
const threadTs = msg.threadTs ?? msg.ts;
const result = threadTs
? await slack.reply(chanId, threadTs, text)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve the conversation key when starting a reply thread

When Inbox Buddy handles a top-level message, conversationKeyForSlack stores that turn under the channel key, but this change posts the answer beneath msg.ts; a user's natural follow-up in that new thread then arrives with threadTs and is loaded under channel:threadTs. Consequently, the initial question and answer are absent from the follow-up prompt, breaking the multi-turn continuity implemented in inbox-buddy/agent.ts. Ensure the initial turn and its resulting thread use the same conversation key.

Useful? React with 👍 / 👎.

Comment threadaskable-gtm/agent.ts
Comment on lines +1007 to +1010
'GTM Signal Scout — machine-readable capability manifest.',
'You can also just ask a GTM question in plain language, or send'
+ ' \u201cwhat can you tell me?\u201d for the short version.',
'```json',

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Keep relay capability responses valid JSON

When an agent or catalog invokes capabilities --json over Relay, handleRelayMessage now returns these prose and fence lines around the payload, so parsing the response directly with JSON.parse fails. This affects Relay as well as Slack because both transports share handleInteractiveCommand, despite the documented contract in ASKABLE_AGENTS.md describing this command as a machine-readable relay response; apply Slack-only presentation formatting or leave the relay response as standalone JSON.

Useful? React with 👍 / 👎.

@cubic-dev-aicubic-dev-aiBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1 issue found across 3 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="shared/slack.ts">
<violation number="1" location="shared/slack.ts:138">
P3: The `postReply` JSDoc now understates the behavior: this line also threads top-level messages under their own timestamp. Update the docstring to describe both top-level and existing-thread replies.</violation>
</file>

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment threadaskable-gtm/agent.ts Outdated
Comment threadshared/slack.ts Outdated
Comment threadshared/slack.ts Outdated
// buries the conversation in the main channel, which is worst exactly where
// agents are useful — several agents share one Slack identity, so a single
// `@Agent Relay` mention can draw a reply from each of them.
const threadTs = msg.threadTs ?? msg.ts;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: The postReply JSDoc now understates the behavior: this line also threads top-level messages under their own timestamp. Update the docstring to describe both top-level and existing-thread replies.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At shared/slack.ts, line 138:
<comment>The `postReply` JSDoc now understates the behavior: this line also threads top-level messages under their own timestamp. Update the docstring to describe both top-level and existing-thread replies.</comment>
<file context>
@@ -130,10 +130,16 @@ export async function postReply(
+ // buries the conversation in the main channel, which is worst exactly where
+ // agents are useful — several agents share one Slack identity, so a single
+ // `@Agent Relay` mention can draw a reply from each of them.
+ const threadTs = msg.threadTs ?? msg.ts;
+ const result = threadTs
+ ? await slack.reply(chanId, threadTs, text)
</file context>

Both P1s from review, each flagged independently by both reviewers.
Threading was applied in shared/slack.ts for every agent, which would have
broken inbox-buddy's multi-turn context: `conversationKeyForSlack` keys a
top-level message on the CHANNEL, so answering it in a new thread stranded the
opening turn under `channel` while the follow-up loaded under `channel:ts` —
the prompt would lose the question it was answering.
Threading is now opt-in via `postReply(..., { startThread: true })`, and
`conversationKeyForSlack` takes the same option so an agent that opts in keys
continuity on the thread it created. Every existing caller passes nothing and
is byte-for-byte unchanged; only askable-gtm opts in, and it keeps no
cross-turn Slack context so moving the conversation unit costs nothing.
`capabilities --json` is documented in ASKABLE_AGENTS.md as a MACHINE-readable
relay response, and both transports share `handleInteractiveCommand` — so
fencing the payload broke `JSON.parse` for any agent or catalog calling it over
relay. The payload is standalone JSON again; presentation moved to the
transport via an optional `presentJson` hook that only the Slack actor supplies.
Tests pin both: relay receives raw parseable JSON with no fences, Slack
receives the fenced form whose payload still round-trips, and an opted-in
top-level mention threads under its own ts.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SQpuAYyuSG3MWSZYnw5o1x
@khaliqgant

Copy link
Copy Markdown
MemberAuthor

Both P1s addressed in 8d65ea9. Both were correct, and the first would have shipped a real regression.

P1 — "Preserve the conversation key when starting a reply thread" (@chatgpt-codex-connector, @cubic-dev-ai)

Correct. conversationKeyForSlack keys a top-level message on the channel, so threading it in shared/slack.ts for every agent would have stranded inbox-buddy's opening turn under channel while the follow-up loaded under channel:threadTs — the prompt would lose the question it was answering. Exactly the blast radius I flagged in the description and then didn't actually contain.

Rather than patch the key, threading is now opt-in: postReply(..., { startThread: true }), with conversationKeyForSlack taking the same option so an agent that opts in keys continuity on the thread it created. Every existing caller passes nothing and is byte-for-byte unchanged — inbox-buddy included. Only askable-gtm opts in, and it keeps no cross-turn Slack context, so moving its conversation unit to the thread costs nothing.

P1 — "Keep relay capability responses valid JSON" (@chatgpt-codex-connector, @cubic-dev-ai)

Correct, and I should have caught it: ASKABLE_AGENTS.md documents this command as a machine-readable relay response, and the manifest's own discovery.machine field advertises it. Both transports share handleInteractiveCommand, so fencing the payload broke JSON.parse for any agent or catalog calling it.

renderCapabilitiesJson returns standalone parseable JSON again. Presentation moved to the transport via an optional presentJson hook that only the Slack actor supplies — the human surface fences, the machine surface does not. I took the shape of @cubic-dev-ai's suggestion rather than the literal diff, so Slack keeps the readable rendering that motivated the PR.

P3 — postReply JSDoc understates the behavior

Fixed; the docstring now describes both cases and says why threading is opt-in rather than default.

Validation: 48 tests in askable-gtm.test.mjs (3 new — relay gets raw fence-free JSON, Slack's fenced payload round-trips, an existing thread stays in that thread), full suite 324 pass / 0 fail, typecheck clean.

@codex review

@cubic-dev-ai

Copy link
Copy Markdown

Both P1s addressed in 8d65ea9. Both were correct, and the first would have shipped a real regression.

P1 — "Preserve the conversation key when starting a reply thread" (@chatgpt-codex-connector, @cubic-dev-ai)

Correct. conversationKeyForSlack keys a top-level message on the channel, so threading it in shared/slack.ts for every agent would have stranded inbox-buddy's opening turn under channel while the follow-up loaded under channel:threadTs — the prompt would lose the question it was answering. Exactly the blast radius I flagged in the description and then didn't actually contain.
...

@khaliqgant Both P1s check out against the diff — good fixes.

P1 #1 (conversation key / opt-in threading). The shape you chose is right. Making startThread opt-in in postReply and mirroring the same option in conversationKeyForSlack closes the regression cleanly: every existing caller (inbox-buddy included) passes nothing and keeps keying top-level continuity on the channel, while askable-gtm, which keeps no cross-turn Slack context, is the only opt-in and loses nothing by moving its unit to the thread. The threadTs = msg.threadTs ?? (options.startThread ? msg.ts : undefined) logic and matching key derivation also keep the "reply stays in an existing thread" path untouched. That's the right containment — the blast radius you flagged in the description is now explicit rather than accidental.

P1 #2 (relay JSON). Correct call. Fencing in handleInteractiveCommand (shared by both transports) would have broken any agent or catalog doing JSON.parse over relay — and worse, it would break silently, since a ```json prefix parses without a hard throw depending on the caller. Moving presentation behind the optional presentJson hook so only the Slack actor fences, while renderCapabilitiesJson stays standalone parseable JSON, is the right separation. The relay test asserting no fences plus the Slack round-trip test pin exactly the contract that was at risk.

P3 (JSDoc). The updated docstring on postReply/SlackReplyOptions now describes both the thread-stays case and why top-level threading is opt-in rather than default — that was the gap.

One small thing worth a second glance: presentJsonForSlack returns the modifier text outside the fence. If a future caller feeds that string back into something that tries to parse it as the payload, the lead line would trip it — but that's presentation-only and the tests correctly round-trip only the fenced slice, so it's fine as-is. No changes needed.

Nice catch on both — the threading one especially would have shipped a real inbox-buddy regression.

Tip: get faster answers by chatting with cubic’s review copilot in the review UI

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. 🚀

Reviewed commit:8d65ea9914

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@cubic-dev-aicubic-dev-aiBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 3 files (changes from recent commits).

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment threadtests/askable-gtm.test.mjs
khaliqgantand others added 2 commits September 1, 2026 12:54
The first live answer in Slack was a ~4,300-character wall. Reddit rows carry a
`title`, LinkedIn rows do not, so the renderer fell back to `body_text` — and
pasted five entire LinkedIn posts into one thread. `oneLine` collapses
whitespace but never truncates.
A citation is a pointer to a source, not a copy of it. Excerpt to 180
characters on a word boundary, and let the link carry the rest. The same answer
is now ~1,200 characters and scannable.
Also attribute LinkedIn rows by public author. Reddit rows show their
community; LinkedIn has none, so those lines carried no indication of who was
speaking — while `author.handle` was already parsed and is evidence the
capability manifest promises to surface.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SQpuAYyuSG3MWSZYnw5o1x
The test named "relay receives raw JSON while Slack receives the fenced form"
only asserted the relay half. Removing `presentJson` from the Slack actor left
it green — the exact regression it was written to catch.
Now drives `handleSlackMessage` as well and asserts the reply is threaded,
fenced, and carries byte-for-byte the same document relay received. Verified by
ablation: deleting `presentJson: presentJsonForSlack` fails this test.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SQpuAYyuSG3MWSZYnw5o1x
@khaliqgant

Copy link
Copy Markdown
MemberAuthor

@cubic-dev-ai P2 addressed in `e0a2b0f` — correct, and a fair hit.

The test asserted a claim its own name made twice and only proved once: it captured relay.dm output but never the Slack side, so removing presentJson from the Slack actor left it green. That is precisely the regression it existed to catch.

It now drives handleSlackMessage too, and asserts the Slack reply is threaded, fenced, and carries byte-for-byte the same document relay received — so the transport split is pinned from both ends rather than one.

Verified by ablation rather than assertion: deleting presentJson: presentJsonForSlack from the Slack actor now fails it (not ok 47), and restoring it passes. 50 tests in this file, 326 in the full suite, 0 failures.

Worth noting this is the third leaf-only test gap caught by review on this branch and its siblings today, all the same shape: a helper tested directly while the wiring that reaches it went unasserted. I've started ablating rather than trusting green.

@cubic-dev-ai

Copy link
Copy Markdown

@cubic-dev-ai P2 addressed in `e0a2b0f` — correct, and a fair hit.

The test asserted a claim its own name made twice and only proved once: it captured relay.dm output but never the Slack side, so removing presentJson from the Slack actor left it green. That is precisely the regression it existed to catch.

It now drives handleSlackMessage too, and asserts the Slack reply is threaded, fenced, and carries byte-for-byte the same document relay received — so the transport split is pinned from both ends rather than one.
...

@khaliqgant Agreed, that's a fair hit and e0a2b0f closes it properly. The old test only pinned the relay half of "relay gets raw JSON while Slack gets fenced" — the name promised both, the assertion proved one, and removing presentJson from the Slack actor slid right through green. That's exactly the leaf-only gap you describe.

The fix lands the right way: driving handleSlackMessage through the real handler and asserting the Slack reply is threaded, fenced, and byte-for-byte the same document relay received pins the transport split from both ends. Ablation-verified too, which is the honest way to prove a test guards a regression — deleting presentJson now fails, restoring passes. Nothing to add on the diff itself.

The pattern you're calling out is worth a habit, not just a patch: a test that names two sides and asserts one is a latent hole. Your "ablate rather than trust green" instinct is the right correction for this branch's whole cluster of same-shaped gaps.

Tip: get faster answers by chatting with cubic’s review copilot in the review UI

@cubic-dev-aicubic-dev-aiBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1 issue found across 2 files (changes from recent commits).

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="askable-gtm/agent.ts">
<violation number="1" location="askable-gtm/agent.ts:1510">
P3: When a post has a non-BMP character at the excerpt boundary, `truncateEvidence` splits its surrogate pair and sends malformed text to Slack. Count and slice Unicode code points instead of UTF-16 code units.</violation>
</file>

Tip: Review your code locally with the cubic CLI to iterate faster.

Re-trigger cubic

Comment threadaskable-gtm/agent.ts
Comment on lines +1510 to +1517
export function truncateEvidence(value: string, max = EVIDENCE_EXCERPT_MAX): string {
const single = oneLine(value);
if (single.length <= max) return single;
const clipped = single.slice(0, max);
const lastSpace = clipped.lastIndexOf(' ');
// Only honour the word boundary if it does not gut the excerpt.
const body = lastSpace > max * 0.6 ? clipped.slice(0, lastSpace) : clipped;
return `${body.replace(/[\s.,;:!?—-]+$/u, '')}…`;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: When a post has a non-BMP character at the excerpt boundary, truncateEvidence splits its surrogate pair and sends malformed text to Slack. Count and slice Unicode code points instead of UTF-16 code units.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At askable-gtm/agent.ts, line 1510:
<comment>When a post has a non-BMP character at the excerpt boundary, `truncateEvidence` splits its surrogate pair and sends malformed text to Slack. Count and slice Unicode code points instead of UTF-16 code units.</comment>
<file context>
@@ -1488,6 +1499,24 @@ function oneLine(value: string): string {
+ * to a source, not a copy of it: pasting five full LinkedIn posts into a Slack
+ * thread buries the very signal the answer exists to surface.
+ */
+export function truncateEvidence(value: string, max = EVIDENCE_EXCERPT_MAX): string {
+ const single = oneLine(value);
+ if (single.length <= max) return single;
</file context>
Suggested change
exportfunctiontruncateEvidence(value: string,max=EVIDENCE_EXCERPT_MAX): string{
constsingle=oneLine(value);
if(single.length<=max)returnsingle;
constclipped=single.slice(0,max);
constlastSpace=clipped.lastIndexOf(' ');
// Only honour the word boundary if it does not gut the excerpt.
constbody=lastSpace>max*0.6 ? clipped.slice(0,lastSpace) : clipped;
return`${body.replace(/[\s.,;:!?-]+$/u,'')}…`;
exportfunctiontruncateEvidence(value: string,max=EVIDENCE_EXCERPT_MAX): string{
constcharacters=[...oneLine(value)];
if(characters.length<=max)returncharacters.join('');
constclipped=characters.slice(0,max);
constlastSpace=clipped.lastIndexOf(' ');
// Only honour the word boundary if it does not gut the excerpt.
constbody=lastSpace>max*0.6
? clipped.slice(0,lastSpace).join('')
: clipped.join('');
return`${body.replace(/[\s.,;:!?-]+$/u,'')}…`;
}

Four lines of ceremony sat above every result: the question restated, the fetch
timestamp and raw coverage map, the credential/endpoint disclosure, and a
standing caveat about inference. All of it was either something the reader had
just typed or something they could see, and it pushed the actual findings
below the fold.
Two things survive the trim, because dropping them makes the answer dishonest
rather than merely terse:
- a missing source, now stated as a compact `(reddit unavailable this
request)` rather than a sentence. Returning LinkedIn-only results while
Reddit is 403 and saying nothing presents a partial view as a complete one,
which is the failure this persona exists to avoid.
- the access disclosure, but only when the credential is NOT the user's own.
The manifest marks managed access `disclosureRequired` because it is
metered and billable, and `unknown` discloses too — the gateway did not say
which path it used, and staying quiet about a possible charge is the wrong
way to be wrong.
Same answer as the screenshot that prompted this: ~4,300 chars → ~680.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SQpuAYyuSG3MWSZYnw5o1x

@cubic-dev-aicubic-dev-aiBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 2 files (changes from recent commits).

Tip: Review your code locally with the cubic CLI to iterate faster.

Re-trigger cubic

Comment threadaskable-gtm/agent.ts
Dropping the query header was right for the interactive path — the reader just
typed the question — but the renderer serves two callers, and I only reasoned
about one. `runWatchSweep` sends the same output as an unsolicited DM that can
land hours later beside other watches, so with the header gone the user could
not tell which saved query fired, or which id to unwatch.
The header belongs at the delivery site rather than in the renderer: the sweep
knows it is delivering unsolicited, the interactive path knows it is not.
Compact by design — query, cadence, id — so it identifies the update without
reintroducing the preamble that buried the results.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SQpuAYyuSG3MWSZYnw5o1x
@khaliqgant

Copy link
Copy Markdown
MemberAuthor

@cubic-dev-ai P2 addressed in db3b1e6 — correct, and a gap in my reasoning rather than a detail.

Dropping the query header was right for the interactive path (the reader just typed the question), but renderListenAnswer serves two callers and I only reasoned about one. runWatchSweep sends the same output as an unsolicited DM that can arrive hours later beside other watches — with the header gone, there was nothing to say which saved query fired or which id to unwatch.

Fixed at the delivery site rather than in the renderer, since that is where the distinction actually lives: the sweep knows it is delivering unsolicited, the interactive path knows it is not. The label is compact by design — New for “…” · every 6h · <id> — so it identifies the update without reintroducing the four lines of preamble that buried the results in the first place.

New test asserts both halves: a swept delivery carries query, cadence and id and is followed immediately by results; the interactive answer stays bare. 51 tests in this file, 327 in the full suite, 0 failures.

@cubic-dev-ai

Copy link
Copy Markdown

@cubic-dev-ai P2 addressed in db3b1e6 — correct, and a gap in my reasoning rather than a detail.

Dropping the query header was right for the interactive path (the reader just typed the question), but renderListenAnswer serves two callers and I only reasoned about one. runWatchSweep sends the same output as an unsolicited DM that can arrive hours later beside other watches — with the header gone, there was nothing to say which saved query fired or which id to unwatch.

Fixed at the delivery site rather than in the renderer, since that is where the distinction actually lives: the sweep knows it is delivering unsolicited, the interactive path knows it is not. The label is compact by design — New for “…” · every 6h · <id> — so it identifies the update without reintroducing the four lines of preamble that buried the results in the first place.
...

@khaliqgant Confirmed — db3b1e6 lands that one where it belongs. Putting the identifier at the delivery site is the right containment: the sweep is the only caller that knows it's unsolicited, so coupling it to renderListenAnswer (which serves the interactive path too) would have dragged the ceremony back into every answer. Keeping the renderer bare and letting runWatchSweep prepend renderWatchHeader keeps each path honest about its own context.

The shape reads well against the diff:

  • renderWatchHeader stays compact — query, cadence, id — so it names the watch without resurrecting the four-line preamble that motivated the trim.
  • Header is only prepended on fresh deliveries (fresh.length > 0), which is correct: a sweep with nothing new shouldn't announce itself.
  • renderListenAnswer dropping the query restatement (now _query) is safe for the interactive path, and the new test pins both halves — swept delivery carries query/cadence/id followed immediately by results, interactive stays bare.

Good distinction between "who is reading this" and "does this renderer know that" — the sweep knows, the renderer doesn't, so the label lives at the delivery point. Nothing to add.

Tip: get faster answers by chatting with cubic’s review copilot in the review UI

@khaliqgant
khaliqgant merged commit 2febc91 into mainSep 1, 2026
3 checks passed
@khaliqgant
khaliqgant deleted the fix/askable-gtm-slack-reply-ux branch September 1, 2026 11:28
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@khaliqgant
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

fix(slack): answer in a thread, and make the manifest readable - #121

Merged
khaliqgant merged 6 commits into
mainfrom
fix/askable-gtm-slack-reply-ux
Sep 1, 2026
Merged

fix(slack): answer in a thread, and make the manifest readable#121
khaliqgant merged 6 commits into
mainfrom
fix/askable-gtm-slack-reply-ux

Conversation

@khaliqgant

@khaliqgantkhaliqgant commented Sep 1, 2026

Copy link
Copy Markdown
Member

Both problems observed on the first live @mention after cloud#3231 deployed.

1. Replies went to the channel, not a thread

postReply only threaded when the incoming message was already in one:

constresult=msg.threadTs
? awaitslack.reply(chanId,msg.threadTs,text)
: awaitslack.post(chanId,text);// ← top-level mention dumps into the channel

That is worst exactly where agents are useful. Every agent shares one Slack identity, so a single @Agent Relay … wakes every agent watching that channel — the production dispatch for one message matched 8 deployments — and they all replied into the main channel. Now the reply threads under the incoming message, and stays in the existing thread when there is one.

2. capabilities --json was an unreadable wall

~4KB of minified JSON posted as plain text. Now pretty-printed inside a ```json fence and led by one line naming the agent and pointing at plain-language questions — nobody should have to parse JSON to learn they can just ask.

Blast radius, deliberately

The threading change is in shared/slack.ts, so it applies to every agent using that helper (hn-monitor, joke-bot, inbox-buddy, review, …). That is the intent: an agent answering a channel mention should thread. Calling it out because it is broader than the askable-gtm title suggests.

The existing test asserting a channel-level post encoded the old behaviour; it now asserts the thread, with the reasoning in a comment so it is not "fixed" back later.

Validation

  • npm run typecheck: pass
  • tests/askable-gtm.test.mjs: 46 pass, 2 new — fenced/parseable manifest, and an existing thread answered in that thread rather than a new one
  • full suite: 322 pass / 0 fail

Not fixed here

One @Agent Relay mention still wakes every agent watching the channel, because they share a Slack identity — cloud#3234 gates on the bot being mentioned, which does not disambiguate which agent is being addressed. Threading makes that liveable, not solved. Worth a follow-up.

🤖 Generated with Claude Code


Summary by cubic

Fixes four problems from the first live @mention: replies thread under the message instead of landing in the channel, capabilities --json stays parseable for machines while Slack gets a readable fenced view, cited evidence is an excerpt rather than a whole post, and answers lead with results instead of a preamble.

  • Replies thread under the incoming message, and replies to a message already in a thread stay in that thread.
  • Threading is opt-in via startThread in postReply/conversationKeyForSlack; only askable-gtm enables it because it keeps no cross-turn Slack context.
  • capabilities --json stays standalone parseable JSON over relay; the Slack actor fences and pretty-prints it via a new presentJson hook.
  • Evidence lines are excerpted to 180 characters on a word boundary, and LinkedIn rows are attributed by author handle.
  • Answers drop the question restatement and coverage preamble, keeping only a compact source-failure note and the disclosure for metered managed access.
  • Unsolicited watch deliveries get a compact header naming the query, cadence, and watch id so the reader knows which one fired and can unwatch it.
  • Tests drive both transport halves through the real handlers, so the relay/Slack split can't silently regress.
  • One @Agent Relay mention still wakes every agent watching the channel since they share a Slack identity; threading makes that liveable, not solved.

Written for commit 55f8d85. Summary will update on new commits.

Review in cubic

Two problems seen in production on the first live @mention.
Replies landed in the channel, not a thread. `postReply` only threaded when the
incoming message was ALREADY in one, so a top-level `@Agent Relay …` got a
channel-level answer. That is worst exactly where agents are useful: every
agent shares one Slack identity, so one mention draws a reply from each agent
watching the channel and they all pile into the main channel. Now the reply
threads under the incoming message, and stays in the existing thread when there
is one.
`capabilities --json` dumped ~4KB of minified JSON as plain text, which Slack
renders as an unreadable wall. Pretty-print it inside a fenced block and lead
with one line naming the agent and saying you can just ask a question in plain
language — nobody should have to parse JSON to discover that.
The threading change is in shared/slack.ts, so it applies to every agent using
that helper. That is intended: an agent answering a channel mention should
thread. The existing test asserting a channel-level `post` encoded the old
behaviour and now asserts the thread.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SQpuAYyuSG3MWSZYnw5o1x
@chatgpt-codex-connector

chatgpt-codex-connectorBot commented Sep 1, 2026

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

ReviewStatusCommitReview trigger
📝 Code ReviewCompleted2026-09-01T10:45:10.460193Z8d65ea9Manual request
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@coderabbitai

coderabbitaiBot commented Sep 1, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

Next included review available in 11 minutes.

Check out review usage here.

View limit details

Limit details: You’ve used the included review currently available.

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

Learn how review limits work.

Review configuration:

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Team

Run ID: 91a9e59a-20ff-4bc8-ab8c-a74c68244ed7

📥 Commits

Reviewing files that changed from the base of the PR and between 2cf284a and 55f8d85.

📒 Files selected for processing (3)
  • askable-gtm/agent.ts
  • shared/slack.ts
  • tests/askable-gtm.test.mjs

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Team

Run ID: d0a8e943-72fc-4d71-bfc9-327e0382122a

📥 Commits

Reviewing files that changed from the base of the PR and between 82d88be and 2cf284a.

📒 Files selected for processing (3)
  • askable-gtm/agent.ts
  • shared/slack.ts
  • tests/askable-gtm.test.mjs

Included review availability: Your plan provides up to 1 included review per hour; 0 remain after this review.


📝 Walkthrough

Walkthrough

The capabilities command now delegates formatted JSON output to an exported renderer. Slack replies now always use a thread root, including for top-level messages.

Changes

Capabilities JSON rendering

Layer / File(s)Summary
Render and validate capability manifests
askable-gtm/agent.ts, tests/askable-gtm.test.mjs
The command uses renderCapabilitiesJson. The helper emits explanatory text, fenced pretty-printed JSON, and runtime access status. Tests validate the output.

Slack threaded replies

Layer / File(s)Summary
Route replies to message threads
shared/slack.ts, tests/askable-gtm.test.mjs
postReply uses the incoming timestamp for top-level messages and preserves existing thread timestamps. Tests cover both reply paths.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Merge Risk:⚪ Minimal · up to 2cf28

Replies now stay under the relevant Slack message and capability output is easier to read without changing its content; no actionable merge-blocking risk remains after normal checks and review.

Poem

A rabbit formats JSON bright,
With fences neat and spaces right.
Slack hops into threads anew,
Keeping every reply in view.
Two tidy paths now serve the queue.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check nameStatusExplanationResolution
Docstring Coverage⚠️ WarningDocstring coverage is 40.00% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 5 functions across 3 files.Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check nameStatusExplanation
Title check✅ PassedThe title clearly summarizes both primary changes: threaded Slack replies and readable capability manifests.
Description check✅ PassedThe description directly explains the Slack threading change, manifest formatting change, scope, tests, validation, and known limitation.
Linked Issues check✅ PassedCheck skipped because no linked issues were found for this pull request.
Out of Scope Changes check✅ PassedCheck skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/askable-gtm-slack-reply-ux

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@chatgpt-codex-connectorchatgpt-codex-connectorBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit:2cf284a9cc

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment threadshared/slack.ts Outdated
Comment on lines +138 to +140
const threadTs = msg.threadTs ?? msg.ts;
const result = threadTs
? await slack.reply(chanId, threadTs, text)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve the conversation key when starting a reply thread

When Inbox Buddy handles a top-level message, conversationKeyForSlack stores that turn under the channel key, but this change posts the answer beneath msg.ts; a user's natural follow-up in that new thread then arrives with threadTs and is loaded under channel:threadTs. Consequently, the initial question and answer are absent from the follow-up prompt, breaking the multi-turn continuity implemented in inbox-buddy/agent.ts. Ensure the initial turn and its resulting thread use the same conversation key.

Useful? React with 👍 / 👎.

Comment threadaskable-gtm/agent.ts
Comment on lines +1007 to +1010
'GTM Signal Scout — machine-readable capability manifest.',
'You can also just ask a GTM question in plain language, or send'
+ ' \u201cwhat can you tell me?\u201d for the short version.',
'```json',

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Keep relay capability responses valid JSON

When an agent or catalog invokes capabilities --json over Relay, handleRelayMessage now returns these prose and fence lines around the payload, so parsing the response directly with JSON.parse fails. This affects Relay as well as Slack because both transports share handleInteractiveCommand, despite the documented contract in ASKABLE_AGENTS.md describing this command as a machine-readable relay response; apply Slack-only presentation formatting or leave the relay response as standalone JSON.

Useful? React with 👍 / 👎.

@cubic-dev-aicubic-dev-aiBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1 issue found across 3 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="shared/slack.ts">
<violation number="1" location="shared/slack.ts:138">
P3: The `postReply` JSDoc now understates the behavior: this line also threads top-level messages under their own timestamp. Update the docstring to describe both top-level and existing-thread replies.</violation>
</file>

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment threadaskable-gtm/agent.ts Outdated
Comment threadshared/slack.ts Outdated
Comment threadshared/slack.ts Outdated
// buries the conversation in the main channel, which is worst exactly where
// agents are useful — several agents share one Slack identity, so a single
// `@Agent Relay` mention can draw a reply from each of them.
const threadTs = msg.threadTs ?? msg.ts;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: The postReply JSDoc now understates the behavior: this line also threads top-level messages under their own timestamp. Update the docstring to describe both top-level and existing-thread replies.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At shared/slack.ts, line 138:
<comment>The `postReply` JSDoc now understates the behavior: this line also threads top-level messages under their own timestamp. Update the docstring to describe both top-level and existing-thread replies.</comment>
<file context>
@@ -130,10 +130,16 @@ export async function postReply(
+ // buries the conversation in the main channel, which is worst exactly where
+ // agents are useful — several agents share one Slack identity, so a single
+ // `@Agent Relay` mention can draw a reply from each of them.
+ const threadTs = msg.threadTs ?? msg.ts;
+ const result = threadTs
+ ? await slack.reply(chanId, threadTs, text)
</file context>

Both P1s from review, each flagged independently by both reviewers.
Threading was applied in shared/slack.ts for every agent, which would have
broken inbox-buddy's multi-turn context: `conversationKeyForSlack` keys a
top-level message on the CHANNEL, so answering it in a new thread stranded the
opening turn under `channel` while the follow-up loaded under `channel:ts` —
the prompt would lose the question it was answering.
Threading is now opt-in via `postReply(..., { startThread: true })`, and
`conversationKeyForSlack` takes the same option so an agent that opts in keys
continuity on the thread it created. Every existing caller passes nothing and
is byte-for-byte unchanged; only askable-gtm opts in, and it keeps no
cross-turn Slack context so moving the conversation unit costs nothing.
`capabilities --json` is documented in ASKABLE_AGENTS.md as a MACHINE-readable
relay response, and both transports share `handleInteractiveCommand` — so
fencing the payload broke `JSON.parse` for any agent or catalog calling it over
relay. The payload is standalone JSON again; presentation moved to the
transport via an optional `presentJson` hook that only the Slack actor supplies.
Tests pin both: relay receives raw parseable JSON with no fences, Slack
receives the fenced form whose payload still round-trips, and an opted-in
top-level mention threads under its own ts.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SQpuAYyuSG3MWSZYnw5o1x
@khaliqgant

Copy link
Copy Markdown
MemberAuthor

Both P1s addressed in 8d65ea9. Both were correct, and the first would have shipped a real regression.

P1 — "Preserve the conversation key when starting a reply thread" (@chatgpt-codex-connector, @cubic-dev-ai)

Correct. conversationKeyForSlack keys a top-level message on the channel, so threading it in shared/slack.ts for every agent would have stranded inbox-buddy's opening turn under channel while the follow-up loaded under channel:threadTs — the prompt would lose the question it was answering. Exactly the blast radius I flagged in the description and then didn't actually contain.

Rather than patch the key, threading is now opt-in: postReply(..., { startThread: true }), with conversationKeyForSlack taking the same option so an agent that opts in keys continuity on the thread it created. Every existing caller passes nothing and is byte-for-byte unchanged — inbox-buddy included. Only askable-gtm opts in, and it keeps no cross-turn Slack context, so moving its conversation unit to the thread costs nothing.

P1 — "Keep relay capability responses valid JSON" (@chatgpt-codex-connector, @cubic-dev-ai)

Correct, and I should have caught it: ASKABLE_AGENTS.md documents this command as a machine-readable relay response, and the manifest's own discovery.machine field advertises it. Both transports share handleInteractiveCommand, so fencing the payload broke JSON.parse for any agent or catalog calling it.

renderCapabilitiesJson returns standalone parseable JSON again. Presentation moved to the transport via an optional presentJson hook that only the Slack actor supplies — the human surface fences, the machine surface does not. I took the shape of @cubic-dev-ai's suggestion rather than the literal diff, so Slack keeps the readable rendering that motivated the PR.

P3 — postReply JSDoc understates the behavior

Fixed; the docstring now describes both cases and says why threading is opt-in rather than default.

Validation: 48 tests in askable-gtm.test.mjs (3 new — relay gets raw fence-free JSON, Slack's fenced payload round-trips, an existing thread stays in that thread), full suite 324 pass / 0 fail, typecheck clean.

@codex review

@cubic-dev-ai

Copy link
Copy Markdown

Both P1s addressed in 8d65ea9. Both were correct, and the first would have shipped a real regression.

P1 — "Preserve the conversation key when starting a reply thread" (@chatgpt-codex-connector, @cubic-dev-ai)

Correct. conversationKeyForSlack keys a top-level message on the channel, so threading it in shared/slack.ts for every agent would have stranded inbox-buddy's opening turn under channel while the follow-up loaded under channel:threadTs — the prompt would lose the question it was answering. Exactly the blast radius I flagged in the description and then didn't actually contain.
...

@khaliqgant Both P1s check out against the diff — good fixes.

P1 #1 (conversation key / opt-in threading). The shape you chose is right. Making startThread opt-in in postReply and mirroring the same option in conversationKeyForSlack closes the regression cleanly: every existing caller (inbox-buddy included) passes nothing and keeps keying top-level continuity on the channel, while askable-gtm, which keeps no cross-turn Slack context, is the only opt-in and loses nothing by moving its unit to the thread. The threadTs = msg.threadTs ?? (options.startThread ? msg.ts : undefined) logic and matching key derivation also keep the "reply stays in an existing thread" path untouched. That's the right containment — the blast radius you flagged in the description is now explicit rather than accidental.

P1 #2 (relay JSON). Correct call. Fencing in handleInteractiveCommand (shared by both transports) would have broken any agent or catalog doing JSON.parse over relay — and worse, it would break silently, since a ```json prefix parses without a hard throw depending on the caller. Moving presentation behind the optional presentJson hook so only the Slack actor fences, while renderCapabilitiesJson stays standalone parseable JSON, is the right separation. The relay test asserting no fences plus the Slack round-trip test pin exactly the contract that was at risk.

P3 (JSDoc). The updated docstring on postReply/SlackReplyOptions now describes both the thread-stays case and why top-level threading is opt-in rather than default — that was the gap.

One small thing worth a second glance: presentJsonForSlack returns the modifier text outside the fence. If a future caller feeds that string back into something that tries to parse it as the payload, the lead line would trip it — but that's presentation-only and the tests correctly round-trip only the fenced slice, so it's fine as-is. No changes needed.

Nice catch on both — the threading one especially would have shipped a real inbox-buddy regression.

Tip: get faster answers by chatting with cubic’s review copilot in the review UI

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. 🚀

Reviewed commit:8d65ea9914

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@cubic-dev-aicubic-dev-aiBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 3 files (changes from recent commits).

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment threadtests/askable-gtm.test.mjs
khaliqgantand others added 2 commits September 1, 2026 12:54
The first live answer in Slack was a ~4,300-character wall. Reddit rows carry a
`title`, LinkedIn rows do not, so the renderer fell back to `body_text` — and
pasted five entire LinkedIn posts into one thread. `oneLine` collapses
whitespace but never truncates.
A citation is a pointer to a source, not a copy of it. Excerpt to 180
characters on a word boundary, and let the link carry the rest. The same answer
is now ~1,200 characters and scannable.
Also attribute LinkedIn rows by public author. Reddit rows show their
community; LinkedIn has none, so those lines carried no indication of who was
speaking — while `author.handle` was already parsed and is evidence the
capability manifest promises to surface.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SQpuAYyuSG3MWSZYnw5o1x
The test named "relay receives raw JSON while Slack receives the fenced form"
only asserted the relay half. Removing `presentJson` from the Slack actor left
it green — the exact regression it was written to catch.
Now drives `handleSlackMessage` as well and asserts the reply is threaded,
fenced, and carries byte-for-byte the same document relay received. Verified by
ablation: deleting `presentJson: presentJsonForSlack` fails this test.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SQpuAYyuSG3MWSZYnw5o1x
@khaliqgant

Copy link
Copy Markdown
MemberAuthor

@cubic-dev-ai P2 addressed in `e0a2b0f` — correct, and a fair hit.

The test asserted a claim its own name made twice and only proved once: it captured relay.dm output but never the Slack side, so removing presentJson from the Slack actor left it green. That is precisely the regression it existed to catch.

It now drives handleSlackMessage too, and asserts the Slack reply is threaded, fenced, and carries byte-for-byte the same document relay received — so the transport split is pinned from both ends rather than one.

Verified by ablation rather than assertion: deleting presentJson: presentJsonForSlack from the Slack actor now fails it (not ok 47), and restoring it passes. 50 tests in this file, 326 in the full suite, 0 failures.

Worth noting this is the third leaf-only test gap caught by review on this branch and its siblings today, all the same shape: a helper tested directly while the wiring that reaches it went unasserted. I've started ablating rather than trusting green.

@cubic-dev-ai

Copy link
Copy Markdown

@cubic-dev-ai P2 addressed in `e0a2b0f` — correct, and a fair hit.

The test asserted a claim its own name made twice and only proved once: it captured relay.dm output but never the Slack side, so removing presentJson from the Slack actor left it green. That is precisely the regression it existed to catch.

It now drives handleSlackMessage too, and asserts the Slack reply is threaded, fenced, and carries byte-for-byte the same document relay received — so the transport split is pinned from both ends rather than one.
...

@khaliqgant Agreed, that's a fair hit and e0a2b0f closes it properly. The old test only pinned the relay half of "relay gets raw JSON while Slack gets fenced" — the name promised both, the assertion proved one, and removing presentJson from the Slack actor slid right through green. That's exactly the leaf-only gap you describe.

The fix lands the right way: driving handleSlackMessage through the real handler and asserting the Slack reply is threaded, fenced, and byte-for-byte the same document relay received pins the transport split from both ends. Ablation-verified too, which is the honest way to prove a test guards a regression — deleting presentJson now fails, restoring passes. Nothing to add on the diff itself.

The pattern you're calling out is worth a habit, not just a patch: a test that names two sides and asserts one is a latent hole. Your "ablate rather than trust green" instinct is the right correction for this branch's whole cluster of same-shaped gaps.

Tip: get faster answers by chatting with cubic’s review copilot in the review UI

@cubic-dev-aicubic-dev-aiBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1 issue found across 2 files (changes from recent commits).

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="askable-gtm/agent.ts">
<violation number="1" location="askable-gtm/agent.ts:1510">
P3: When a post has a non-BMP character at the excerpt boundary, `truncateEvidence` splits its surrogate pair and sends malformed text to Slack. Count and slice Unicode code points instead of UTF-16 code units.</violation>
</file>

Tip: Review your code locally with the cubic CLI to iterate faster.

Re-trigger cubic

Comment threadaskable-gtm/agent.ts
Comment on lines +1510 to +1517
export function truncateEvidence(value: string, max = EVIDENCE_EXCERPT_MAX): string {
const single = oneLine(value);
if (single.length <= max) return single;
const clipped = single.slice(0, max);
const lastSpace = clipped.lastIndexOf(' ');
// Only honour the word boundary if it does not gut the excerpt.
const body = lastSpace > max * 0.6 ? clipped.slice(0, lastSpace) : clipped;
return `${body.replace(/[\s.,;:!?—-]+$/u, '')}…`;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: When a post has a non-BMP character at the excerpt boundary, truncateEvidence splits its surrogate pair and sends malformed text to Slack. Count and slice Unicode code points instead of UTF-16 code units.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At askable-gtm/agent.ts, line 1510:
<comment>When a post has a non-BMP character at the excerpt boundary, `truncateEvidence` splits its surrogate pair and sends malformed text to Slack. Count and slice Unicode code points instead of UTF-16 code units.</comment>
<file context>
@@ -1488,6 +1499,24 @@ function oneLine(value: string): string {
+ * to a source, not a copy of it: pasting five full LinkedIn posts into a Slack
+ * thread buries the very signal the answer exists to surface.
+ */
+export function truncateEvidence(value: string, max = EVIDENCE_EXCERPT_MAX): string {
+ const single = oneLine(value);
+ if (single.length <= max) return single;
</file context>
Suggested change
exportfunctiontruncateEvidence(value: string,max=EVIDENCE_EXCERPT_MAX): string{
constsingle=oneLine(value);
if(single.length<=max)returnsingle;
constclipped=single.slice(0,max);
constlastSpace=clipped.lastIndexOf(' ');
// Only honour the word boundary if it does not gut the excerpt.
constbody=lastSpace>max*0.6 ? clipped.slice(0,lastSpace) : clipped;
return`${body.replace(/[\s.,;:!?-]+$/u,'')}…`;
exportfunctiontruncateEvidence(value: string,max=EVIDENCE_EXCERPT_MAX): string{
constcharacters=[...oneLine(value)];
if(characters.length<=max)returncharacters.join('');
constclipped=characters.slice(0,max);
constlastSpace=clipped.lastIndexOf(' ');
// Only honour the word boundary if it does not gut the excerpt.
constbody=lastSpace>max*0.6
? clipped.slice(0,lastSpace).join('')
: clipped.join('');
return`${body.replace(/[\s.,;:!?-]+$/u,'')}…`;
}

Four lines of ceremony sat above every result: the question restated, the fetch
timestamp and raw coverage map, the credential/endpoint disclosure, and a
standing caveat about inference. All of it was either something the reader had
just typed or something they could see, and it pushed the actual findings
below the fold.
Two things survive the trim, because dropping them makes the answer dishonest
rather than merely terse:
- a missing source, now stated as a compact `(reddit unavailable this
request)` rather than a sentence. Returning LinkedIn-only results while
Reddit is 403 and saying nothing presents a partial view as a complete one,
which is the failure this persona exists to avoid.
- the access disclosure, but only when the credential is NOT the user's own.
The manifest marks managed access `disclosureRequired` because it is
metered and billable, and `unknown` discloses too — the gateway did not say
which path it used, and staying quiet about a possible charge is the wrong
way to be wrong.
Same answer as the screenshot that prompted this: ~4,300 chars → ~680.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SQpuAYyuSG3MWSZYnw5o1x

@cubic-dev-aicubic-dev-aiBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 2 files (changes from recent commits).

Tip: Review your code locally with the cubic CLI to iterate faster.

Re-trigger cubic

Comment threadaskable-gtm/agent.ts
Dropping the query header was right for the interactive path — the reader just
typed the question — but the renderer serves two callers, and I only reasoned
about one. `runWatchSweep` sends the same output as an unsolicited DM that can
land hours later beside other watches, so with the header gone the user could
not tell which saved query fired, or which id to unwatch.
The header belongs at the delivery site rather than in the renderer: the sweep
knows it is delivering unsolicited, the interactive path knows it is not.
Compact by design — query, cadence, id — so it identifies the update without
reintroducing the preamble that buried the results.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SQpuAYyuSG3MWSZYnw5o1x
@khaliqgant

Copy link
Copy Markdown
MemberAuthor

@cubic-dev-ai P2 addressed in db3b1e6 — correct, and a gap in my reasoning rather than a detail.

Dropping the query header was right for the interactive path (the reader just typed the question), but renderListenAnswer serves two callers and I only reasoned about one. runWatchSweep sends the same output as an unsolicited DM that can arrive hours later beside other watches — with the header gone, there was nothing to say which saved query fired or which id to unwatch.

Fixed at the delivery site rather than in the renderer, since that is where the distinction actually lives: the sweep knows it is delivering unsolicited, the interactive path knows it is not. The label is compact by design — New for “…” · every 6h · <id> — so it identifies the update without reintroducing the four lines of preamble that buried the results in the first place.

New test asserts both halves: a swept delivery carries query, cadence and id and is followed immediately by results; the interactive answer stays bare. 51 tests in this file, 327 in the full suite, 0 failures.

@cubic-dev-ai

Copy link
Copy Markdown

@cubic-dev-ai P2 addressed in db3b1e6 — correct, and a gap in my reasoning rather than a detail.

Dropping the query header was right for the interactive path (the reader just typed the question), but renderListenAnswer serves two callers and I only reasoned about one. runWatchSweep sends the same output as an unsolicited DM that can arrive hours later beside other watches — with the header gone, there was nothing to say which saved query fired or which id to unwatch.

Fixed at the delivery site rather than in the renderer, since that is where the distinction actually lives: the sweep knows it is delivering unsolicited, the interactive path knows it is not. The label is compact by design — New for “…” · every 6h · <id> — so it identifies the update without reintroducing the four lines of preamble that buried the results in the first place.
...

@khaliqgant Confirmed — db3b1e6 lands that one where it belongs. Putting the identifier at the delivery site is the right containment: the sweep is the only caller that knows it's unsolicited, so coupling it to renderListenAnswer (which serves the interactive path too) would have dragged the ceremony back into every answer. Keeping the renderer bare and letting runWatchSweep prepend renderWatchHeader keeps each path honest about its own context.

The shape reads well against the diff:

  • renderWatchHeader stays compact — query, cadence, id — so it names the watch without resurrecting the four-line preamble that motivated the trim.
  • Header is only prepended on fresh deliveries (fresh.length > 0), which is correct: a sweep with nothing new shouldn't announce itself.
  • renderListenAnswer dropping the query restatement (now _query) is safe for the interactive path, and the new test pins both halves — swept delivery carries query/cadence/id followed immediately by results, interactive stays bare.

Good distinction between "who is reading this" and "does this renderer know that" — the sweep knows, the renderer doesn't, so the label lives at the delivery point. Nothing to add.

Tip: get faster answers by chatting with cubic’s review copilot in the review UI

@khaliqgant
khaliqgant merged commit 2febc91 into mainSep 1, 2026
3 checks passed
@khaliqgant
khaliqgant deleted the fix/askable-gtm-slack-reply-ux branch September 1, 2026 11:28
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@khaliqgant
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix(slack): answer in a thread, and make the manifest readable - #121

Merged
khaliqgant merged 6 commits into
mainfrom
fix/askable-gtm-slack-reply-ux
Sep 1, 2026
Merged

fix(slack): answer in a thread, and make the manifest readable#121
khaliqgant merged 6 commits into
mainfrom
fix/askable-gtm-slack-reply-ux

Conversation

@khaliqgant

@khaliqgantkhaliqgant commented Sep 1, 2026

Copy link
Copy Markdown
Member

Both problems observed on the first live @mention after cloud#3231 deployed.

1. Replies went to the channel, not a thread

postReply only threaded when the incoming message was already in one:

constresult=msg.threadTs
? awaitslack.reply(chanId,msg.threadTs,text)
: awaitslack.post(chanId,text);// ← top-level mention dumps into the channel

That is worst exactly where agents are useful. Every agent shares one Slack identity, so a single @Agent Relay … wakes every agent watching that channel — the production dispatch for one message matched 8 deployments — and they all replied into the main channel. Now the reply threads under the incoming message, and stays in the existing thread when there is one.

2. capabilities --json was an unreadable wall

~4KB of minified JSON posted as plain text. Now pretty-printed inside a ```json fence and led by one line naming the agent and pointing at plain-language questions — nobody should have to parse JSON to learn they can just ask.

Blast radius, deliberately

The threading change is in shared/slack.ts, so it applies to every agent using that helper (hn-monitor, joke-bot, inbox-buddy, review, …). That is the intent: an agent answering a channel mention should thread. Calling it out because it is broader than the askable-gtm title suggests.

The existing test asserting a channel-level post encoded the old behaviour; it now asserts the thread, with the reasoning in a comment so it is not "fixed" back later.

Validation

  • npm run typecheck: pass
  • tests/askable-gtm.test.mjs: 46 pass, 2 new — fenced/parseable manifest, and an existing thread answered in that thread rather than a new one
  • full suite: 322 pass / 0 fail

Not fixed here

One @Agent Relay mention still wakes every agent watching the channel, because they share a Slack identity — cloud#3234 gates on the bot being mentioned, which does not disambiguate which agent is being addressed. Threading makes that liveable, not solved. Worth a follow-up.

🤖 Generated with Claude Code


Summary by cubic

Fixes four problems from the first live @mention: replies thread under the message instead of landing in the channel, capabilities --json stays parseable for machines while Slack gets a readable fenced view, cited evidence is an excerpt rather than a whole post, and answers lead with results instead of a preamble.

  • Replies thread under the incoming message, and replies to a message already in a thread stay in that thread.
  • Threading is opt-in via startThread in postReply/conversationKeyForSlack; only askable-gtm enables it because it keeps no cross-turn Slack context.
  • capabilities --json stays standalone parseable JSON over relay; the Slack actor fences and pretty-prints it via a new presentJson hook.
  • Evidence lines are excerpted to 180 characters on a word boundary, and LinkedIn rows are attributed by author handle.
  • Answers drop the question restatement and coverage preamble, keeping only a compact source-failure note and the disclosure for metered managed access.
  • Unsolicited watch deliveries get a compact header naming the query, cadence, and watch id so the reader knows which one fired and can unwatch it.
  • Tests drive both transport halves through the real handlers, so the relay/Slack split can't silently regress.
  • One @Agent Relay mention still wakes every agent watching the channel since they share a Slack identity; threading makes that liveable, not solved.

Written for commit 55f8d85. Summary will update on new commits.

Review in cubic

Two problems seen in production on the first live @mention.
Replies landed in the channel, not a thread. `postReply` only threaded when the
incoming message was ALREADY in one, so a top-level `@Agent Relay …` got a
channel-level answer. That is worst exactly where agents are useful: every
agent shares one Slack identity, so one mention draws a reply from each agent
watching the channel and they all pile into the main channel. Now the reply
threads under the incoming message, and stays in the existing thread when there
is one.
`capabilities --json` dumped ~4KB of minified JSON as plain text, which Slack
renders as an unreadable wall. Pretty-print it inside a fenced block and lead
with one line naming the agent and saying you can just ask a question in plain
language — nobody should have to parse JSON to discover that.
The threading change is in shared/slack.ts, so it applies to every agent using
that helper. That is intended: an agent answering a channel mention should
thread. The existing test asserting a channel-level `post` encoded the old
behaviour and now asserts the thread.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SQpuAYyuSG3MWSZYnw5o1x
@chatgpt-codex-connector

chatgpt-codex-connectorBot commented Sep 1, 2026

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

ReviewStatusCommitReview trigger
📝 Code ReviewCompleted2026-09-01T10:45:10.460193Z8d65ea9Manual request
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@coderabbitai

coderabbitaiBot commented Sep 1, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

Next included review available in 11 minutes.

Check out review usage here.

View limit details

Limit details: You’ve used the included review currently available.

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

Learn how review limits work.

Review configuration:

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Team

Run ID: 91a9e59a-20ff-4bc8-ab8c-a74c68244ed7

📥 Commits

Reviewing files that changed from the base of the PR and between 2cf284a and 55f8d85.

📒 Files selected for processing (3)
  • askable-gtm/agent.ts
  • shared/slack.ts
  • tests/askable-gtm.test.mjs

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Team

Run ID: d0a8e943-72fc-4d71-bfc9-327e0382122a

📥 Commits

Reviewing files that changed from the base of the PR and between 82d88be and 2cf284a.

📒 Files selected for processing (3)
  • askable-gtm/agent.ts
  • shared/slack.ts
  • tests/askable-gtm.test.mjs

Included review availability: Your plan provides up to 1 included review per hour; 0 remain after this review.


📝 Walkthrough

Walkthrough

The capabilities command now delegates formatted JSON output to an exported renderer. Slack replies now always use a thread root, including for top-level messages.

Changes

Capabilities JSON rendering

Layer / File(s)Summary
Render and validate capability manifests
askable-gtm/agent.ts, tests/askable-gtm.test.mjs
The command uses renderCapabilitiesJson. The helper emits explanatory text, fenced pretty-printed JSON, and runtime access status. Tests validate the output.

Slack threaded replies

Layer / File(s)Summary
Route replies to message threads
shared/slack.ts, tests/askable-gtm.test.mjs
postReply uses the incoming timestamp for top-level messages and preserves existing thread timestamps. Tests cover both reply paths.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Merge Risk:⚪ Minimal · up to 2cf28

Replies now stay under the relevant Slack message and capability output is easier to read without changing its content; no actionable merge-blocking risk remains after normal checks and review.

Poem

A rabbit formats JSON bright,
With fences neat and spaces right.
Slack hops into threads anew,
Keeping every reply in view.
Two tidy paths now serve the queue.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check nameStatusExplanationResolution
Docstring Coverage⚠️ WarningDocstring coverage is 40.00% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 5 functions across 3 files.Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check nameStatusExplanation
Title check✅ PassedThe title clearly summarizes both primary changes: threaded Slack replies and readable capability manifests.
Description check✅ PassedThe description directly explains the Slack threading change, manifest formatting change, scope, tests, validation, and known limitation.
Linked Issues check✅ PassedCheck skipped because no linked issues were found for this pull request.
Out of Scope Changes check✅ PassedCheck skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/askable-gtm-slack-reply-ux

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@chatgpt-codex-connectorchatgpt-codex-connectorBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit:2cf284a9cc

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment threadshared/slack.ts Outdated
Comment on lines +138 to +140
const threadTs = msg.threadTs ?? msg.ts;
const result = threadTs
? await slack.reply(chanId, threadTs, text)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve the conversation key when starting a reply thread

When Inbox Buddy handles a top-level message, conversationKeyForSlack stores that turn under the channel key, but this change posts the answer beneath msg.ts; a user's natural follow-up in that new thread then arrives with threadTs and is loaded under channel:threadTs. Consequently, the initial question and answer are absent from the follow-up prompt, breaking the multi-turn continuity implemented in inbox-buddy/agent.ts. Ensure the initial turn and its resulting thread use the same conversation key.

Useful? React with 👍 / 👎.

Comment threadaskable-gtm/agent.ts
Comment on lines +1007 to +1010
'GTM Signal Scout — machine-readable capability manifest.',
'You can also just ask a GTM question in plain language, or send'
+ ' \u201cwhat can you tell me?\u201d for the short version.',
'```json',

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Keep relay capability responses valid JSON

When an agent or catalog invokes capabilities --json over Relay, handleRelayMessage now returns these prose and fence lines around the payload, so parsing the response directly with JSON.parse fails. This affects Relay as well as Slack because both transports share handleInteractiveCommand, despite the documented contract in ASKABLE_AGENTS.md describing this command as a machine-readable relay response; apply Slack-only presentation formatting or leave the relay response as standalone JSON.

Useful? React with 👍 / 👎.

@cubic-dev-aicubic-dev-aiBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1 issue found across 3 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="shared/slack.ts">
<violation number="1" location="shared/slack.ts:138">
P3: The `postReply` JSDoc now understates the behavior: this line also threads top-level messages under their own timestamp. Update the docstring to describe both top-level and existing-thread replies.</violation>
</file>

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment threadaskable-gtm/agent.ts Outdated
Comment threadshared/slack.ts Outdated
Comment threadshared/slack.ts Outdated
// buries the conversation in the main channel, which is worst exactly where
// agents are useful — several agents share one Slack identity, so a single
// `@Agent Relay` mention can draw a reply from each of them.
const threadTs = msg.threadTs ?? msg.ts;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: The postReply JSDoc now understates the behavior: this line also threads top-level messages under their own timestamp. Update the docstring to describe both top-level and existing-thread replies.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At shared/slack.ts, line 138:
<comment>The `postReply` JSDoc now understates the behavior: this line also threads top-level messages under their own timestamp. Update the docstring to describe both top-level and existing-thread replies.</comment>
<file context>
@@ -130,10 +130,16 @@ export async function postReply(
+ // buries the conversation in the main channel, which is worst exactly where
+ // agents are useful — several agents share one Slack identity, so a single
+ // `@Agent Relay` mention can draw a reply from each of them.
+ const threadTs = msg.threadTs ?? msg.ts;
+ const result = threadTs
+ ? await slack.reply(chanId, threadTs, text)
</file context>

Both P1s from review, each flagged independently by both reviewers.
Threading was applied in shared/slack.ts for every agent, which would have
broken inbox-buddy's multi-turn context: `conversationKeyForSlack` keys a
top-level message on the CHANNEL, so answering it in a new thread stranded the
opening turn under `channel` while the follow-up loaded under `channel:ts` —
the prompt would lose the question it was answering.
Threading is now opt-in via `postReply(..., { startThread: true })`, and
`conversationKeyForSlack` takes the same option so an agent that opts in keys
continuity on the thread it created. Every existing caller passes nothing and
is byte-for-byte unchanged; only askable-gtm opts in, and it keeps no
cross-turn Slack context so moving the conversation unit costs nothing.
`capabilities --json` is documented in ASKABLE_AGENTS.md as a MACHINE-readable
relay response, and both transports share `handleInteractiveCommand` — so
fencing the payload broke `JSON.parse` for any agent or catalog calling it over
relay. The payload is standalone JSON again; presentation moved to the
transport via an optional `presentJson` hook that only the Slack actor supplies.
Tests pin both: relay receives raw parseable JSON with no fences, Slack
receives the fenced form whose payload still round-trips, and an opted-in
top-level mention threads under its own ts.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SQpuAYyuSG3MWSZYnw5o1x
@khaliqgant

Copy link
Copy Markdown
MemberAuthor

Both P1s addressed in 8d65ea9. Both were correct, and the first would have shipped a real regression.

P1 — "Preserve the conversation key when starting a reply thread" (@chatgpt-codex-connector, @cubic-dev-ai)

Correct. conversationKeyForSlack keys a top-level message on the channel, so threading it in shared/slack.ts for every agent would have stranded inbox-buddy's opening turn under channel while the follow-up loaded under channel:threadTs — the prompt would lose the question it was answering. Exactly the blast radius I flagged in the description and then didn't actually contain.

Rather than patch the key, threading is now opt-in: postReply(..., { startThread: true }), with conversationKeyForSlack taking the same option so an agent that opts in keys continuity on the thread it created. Every existing caller passes nothing and is byte-for-byte unchanged — inbox-buddy included. Only askable-gtm opts in, and it keeps no cross-turn Slack context, so moving its conversation unit to the thread costs nothing.

P1 — "Keep relay capability responses valid JSON" (@chatgpt-codex-connector, @cubic-dev-ai)

Correct, and I should have caught it: ASKABLE_AGENTS.md documents this command as a machine-readable relay response, and the manifest's own discovery.machine field advertises it. Both transports share handleInteractiveCommand, so fencing the payload broke JSON.parse for any agent or catalog calling it.

renderCapabilitiesJson returns standalone parseable JSON again. Presentation moved to the transport via an optional presentJson hook that only the Slack actor supplies — the human surface fences, the machine surface does not. I took the shape of @cubic-dev-ai's suggestion rather than the literal diff, so Slack keeps the readable rendering that motivated the PR.

P3 — postReply JSDoc understates the behavior

Fixed; the docstring now describes both cases and says why threading is opt-in rather than default.

Validation: 48 tests in askable-gtm.test.mjs (3 new — relay gets raw fence-free JSON, Slack's fenced payload round-trips, an existing thread stays in that thread), full suite 324 pass / 0 fail, typecheck clean.

@codex review

@cubic-dev-ai

Copy link
Copy Markdown

Both P1s addressed in 8d65ea9. Both were correct, and the first would have shipped a real regression.

P1 — "Preserve the conversation key when starting a reply thread" (@chatgpt-codex-connector, @cubic-dev-ai)

Correct. conversationKeyForSlack keys a top-level message on the channel, so threading it in shared/slack.ts for every agent would have stranded inbox-buddy's opening turn under channel while the follow-up loaded under channel:threadTs — the prompt would lose the question it was answering. Exactly the blast radius I flagged in the description and then didn't actually contain.
...

@khaliqgant Both P1s check out against the diff — good fixes.

P1 #1 (conversation key / opt-in threading). The shape you chose is right. Making startThread opt-in in postReply and mirroring the same option in conversationKeyForSlack closes the regression cleanly: every existing caller (inbox-buddy included) passes nothing and keeps keying top-level continuity on the channel, while askable-gtm, which keeps no cross-turn Slack context, is the only opt-in and loses nothing by moving its unit to the thread. The threadTs = msg.threadTs ?? (options.startThread ? msg.ts : undefined) logic and matching key derivation also keep the "reply stays in an existing thread" path untouched. That's the right containment — the blast radius you flagged in the description is now explicit rather than accidental.

P1 #2 (relay JSON). Correct call. Fencing in handleInteractiveCommand (shared by both transports) would have broken any agent or catalog doing JSON.parse over relay — and worse, it would break silently, since a ```json prefix parses without a hard throw depending on the caller. Moving presentation behind the optional presentJson hook so only the Slack actor fences, while renderCapabilitiesJson stays standalone parseable JSON, is the right separation. The relay test asserting no fences plus the Slack round-trip test pin exactly the contract that was at risk.

P3 (JSDoc). The updated docstring on postReply/SlackReplyOptions now describes both the thread-stays case and why top-level threading is opt-in rather than default — that was the gap.

One small thing worth a second glance: presentJsonForSlack returns the modifier text outside the fence. If a future caller feeds that string back into something that tries to parse it as the payload, the lead line would trip it — but that's presentation-only and the tests correctly round-trip only the fenced slice, so it's fine as-is. No changes needed.

Nice catch on both — the threading one especially would have shipped a real inbox-buddy regression.

Tip: get faster answers by chatting with cubic’s review copilot in the review UI

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. 🚀

Reviewed commit:8d65ea9914

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@cubic-dev-aicubic-dev-aiBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 3 files (changes from recent commits).

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment threadtests/askable-gtm.test.mjs
khaliqgantand others added 2 commits September 1, 2026 12:54
The first live answer in Slack was a ~4,300-character wall. Reddit rows carry a
`title`, LinkedIn rows do not, so the renderer fell back to `body_text` — and
pasted five entire LinkedIn posts into one thread. `oneLine` collapses
whitespace but never truncates.
A citation is a pointer to a source, not a copy of it. Excerpt to 180
characters on a word boundary, and let the link carry the rest. The same answer
is now ~1,200 characters and scannable.
Also attribute LinkedIn rows by public author. Reddit rows show their
community; LinkedIn has none, so those lines carried no indication of who was
speaking — while `author.handle` was already parsed and is evidence the
capability manifest promises to surface.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SQpuAYyuSG3MWSZYnw5o1x
The test named "relay receives raw JSON while Slack receives the fenced form"
only asserted the relay half. Removing `presentJson` from the Slack actor left
it green — the exact regression it was written to catch.
Now drives `handleSlackMessage` as well and asserts the reply is threaded,
fenced, and carries byte-for-byte the same document relay received. Verified by
ablation: deleting `presentJson: presentJsonForSlack` fails this test.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SQpuAYyuSG3MWSZYnw5o1x
@khaliqgant

Copy link
Copy Markdown
MemberAuthor

@cubic-dev-ai P2 addressed in `e0a2b0f` — correct, and a fair hit.

The test asserted a claim its own name made twice and only proved once: it captured relay.dm output but never the Slack side, so removing presentJson from the Slack actor left it green. That is precisely the regression it existed to catch.

It now drives handleSlackMessage too, and asserts the Slack reply is threaded, fenced, and carries byte-for-byte the same document relay received — so the transport split is pinned from both ends rather than one.

Verified by ablation rather than assertion: deleting presentJson: presentJsonForSlack from the Slack actor now fails it (not ok 47), and restoring it passes. 50 tests in this file, 326 in the full suite, 0 failures.

Worth noting this is the third leaf-only test gap caught by review on this branch and its siblings today, all the same shape: a helper tested directly while the wiring that reaches it went unasserted. I've started ablating rather than trusting green.

@cubic-dev-ai

Copy link
Copy Markdown

@cubic-dev-ai P2 addressed in `e0a2b0f` — correct, and a fair hit.

The test asserted a claim its own name made twice and only proved once: it captured relay.dm output but never the Slack side, so removing presentJson from the Slack actor left it green. That is precisely the regression it existed to catch.

It now drives handleSlackMessage too, and asserts the Slack reply is threaded, fenced, and carries byte-for-byte the same document relay received — so the transport split is pinned from both ends rather than one.
...

@khaliqgant Agreed, that's a fair hit and e0a2b0f closes it properly. The old test only pinned the relay half of "relay gets raw JSON while Slack gets fenced" — the name promised both, the assertion proved one, and removing presentJson from the Slack actor slid right through green. That's exactly the leaf-only gap you describe.

The fix lands the right way: driving handleSlackMessage through the real handler and asserting the Slack reply is threaded, fenced, and byte-for-byte the same document relay received pins the transport split from both ends. Ablation-verified too, which is the honest way to prove a test guards a regression — deleting presentJson now fails, restoring passes. Nothing to add on the diff itself.

The pattern you're calling out is worth a habit, not just a patch: a test that names two sides and asserts one is a latent hole. Your "ablate rather than trust green" instinct is the right correction for this branch's whole cluster of same-shaped gaps.

Tip: get faster answers by chatting with cubic’s review copilot in the review UI

@cubic-dev-aicubic-dev-aiBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1 issue found across 2 files (changes from recent commits).

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="askable-gtm/agent.ts">
<violation number="1" location="askable-gtm/agent.ts:1510">
P3: When a post has a non-BMP character at the excerpt boundary, `truncateEvidence` splits its surrogate pair and sends malformed text to Slack. Count and slice Unicode code points instead of UTF-16 code units.</violation>
</file>

Tip: Review your code locally with the cubic CLI to iterate faster.

Re-trigger cubic

Comment threadaskable-gtm/agent.ts
Comment on lines +1510 to +1517
export function truncateEvidence(value: string, max = EVIDENCE_EXCERPT_MAX): string {
const single = oneLine(value);
if (single.length <= max) return single;
const clipped = single.slice(0, max);
const lastSpace = clipped.lastIndexOf(' ');
// Only honour the word boundary if it does not gut the excerpt.
const body = lastSpace > max * 0.6 ? clipped.slice(0, lastSpace) : clipped;
return `${body.replace(/[\s.,;:!?—-]+$/u, '')}…`;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: When a post has a non-BMP character at the excerpt boundary, truncateEvidence splits its surrogate pair and sends malformed text to Slack. Count and slice Unicode code points instead of UTF-16 code units.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At askable-gtm/agent.ts, line 1510:
<comment>When a post has a non-BMP character at the excerpt boundary, `truncateEvidence` splits its surrogate pair and sends malformed text to Slack. Count and slice Unicode code points instead of UTF-16 code units.</comment>
<file context>
@@ -1488,6 +1499,24 @@ function oneLine(value: string): string {
+ * to a source, not a copy of it: pasting five full LinkedIn posts into a Slack
+ * thread buries the very signal the answer exists to surface.
+ */
+export function truncateEvidence(value: string, max = EVIDENCE_EXCERPT_MAX): string {
+ const single = oneLine(value);
+ if (single.length <= max) return single;
</file context>
Suggested change
exportfunctiontruncateEvidence(value: string,max=EVIDENCE_EXCERPT_MAX): string{
constsingle=oneLine(value);
if(single.length<=max)returnsingle;
constclipped=single.slice(0,max);
constlastSpace=clipped.lastIndexOf(' ');
// Only honour the word boundary if it does not gut the excerpt.
constbody=lastSpace>max*0.6 ? clipped.slice(0,lastSpace) : clipped;
return`${body.replace(/[\s.,;:!?-]+$/u,'')}…`;
exportfunctiontruncateEvidence(value: string,max=EVIDENCE_EXCERPT_MAX): string{
constcharacters=[...oneLine(value)];
if(characters.length<=max)returncharacters.join('');
constclipped=characters.slice(0,max);
constlastSpace=clipped.lastIndexOf(' ');
// Only honour the word boundary if it does not gut the excerpt.
constbody=lastSpace>max*0.6
? clipped.slice(0,lastSpace).join('')
: clipped.join('');
return`${body.replace(/[\s.,;:!?-]+$/u,'')}…`;
}

Four lines of ceremony sat above every result: the question restated, the fetch
timestamp and raw coverage map, the credential/endpoint disclosure, and a
standing caveat about inference. All of it was either something the reader had
just typed or something they could see, and it pushed the actual findings
below the fold.
Two things survive the trim, because dropping them makes the answer dishonest
rather than merely terse:
- a missing source, now stated as a compact `(reddit unavailable this
request)` rather than a sentence. Returning LinkedIn-only results while
Reddit is 403 and saying nothing presents a partial view as a complete one,
which is the failure this persona exists to avoid.
- the access disclosure, but only when the credential is NOT the user's own.
The manifest marks managed access `disclosureRequired` because it is
metered and billable, and `unknown` discloses too — the gateway did not say
which path it used, and staying quiet about a possible charge is the wrong
way to be wrong.
Same answer as the screenshot that prompted this: ~4,300 chars → ~680.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SQpuAYyuSG3MWSZYnw5o1x

@cubic-dev-aicubic-dev-aiBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 2 files (changes from recent commits).

Tip: Review your code locally with the cubic CLI to iterate faster.

Re-trigger cubic

Comment threadaskable-gtm/agent.ts
Dropping the query header was right for the interactive path — the reader just
typed the question — but the renderer serves two callers, and I only reasoned
about one. `runWatchSweep` sends the same output as an unsolicited DM that can
land hours later beside other watches, so with the header gone the user could
not tell which saved query fired, or which id to unwatch.
The header belongs at the delivery site rather than in the renderer: the sweep
knows it is delivering unsolicited, the interactive path knows it is not.
Compact by design — query, cadence, id — so it identifies the update without
reintroducing the preamble that buried the results.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SQpuAYyuSG3MWSZYnw5o1x
@khaliqgant

Copy link
Copy Markdown
MemberAuthor

@cubic-dev-ai P2 addressed in db3b1e6 — correct, and a gap in my reasoning rather than a detail.

Dropping the query header was right for the interactive path (the reader just typed the question), but renderListenAnswer serves two callers and I only reasoned about one. runWatchSweep sends the same output as an unsolicited DM that can arrive hours later beside other watches — with the header gone, there was nothing to say which saved query fired or which id to unwatch.

Fixed at the delivery site rather than in the renderer, since that is where the distinction actually lives: the sweep knows it is delivering unsolicited, the interactive path knows it is not. The label is compact by design — New for “…” · every 6h · <id> — so it identifies the update without reintroducing the four lines of preamble that buried the results in the first place.

New test asserts both halves: a swept delivery carries query, cadence and id and is followed immediately by results; the interactive answer stays bare. 51 tests in this file, 327 in the full suite, 0 failures.

@cubic-dev-ai

Copy link
Copy Markdown

@cubic-dev-ai P2 addressed in db3b1e6 — correct, and a gap in my reasoning rather than a detail.

Dropping the query header was right for the interactive path (the reader just typed the question), but renderListenAnswer serves two callers and I only reasoned about one. runWatchSweep sends the same output as an unsolicited DM that can arrive hours later beside other watches — with the header gone, there was nothing to say which saved query fired or which id to unwatch.

Fixed at the delivery site rather than in the renderer, since that is where the distinction actually lives: the sweep knows it is delivering unsolicited, the interactive path knows it is not. The label is compact by design — New for “…” · every 6h · <id> — so it identifies the update without reintroducing the four lines of preamble that buried the results in the first place.
...

@khaliqgant Confirmed — db3b1e6 lands that one where it belongs. Putting the identifier at the delivery site is the right containment: the sweep is the only caller that knows it's unsolicited, so coupling it to renderListenAnswer (which serves the interactive path too) would have dragged the ceremony back into every answer. Keeping the renderer bare and letting runWatchSweep prepend renderWatchHeader keeps each path honest about its own context.

The shape reads well against the diff:

  • renderWatchHeader stays compact — query, cadence, id — so it names the watch without resurrecting the four-line preamble that motivated the trim.
  • Header is only prepended on fresh deliveries (fresh.length > 0), which is correct: a sweep with nothing new shouldn't announce itself.
  • renderListenAnswer dropping the query restatement (now _query) is safe for the interactive path, and the new test pins both halves — swept delivery carries query/cadence/id followed immediately by results, interactive stays bare.

Good distinction between "who is reading this" and "does this renderer know that" — the sweep knows, the renderer doesn't, so the label lives at the delivery point. Nothing to add.

Tip: get faster answers by chatting with cubic’s review copilot in the review UI

@khaliqgant
khaliqgant merged commit 2febc91 into mainSep 1, 2026
3 checks passed
@khaliqgant
khaliqgant deleted the fix/askable-gtm-slack-reply-ux branch September 1, 2026 11:28
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@khaliqgant
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix(slack): answer in a thread, and make the manifest readable - #121

Merged
khaliqgant merged 6 commits into
mainfrom
fix/askable-gtm-slack-reply-ux
Sep 1, 2026
Merged

fix(slack): answer in a thread, and make the manifest readable#121
khaliqgant merged 6 commits into
mainfrom
fix/askable-gtm-slack-reply-ux

Conversation

@khaliqgant

@khaliqgantkhaliqgant commented Sep 1, 2026

Copy link
Copy Markdown
Member

Both problems observed on the first live @mention after cloud#3231 deployed.

1. Replies went to the channel, not a thread

postReply only threaded when the incoming message was already in one:

constresult=msg.threadTs
? awaitslack.reply(chanId,msg.threadTs,text)
: awaitslack.post(chanId,text);// ← top-level mention dumps into the channel

That is worst exactly where agents are useful. Every agent shares one Slack identity, so a single @Agent Relay … wakes every agent watching that channel — the production dispatch for one message matched 8 deployments — and they all replied into the main channel. Now the reply threads under the incoming message, and stays in the existing thread when there is one.

2. capabilities --json was an unreadable wall

~4KB of minified JSON posted as plain text. Now pretty-printed inside a ```json fence and led by one line naming the agent and pointing at plain-language questions — nobody should have to parse JSON to learn they can just ask.

Blast radius, deliberately

The threading change is in shared/slack.ts, so it applies to every agent using that helper (hn-monitor, joke-bot, inbox-buddy, review, …). That is the intent: an agent answering a channel mention should thread. Calling it out because it is broader than the askable-gtm title suggests.

The existing test asserting a channel-level post encoded the old behaviour; it now asserts the thread, with the reasoning in a comment so it is not "fixed" back later.

Validation

  • npm run typecheck: pass
  • tests/askable-gtm.test.mjs: 46 pass, 2 new — fenced/parseable manifest, and an existing thread answered in that thread rather than a new one
  • full suite: 322 pass / 0 fail

Not fixed here

One @Agent Relay mention still wakes every agent watching the channel, because they share a Slack identity — cloud#3234 gates on the bot being mentioned, which does not disambiguate which agent is being addressed. Threading makes that liveable, not solved. Worth a follow-up.

🤖 Generated with Claude Code


Summary by cubic

Fixes four problems from the first live @mention: replies thread under the message instead of landing in the channel, capabilities --json stays parseable for machines while Slack gets a readable fenced view, cited evidence is an excerpt rather than a whole post, and answers lead with results instead of a preamble.

  • Replies thread under the incoming message, and replies to a message already in a thread stay in that thread.
  • Threading is opt-in via startThread in postReply/conversationKeyForSlack; only askable-gtm enables it because it keeps no cross-turn Slack context.
  • capabilities --json stays standalone parseable JSON over relay; the Slack actor fences and pretty-prints it via a new presentJson hook.
  • Evidence lines are excerpted to 180 characters on a word boundary, and LinkedIn rows are attributed by author handle.
  • Answers drop the question restatement and coverage preamble, keeping only a compact source-failure note and the disclosure for metered managed access.
  • Unsolicited watch deliveries get a compact header naming the query, cadence, and watch id so the reader knows which one fired and can unwatch it.
  • Tests drive both transport halves through the real handlers, so the relay/Slack split can't silently regress.
  • One @Agent Relay mention still wakes every agent watching the channel since they share a Slack identity; threading makes that liveable, not solved.

Written for commit 55f8d85. Summary will update on new commits.

Review in cubic

Two problems seen in production on the first live @mention.
Replies landed in the channel, not a thread. `postReply` only threaded when the
incoming message was ALREADY in one, so a top-level `@Agent Relay …` got a
channel-level answer. That is worst exactly where agents are useful: every
agent shares one Slack identity, so one mention draws a reply from each agent
watching the channel and they all pile into the main channel. Now the reply
threads under the incoming message, and stays in the existing thread when there
is one.
`capabilities --json` dumped ~4KB of minified JSON as plain text, which Slack
renders as an unreadable wall. Pretty-print it inside a fenced block and lead
with one line naming the agent and saying you can just ask a question in plain
language — nobody should have to parse JSON to discover that.
The threading change is in shared/slack.ts, so it applies to every agent using
that helper. That is intended: an agent answering a channel mention should
thread. The existing test asserting a channel-level `post` encoded the old
behaviour and now asserts the thread.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SQpuAYyuSG3MWSZYnw5o1x
@chatgpt-codex-connector

chatgpt-codex-connectorBot commented Sep 1, 2026

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

ReviewStatusCommitReview trigger
📝 Code ReviewCompleted2026-09-01T10:45:10.460193Z8d65ea9Manual request
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@coderabbitai

coderabbitaiBot commented Sep 1, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

Next included review available in 11 minutes.

Check out review usage here.

View limit details

Limit details: You’ve used the included review currently available.

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

Learn how review limits work.

Review configuration:

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Team

Run ID: 91a9e59a-20ff-4bc8-ab8c-a74c68244ed7

📥 Commits

Reviewing files that changed from the base of the PR and between 2cf284a and 55f8d85.

📒 Files selected for processing (3)
  • askable-gtm/agent.ts
  • shared/slack.ts
  • tests/askable-gtm.test.mjs

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Team

Run ID: d0a8e943-72fc-4d71-bfc9-327e0382122a

📥 Commits

Reviewing files that changed from the base of the PR and between 82d88be and 2cf284a.

📒 Files selected for processing (3)
  • askable-gtm/agent.ts
  • shared/slack.ts
  • tests/askable-gtm.test.mjs

Included review availability: Your plan provides up to 1 included review per hour; 0 remain after this review.


📝 Walkthrough

Walkthrough

The capabilities command now delegates formatted JSON output to an exported renderer. Slack replies now always use a thread root, including for top-level messages.

Changes

Capabilities JSON rendering

Layer / File(s)Summary
Render and validate capability manifests
askable-gtm/agent.ts, tests/askable-gtm.test.mjs
The command uses renderCapabilitiesJson. The helper emits explanatory text, fenced pretty-printed JSON, and runtime access status. Tests validate the output.

Slack threaded replies

Layer / File(s)Summary
Route replies to message threads
shared/slack.ts, tests/askable-gtm.test.mjs
postReply uses the incoming timestamp for top-level messages and preserves existing thread timestamps. Tests cover both reply paths.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Merge Risk:⚪ Minimal · up to 2cf28

Replies now stay under the relevant Slack message and capability output is easier to read without changing its content; no actionable merge-blocking risk remains after normal checks and review.

Poem

A rabbit formats JSON bright,
With fences neat and spaces right.
Slack hops into threads anew,
Keeping every reply in view.
Two tidy paths now serve the queue.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check nameStatusExplanationResolution
Docstring Coverage⚠️ WarningDocstring coverage is 40.00% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 5 functions across 3 files.Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check nameStatusExplanation
Title check✅ PassedThe title clearly summarizes both primary changes: threaded Slack replies and readable capability manifests.
Description check✅ PassedThe description directly explains the Slack threading change, manifest formatting change, scope, tests, validation, and known limitation.
Linked Issues check✅ PassedCheck skipped because no linked issues were found for this pull request.
Out of Scope Changes check✅ PassedCheck skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/askable-gtm-slack-reply-ux

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@chatgpt-codex-connectorchatgpt-codex-connectorBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit:2cf284a9cc

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment threadshared/slack.ts Outdated
Comment on lines +138 to +140
const threadTs = msg.threadTs ?? msg.ts;
const result = threadTs
? await slack.reply(chanId, threadTs, text)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve the conversation key when starting a reply thread

When Inbox Buddy handles a top-level message, conversationKeyForSlack stores that turn under the channel key, but this change posts the answer beneath msg.ts; a user's natural follow-up in that new thread then arrives with threadTs and is loaded under channel:threadTs. Consequently, the initial question and answer are absent from the follow-up prompt, breaking the multi-turn continuity implemented in inbox-buddy/agent.ts. Ensure the initial turn and its resulting thread use the same conversation key.

Useful? React with 👍 / 👎.

Comment threadaskable-gtm/agent.ts
Comment on lines +1007 to +1010
'GTM Signal Scout — machine-readable capability manifest.',
'You can also just ask a GTM question in plain language, or send'
+ ' \u201cwhat can you tell me?\u201d for the short version.',
'```json',

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Keep relay capability responses valid JSON

When an agent or catalog invokes capabilities --json over Relay, handleRelayMessage now returns these prose and fence lines around the payload, so parsing the response directly with JSON.parse fails. This affects Relay as well as Slack because both transports share handleInteractiveCommand, despite the documented contract in ASKABLE_AGENTS.md describing this command as a machine-readable relay response; apply Slack-only presentation formatting or leave the relay response as standalone JSON.

Useful? React with 👍 / 👎.

@cubic-dev-aicubic-dev-aiBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1 issue found across 3 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="shared/slack.ts">
<violation number="1" location="shared/slack.ts:138">
P3: The `postReply` JSDoc now understates the behavior: this line also threads top-level messages under their own timestamp. Update the docstring to describe both top-level and existing-thread replies.</violation>
</file>

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment threadaskable-gtm/agent.ts Outdated
Comment threadshared/slack.ts Outdated
Comment threadshared/slack.ts Outdated
// buries the conversation in the main channel, which is worst exactly where
// agents are useful — several agents share one Slack identity, so a single
// `@Agent Relay` mention can draw a reply from each of them.
const threadTs = msg.threadTs ?? msg.ts;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: The postReply JSDoc now understates the behavior: this line also threads top-level messages under their own timestamp. Update the docstring to describe both top-level and existing-thread replies.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At shared/slack.ts, line 138:
<comment>The `postReply` JSDoc now understates the behavior: this line also threads top-level messages under their own timestamp. Update the docstring to describe both top-level and existing-thread replies.</comment>
<file context>
@@ -130,10 +130,16 @@ export async function postReply(
+ // buries the conversation in the main channel, which is worst exactly where
+ // agents are useful — several agents share one Slack identity, so a single
+ // `@Agent Relay` mention can draw a reply from each of them.
+ const threadTs = msg.threadTs ?? msg.ts;
+ const result = threadTs
+ ? await slack.reply(chanId, threadTs, text)
</file context>

Both P1s from review, each flagged independently by both reviewers.
Threading was applied in shared/slack.ts for every agent, which would have
broken inbox-buddy's multi-turn context: `conversationKeyForSlack` keys a
top-level message on the CHANNEL, so answering it in a new thread stranded the
opening turn under `channel` while the follow-up loaded under `channel:ts` —
the prompt would lose the question it was answering.
Threading is now opt-in via `postReply(..., { startThread: true })`, and
`conversationKeyForSlack` takes the same option so an agent that opts in keys
continuity on the thread it created. Every existing caller passes nothing and
is byte-for-byte unchanged; only askable-gtm opts in, and it keeps no
cross-turn Slack context so moving the conversation unit costs nothing.
`capabilities --json` is documented in ASKABLE_AGENTS.md as a MACHINE-readable
relay response, and both transports share `handleInteractiveCommand` — so
fencing the payload broke `JSON.parse` for any agent or catalog calling it over
relay. The payload is standalone JSON again; presentation moved to the
transport via an optional `presentJson` hook that only the Slack actor supplies.
Tests pin both: relay receives raw parseable JSON with no fences, Slack
receives the fenced form whose payload still round-trips, and an opted-in
top-level mention threads under its own ts.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SQpuAYyuSG3MWSZYnw5o1x
@khaliqgant

Copy link
Copy Markdown
MemberAuthor

Both P1s addressed in 8d65ea9. Both were correct, and the first would have shipped a real regression.

P1 — "Preserve the conversation key when starting a reply thread" (@chatgpt-codex-connector, @cubic-dev-ai)

Correct. conversationKeyForSlack keys a top-level message on the channel, so threading it in shared/slack.ts for every agent would have stranded inbox-buddy's opening turn under channel while the follow-up loaded under channel:threadTs — the prompt would lose the question it was answering. Exactly the blast radius I flagged in the description and then didn't actually contain.

Rather than patch the key, threading is now opt-in: postReply(..., { startThread: true }), with conversationKeyForSlack taking the same option so an agent that opts in keys continuity on the thread it created. Every existing caller passes nothing and is byte-for-byte unchanged — inbox-buddy included. Only askable-gtm opts in, and it keeps no cross-turn Slack context, so moving its conversation unit to the thread costs nothing.

P1 — "Keep relay capability responses valid JSON" (@chatgpt-codex-connector, @cubic-dev-ai)

Correct, and I should have caught it: ASKABLE_AGENTS.md documents this command as a machine-readable relay response, and the manifest's own discovery.machine field advertises it. Both transports share handleInteractiveCommand, so fencing the payload broke JSON.parse for any agent or catalog calling it.

renderCapabilitiesJson returns standalone parseable JSON again. Presentation moved to the transport via an optional presentJson hook that only the Slack actor supplies — the human surface fences, the machine surface does not. I took the shape of @cubic-dev-ai's suggestion rather than the literal diff, so Slack keeps the readable rendering that motivated the PR.

P3 — postReply JSDoc understates the behavior

Fixed; the docstring now describes both cases and says why threading is opt-in rather than default.

Validation: 48 tests in askable-gtm.test.mjs (3 new — relay gets raw fence-free JSON, Slack's fenced payload round-trips, an existing thread stays in that thread), full suite 324 pass / 0 fail, typecheck clean.

@codex review

@cubic-dev-ai

Copy link
Copy Markdown

Both P1s addressed in 8d65ea9. Both were correct, and the first would have shipped a real regression.

P1 — "Preserve the conversation key when starting a reply thread" (@chatgpt-codex-connector, @cubic-dev-ai)

Correct. conversationKeyForSlack keys a top-level message on the channel, so threading it in shared/slack.ts for every agent would have stranded inbox-buddy's opening turn under channel while the follow-up loaded under channel:threadTs — the prompt would lose the question it was answering. Exactly the blast radius I flagged in the description and then didn't actually contain.
...

@khaliqgant Both P1s check out against the diff — good fixes.

P1 #1 (conversation key / opt-in threading). The shape you chose is right. Making startThread opt-in in postReply and mirroring the same option in conversationKeyForSlack closes the regression cleanly: every existing caller (inbox-buddy included) passes nothing and keeps keying top-level continuity on the channel, while askable-gtm, which keeps no cross-turn Slack context, is the only opt-in and loses nothing by moving its unit to the thread. The threadTs = msg.threadTs ?? (options.startThread ? msg.ts : undefined) logic and matching key derivation also keep the "reply stays in an existing thread" path untouched. That's the right containment — the blast radius you flagged in the description is now explicit rather than accidental.

P1 #2 (relay JSON). Correct call. Fencing in handleInteractiveCommand (shared by both transports) would have broken any agent or catalog doing JSON.parse over relay — and worse, it would break silently, since a ```json prefix parses without a hard throw depending on the caller. Moving presentation behind the optional presentJson hook so only the Slack actor fences, while renderCapabilitiesJson stays standalone parseable JSON, is the right separation. The relay test asserting no fences plus the Slack round-trip test pin exactly the contract that was at risk.

P3 (JSDoc). The updated docstring on postReply/SlackReplyOptions now describes both the thread-stays case and why top-level threading is opt-in rather than default — that was the gap.

One small thing worth a second glance: presentJsonForSlack returns the modifier text outside the fence. If a future caller feeds that string back into something that tries to parse it as the payload, the lead line would trip it — but that's presentation-only and the tests correctly round-trip only the fenced slice, so it's fine as-is. No changes needed.

Nice catch on both — the threading one especially would have shipped a real inbox-buddy regression.

Tip: get faster answers by chatting with cubic’s review copilot in the review UI

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. 🚀

Reviewed commit:8d65ea9914

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@cubic-dev-aicubic-dev-aiBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 3 files (changes from recent commits).

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment threadtests/askable-gtm.test.mjs
khaliqgantand others added 2 commits September 1, 2026 12:54
The first live answer in Slack was a ~4,300-character wall. Reddit rows carry a
`title`, LinkedIn rows do not, so the renderer fell back to `body_text` — and
pasted five entire LinkedIn posts into one thread. `oneLine` collapses
whitespace but never truncates.
A citation is a pointer to a source, not a copy of it. Excerpt to 180
characters on a word boundary, and let the link carry the rest. The same answer
is now ~1,200 characters and scannable.
Also attribute LinkedIn rows by public author. Reddit rows show their
community; LinkedIn has none, so those lines carried no indication of who was
speaking — while `author.handle` was already parsed and is evidence the
capability manifest promises to surface.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SQpuAYyuSG3MWSZYnw5o1x
The test named "relay receives raw JSON while Slack receives the fenced form"
only asserted the relay half. Removing `presentJson` from the Slack actor left
it green — the exact regression it was written to catch.
Now drives `handleSlackMessage` as well and asserts the reply is threaded,
fenced, and carries byte-for-byte the same document relay received. Verified by
ablation: deleting `presentJson: presentJsonForSlack` fails this test.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SQpuAYyuSG3MWSZYnw5o1x
@khaliqgant

Copy link
Copy Markdown
MemberAuthor

@cubic-dev-ai P2 addressed in `e0a2b0f` — correct, and a fair hit.

The test asserted a claim its own name made twice and only proved once: it captured relay.dm output but never the Slack side, so removing presentJson from the Slack actor left it green. That is precisely the regression it existed to catch.

It now drives handleSlackMessage too, and asserts the Slack reply is threaded, fenced, and carries byte-for-byte the same document relay received — so the transport split is pinned from both ends rather than one.

Verified by ablation rather than assertion: deleting presentJson: presentJsonForSlack from the Slack actor now fails it (not ok 47), and restoring it passes. 50 tests in this file, 326 in the full suite, 0 failures.

Worth noting this is the third leaf-only test gap caught by review on this branch and its siblings today, all the same shape: a helper tested directly while the wiring that reaches it went unasserted. I've started ablating rather than trusting green.

@cubic-dev-ai

Copy link
Copy Markdown

@cubic-dev-ai P2 addressed in `e0a2b0f` — correct, and a fair hit.

The test asserted a claim its own name made twice and only proved once: it captured relay.dm output but never the Slack side, so removing presentJson from the Slack actor left it green. That is precisely the regression it existed to catch.

It now drives handleSlackMessage too, and asserts the Slack reply is threaded, fenced, and carries byte-for-byte the same document relay received — so the transport split is pinned from both ends rather than one.
...

@khaliqgant Agreed, that's a fair hit and e0a2b0f closes it properly. The old test only pinned the relay half of "relay gets raw JSON while Slack gets fenced" — the name promised both, the assertion proved one, and removing presentJson from the Slack actor slid right through green. That's exactly the leaf-only gap you describe.

The fix lands the right way: driving handleSlackMessage through the real handler and asserting the Slack reply is threaded, fenced, and byte-for-byte the same document relay received pins the transport split from both ends. Ablation-verified too, which is the honest way to prove a test guards a regression — deleting presentJson now fails, restoring passes. Nothing to add on the diff itself.

The pattern you're calling out is worth a habit, not just a patch: a test that names two sides and asserts one is a latent hole. Your "ablate rather than trust green" instinct is the right correction for this branch's whole cluster of same-shaped gaps.

Tip: get faster answers by chatting with cubic’s review copilot in the review UI

@cubic-dev-aicubic-dev-aiBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1 issue found across 2 files (changes from recent commits).

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="askable-gtm/agent.ts">
<violation number="1" location="askable-gtm/agent.ts:1510">
P3: When a post has a non-BMP character at the excerpt boundary, `truncateEvidence` splits its surrogate pair and sends malformed text to Slack. Count and slice Unicode code points instead of UTF-16 code units.</violation>
</file>

Tip: Review your code locally with the cubic CLI to iterate faster.

Re-trigger cubic

Comment threadaskable-gtm/agent.ts
Comment on lines +1510 to +1517
export function truncateEvidence(value: string, max = EVIDENCE_EXCERPT_MAX): string {
const single = oneLine(value);
if (single.length <= max) return single;
const clipped = single.slice(0, max);
const lastSpace = clipped.lastIndexOf(' ');
// Only honour the word boundary if it does not gut the excerpt.
const body = lastSpace > max * 0.6 ? clipped.slice(0, lastSpace) : clipped;
return `${body.replace(/[\s.,;:!?—-]+$/u, '')}…`;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: When a post has a non-BMP character at the excerpt boundary, truncateEvidence splits its surrogate pair and sends malformed text to Slack. Count and slice Unicode code points instead of UTF-16 code units.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At askable-gtm/agent.ts, line 1510:
<comment>When a post has a non-BMP character at the excerpt boundary, `truncateEvidence` splits its surrogate pair and sends malformed text to Slack. Count and slice Unicode code points instead of UTF-16 code units.</comment>
<file context>
@@ -1488,6 +1499,24 @@ function oneLine(value: string): string {
+ * to a source, not a copy of it: pasting five full LinkedIn posts into a Slack
+ * thread buries the very signal the answer exists to surface.
+ */
+export function truncateEvidence(value: string, max = EVIDENCE_EXCERPT_MAX): string {
+ const single = oneLine(value);
+ if (single.length <= max) return single;
</file context>
Suggested change
exportfunctiontruncateEvidence(value: string,max=EVIDENCE_EXCERPT_MAX): string{
constsingle=oneLine(value);
if(single.length<=max)returnsingle;
constclipped=single.slice(0,max);
constlastSpace=clipped.lastIndexOf(' ');
// Only honour the word boundary if it does not gut the excerpt.
constbody=lastSpace>max*0.6 ? clipped.slice(0,lastSpace) : clipped;
return`${body.replace(/[\s.,;:!?-]+$/u,'')}…`;
exportfunctiontruncateEvidence(value: string,max=EVIDENCE_EXCERPT_MAX): string{
constcharacters=[...oneLine(value)];
if(characters.length<=max)returncharacters.join('');
constclipped=characters.slice(0,max);
constlastSpace=clipped.lastIndexOf(' ');
// Only honour the word boundary if it does not gut the excerpt.
constbody=lastSpace>max*0.6
? clipped.slice(0,lastSpace).join('')
: clipped.join('');
return`${body.replace(/[\s.,;:!?-]+$/u,'')}…`;
}

Four lines of ceremony sat above every result: the question restated, the fetch
timestamp and raw coverage map, the credential/endpoint disclosure, and a
standing caveat about inference. All of it was either something the reader had
just typed or something they could see, and it pushed the actual findings
below the fold.
Two things survive the trim, because dropping them makes the answer dishonest
rather than merely terse:
- a missing source, now stated as a compact `(reddit unavailable this
request)` rather than a sentence. Returning LinkedIn-only results while
Reddit is 403 and saying nothing presents a partial view as a complete one,
which is the failure this persona exists to avoid.
- the access disclosure, but only when the credential is NOT the user's own.
The manifest marks managed access `disclosureRequired` because it is
metered and billable, and `unknown` discloses too — the gateway did not say
which path it used, and staying quiet about a possible charge is the wrong
way to be wrong.
Same answer as the screenshot that prompted this: ~4,300 chars → ~680.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SQpuAYyuSG3MWSZYnw5o1x

@cubic-dev-aicubic-dev-aiBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 2 files (changes from recent commits).

Tip: Review your code locally with the cubic CLI to iterate faster.

Re-trigger cubic

Comment threadaskable-gtm/agent.ts
Dropping the query header was right for the interactive path — the reader just
typed the question — but the renderer serves two callers, and I only reasoned
about one. `runWatchSweep` sends the same output as an unsolicited DM that can
land hours later beside other watches, so with the header gone the user could
not tell which saved query fired, or which id to unwatch.
The header belongs at the delivery site rather than in the renderer: the sweep
knows it is delivering unsolicited, the interactive path knows it is not.
Compact by design — query, cadence, id — so it identifies the update without
reintroducing the preamble that buried the results.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SQpuAYyuSG3MWSZYnw5o1x
@khaliqgant

Copy link
Copy Markdown
MemberAuthor

@cubic-dev-ai P2 addressed in db3b1e6 — correct, and a gap in my reasoning rather than a detail.

Dropping the query header was right for the interactive path (the reader just typed the question), but renderListenAnswer serves two callers and I only reasoned about one. runWatchSweep sends the same output as an unsolicited DM that can arrive hours later beside other watches — with the header gone, there was nothing to say which saved query fired or which id to unwatch.

Fixed at the delivery site rather than in the renderer, since that is where the distinction actually lives: the sweep knows it is delivering unsolicited, the interactive path knows it is not. The label is compact by design — New for “…” · every 6h · <id> — so it identifies the update without reintroducing the four lines of preamble that buried the results in the first place.

New test asserts both halves: a swept delivery carries query, cadence and id and is followed immediately by results; the interactive answer stays bare. 51 tests in this file, 327 in the full suite, 0 failures.

@cubic-dev-ai

Copy link
Copy Markdown

@cubic-dev-ai P2 addressed in db3b1e6 — correct, and a gap in my reasoning rather than a detail.

Dropping the query header was right for the interactive path (the reader just typed the question), but renderListenAnswer serves two callers and I only reasoned about one. runWatchSweep sends the same output as an unsolicited DM that can arrive hours later beside other watches — with the header gone, there was nothing to say which saved query fired or which id to unwatch.

Fixed at the delivery site rather than in the renderer, since that is where the distinction actually lives: the sweep knows it is delivering unsolicited, the interactive path knows it is not. The label is compact by design — New for “…” · every 6h · <id> — so it identifies the update without reintroducing the four lines of preamble that buried the results in the first place.
...

@khaliqgant Confirmed — db3b1e6 lands that one where it belongs. Putting the identifier at the delivery site is the right containment: the sweep is the only caller that knows it's unsolicited, so coupling it to renderListenAnswer (which serves the interactive path too) would have dragged the ceremony back into every answer. Keeping the renderer bare and letting runWatchSweep prepend renderWatchHeader keeps each path honest about its own context.

The shape reads well against the diff:

  • renderWatchHeader stays compact — query, cadence, id — so it names the watch without resurrecting the four-line preamble that motivated the trim.
  • Header is only prepended on fresh deliveries (fresh.length > 0), which is correct: a sweep with nothing new shouldn't announce itself.
  • renderListenAnswer dropping the query restatement (now _query) is safe for the interactive path, and the new test pins both halves — swept delivery carries query/cadence/id followed immediately by results, interactive stays bare.

Good distinction between "who is reading this" and "does this renderer know that" — the sweep knows, the renderer doesn't, so the label lives at the delivery point. Nothing to add.

Tip: get faster answers by chatting with cubic’s review copilot in the review UI

@khaliqgant
khaliqgant merged commit 2febc91 into mainSep 1, 2026
3 checks passed
@khaliqgant
khaliqgant deleted the fix/askable-gtm-slack-reply-ux branch September 1, 2026 11:28
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@khaliqgant
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

fix(slack): answer in a thread, and make the manifest readable - #121

Merged
khaliqgant merged 6 commits into
mainfrom
fix/askable-gtm-slack-reply-ux
Sep 1, 2026
Merged

fix(slack): answer in a thread, and make the manifest readable#121
khaliqgant merged 6 commits into
mainfrom
fix/askable-gtm-slack-reply-ux

Conversation

@khaliqgant

@khaliqgantkhaliqgant commented Sep 1, 2026

Copy link
Copy Markdown
Member

Both problems observed on the first live @mention after cloud#3231 deployed.

1. Replies went to the channel, not a thread

postReply only threaded when the incoming message was already in one:

constresult=msg.threadTs
? awaitslack.reply(chanId,msg.threadTs,text)
: awaitslack.post(chanId,text);// ← top-level mention dumps into the channel

That is worst exactly where agents are useful. Every agent shares one Slack identity, so a single @Agent Relay … wakes every agent watching that channel — the production dispatch for one message matched 8 deployments — and they all replied into the main channel. Now the reply threads under the incoming message, and stays in the existing thread when there is one.

2. capabilities --json was an unreadable wall

~4KB of minified JSON posted as plain text. Now pretty-printed inside a ```json fence and led by one line naming the agent and pointing at plain-language questions — nobody should have to parse JSON to learn they can just ask.

Blast radius, deliberately

The threading change is in shared/slack.ts, so it applies to every agent using that helper (hn-monitor, joke-bot, inbox-buddy, review, …). That is the intent: an agent answering a channel mention should thread. Calling it out because it is broader than the askable-gtm title suggests.

The existing test asserting a channel-level post encoded the old behaviour; it now asserts the thread, with the reasoning in a comment so it is not "fixed" back later.

Validation

  • npm run typecheck: pass
  • tests/askable-gtm.test.mjs: 46 pass, 2 new — fenced/parseable manifest, and an existing thread answered in that thread rather than a new one
  • full suite: 322 pass / 0 fail

Not fixed here

One @Agent Relay mention still wakes every agent watching the channel, because they share a Slack identity — cloud#3234 gates on the bot being mentioned, which does not disambiguate which agent is being addressed. Threading makes that liveable, not solved. Worth a follow-up.

🤖 Generated with Claude Code


Summary by cubic

Fixes four problems from the first live @mention: replies thread under the message instead of landing in the channel, capabilities --json stays parseable for machines while Slack gets a readable fenced view, cited evidence is an excerpt rather than a whole post, and answers lead with results instead of a preamble.

  • Replies thread under the incoming message, and replies to a message already in a thread stay in that thread.
  • Threading is opt-in via startThread in postReply/conversationKeyForSlack; only askable-gtm enables it because it keeps no cross-turn Slack context.
  • capabilities --json stays standalone parseable JSON over relay; the Slack actor fences and pretty-prints it via a new presentJson hook.
  • Evidence lines are excerpted to 180 characters on a word boundary, and LinkedIn rows are attributed by author handle.
  • Answers drop the question restatement and coverage preamble, keeping only a compact source-failure note and the disclosure for metered managed access.
  • Unsolicited watch deliveries get a compact header naming the query, cadence, and watch id so the reader knows which one fired and can unwatch it.
  • Tests drive both transport halves through the real handlers, so the relay/Slack split can't silently regress.
  • One @Agent Relay mention still wakes every agent watching the channel since they share a Slack identity; threading makes that liveable, not solved.

Written for commit 55f8d85. Summary will update on new commits.

Review in cubic

Two problems seen in production on the first live @mention.
Replies landed in the channel, not a thread. `postReply` only threaded when the
incoming message was ALREADY in one, so a top-level `@Agent Relay …` got a
channel-level answer. That is worst exactly where agents are useful: every
agent shares one Slack identity, so one mention draws a reply from each agent
watching the channel and they all pile into the main channel. Now the reply
threads under the incoming message, and stays in the existing thread when there
is one.
`capabilities --json` dumped ~4KB of minified JSON as plain text, which Slack
renders as an unreadable wall. Pretty-print it inside a fenced block and lead
with one line naming the agent and saying you can just ask a question in plain
language — nobody should have to parse JSON to discover that.
The threading change is in shared/slack.ts, so it applies to every agent using
that helper. That is intended: an agent answering a channel mention should
thread. The existing test asserting a channel-level `post` encoded the old
behaviour and now asserts the thread.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SQpuAYyuSG3MWSZYnw5o1x
@chatgpt-codex-connector

chatgpt-codex-connectorBot commented Sep 1, 2026

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

ReviewStatusCommitReview trigger
📝 Code ReviewCompleted2026-09-01T10:45:10.460193Z8d65ea9Manual request
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@coderabbitai

coderabbitaiBot commented Sep 1, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

Next included review available in 11 minutes.

Check out review usage here.

View limit details

Limit details: You’ve used the included review currently available.

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

Learn how review limits work.

Review configuration:

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Team

Run ID: 91a9e59a-20ff-4bc8-ab8c-a74c68244ed7

📥 Commits

Reviewing files that changed from the base of the PR and between 2cf284a and 55f8d85.

📒 Files selected for processing (3)
  • askable-gtm/agent.ts
  • shared/slack.ts
  • tests/askable-gtm.test.mjs

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Team

Run ID: d0a8e943-72fc-4d71-bfc9-327e0382122a

📥 Commits

Reviewing files that changed from the base of the PR and between 82d88be and 2cf284a.

📒 Files selected for processing (3)
  • askable-gtm/agent.ts
  • shared/slack.ts
  • tests/askable-gtm.test.mjs

Included review availability: Your plan provides up to 1 included review per hour; 0 remain after this review.


📝 Walkthrough

Walkthrough

The capabilities command now delegates formatted JSON output to an exported renderer. Slack replies now always use a thread root, including for top-level messages.

Changes

Capabilities JSON rendering

Layer / File(s)Summary
Render and validate capability manifests
askable-gtm/agent.ts, tests/askable-gtm.test.mjs
The command uses renderCapabilitiesJson. The helper emits explanatory text, fenced pretty-printed JSON, and runtime access status. Tests validate the output.

Slack threaded replies

Layer / File(s)Summary
Route replies to message threads
shared/slack.ts, tests/askable-gtm.test.mjs
postReply uses the incoming timestamp for top-level messages and preserves existing thread timestamps. Tests cover both reply paths.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Merge Risk:⚪ Minimal · up to 2cf28

Replies now stay under the relevant Slack message and capability output is easier to read without changing its content; no actionable merge-blocking risk remains after normal checks and review.

Poem

A rabbit formats JSON bright,
With fences neat and spaces right.
Slack hops into threads anew,
Keeping every reply in view.
Two tidy paths now serve the queue.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check nameStatusExplanationResolution
Docstring Coverage⚠️ WarningDocstring coverage is 40.00% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 5 functions across 3 files.Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check nameStatusExplanation
Title check✅ PassedThe title clearly summarizes both primary changes: threaded Slack replies and readable capability manifests.
Description check✅ PassedThe description directly explains the Slack threading change, manifest formatting change, scope, tests, validation, and known limitation.
Linked Issues check✅ PassedCheck skipped because no linked issues were found for this pull request.
Out of Scope Changes check✅ PassedCheck skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/askable-gtm-slack-reply-ux

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@chatgpt-codex-connectorchatgpt-codex-connectorBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit:2cf284a9cc

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment threadshared/slack.ts Outdated
Comment on lines +138 to +140
const threadTs = msg.threadTs ?? msg.ts;
const result = threadTs
? await slack.reply(chanId, threadTs, text)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve the conversation key when starting a reply thread

When Inbox Buddy handles a top-level message, conversationKeyForSlack stores that turn under the channel key, but this change posts the answer beneath msg.ts; a user's natural follow-up in that new thread then arrives with threadTs and is loaded under channel:threadTs. Consequently, the initial question and answer are absent from the follow-up prompt, breaking the multi-turn continuity implemented in inbox-buddy/agent.ts. Ensure the initial turn and its resulting thread use the same conversation key.

Useful? React with 👍 / 👎.

Comment threadaskable-gtm/agent.ts
Comment on lines +1007 to +1010
'GTM Signal Scout — machine-readable capability manifest.',
'You can also just ask a GTM question in plain language, or send'
+ ' \u201cwhat can you tell me?\u201d for the short version.',
'```json',

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Keep relay capability responses valid JSON

When an agent or catalog invokes capabilities --json over Relay, handleRelayMessage now returns these prose and fence lines around the payload, so parsing the response directly with JSON.parse fails. This affects Relay as well as Slack because both transports share handleInteractiveCommand, despite the documented contract in ASKABLE_AGENTS.md describing this command as a machine-readable relay response; apply Slack-only presentation formatting or leave the relay response as standalone JSON.

Useful? React with 👍 / 👎.

@cubic-dev-aicubic-dev-aiBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1 issue found across 3 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="shared/slack.ts">
<violation number="1" location="shared/slack.ts:138">
P3: The `postReply` JSDoc now understates the behavior: this line also threads top-level messages under their own timestamp. Update the docstring to describe both top-level and existing-thread replies.</violation>
</file>

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment threadaskable-gtm/agent.ts Outdated
Comment threadshared/slack.ts Outdated
Comment threadshared/slack.ts Outdated
// buries the conversation in the main channel, which is worst exactly where
// agents are useful — several agents share one Slack identity, so a single
// `@Agent Relay` mention can draw a reply from each of them.
const threadTs = msg.threadTs ?? msg.ts;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: The postReply JSDoc now understates the behavior: this line also threads top-level messages under their own timestamp. Update the docstring to describe both top-level and existing-thread replies.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At shared/slack.ts, line 138:
<comment>The `postReply` JSDoc now understates the behavior: this line also threads top-level messages under their own timestamp. Update the docstring to describe both top-level and existing-thread replies.</comment>
<file context>
@@ -130,10 +130,16 @@ export async function postReply(
+ // buries the conversation in the main channel, which is worst exactly where
+ // agents are useful — several agents share one Slack identity, so a single
+ // `@Agent Relay` mention can draw a reply from each of them.
+ const threadTs = msg.threadTs ?? msg.ts;
+ const result = threadTs
+ ? await slack.reply(chanId, threadTs, text)
</file context>

Both P1s from review, each flagged independently by both reviewers.
Threading was applied in shared/slack.ts for every agent, which would have
broken inbox-buddy's multi-turn context: `conversationKeyForSlack` keys a
top-level message on the CHANNEL, so answering it in a new thread stranded the
opening turn under `channel` while the follow-up loaded under `channel:ts` —
the prompt would lose the question it was answering.
Threading is now opt-in via `postReply(..., { startThread: true })`, and
`conversationKeyForSlack` takes the same option so an agent that opts in keys
continuity on the thread it created. Every existing caller passes nothing and
is byte-for-byte unchanged; only askable-gtm opts in, and it keeps no
cross-turn Slack context so moving the conversation unit costs nothing.
`capabilities --json` is documented in ASKABLE_AGENTS.md as a MACHINE-readable
relay response, and both transports share `handleInteractiveCommand` — so
fencing the payload broke `JSON.parse` for any agent or catalog calling it over
relay. The payload is standalone JSON again; presentation moved to the
transport via an optional `presentJson` hook that only the Slack actor supplies.
Tests pin both: relay receives raw parseable JSON with no fences, Slack
receives the fenced form whose payload still round-trips, and an opted-in
top-level mention threads under its own ts.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SQpuAYyuSG3MWSZYnw5o1x
@khaliqgant

Copy link
Copy Markdown
MemberAuthor

Both P1s addressed in 8d65ea9. Both were correct, and the first would have shipped a real regression.

P1 — "Preserve the conversation key when starting a reply thread" (@chatgpt-codex-connector, @cubic-dev-ai)

Correct. conversationKeyForSlack keys a top-level message on the channel, so threading it in shared/slack.ts for every agent would have stranded inbox-buddy's opening turn under channel while the follow-up loaded under channel:threadTs — the prompt would lose the question it was answering. Exactly the blast radius I flagged in the description and then didn't actually contain.

Rather than patch the key, threading is now opt-in: postReply(..., { startThread: true }), with conversationKeyForSlack taking the same option so an agent that opts in keys continuity on the thread it created. Every existing caller passes nothing and is byte-for-byte unchanged — inbox-buddy included. Only askable-gtm opts in, and it keeps no cross-turn Slack context, so moving its conversation unit to the thread costs nothing.

P1 — "Keep relay capability responses valid JSON" (@chatgpt-codex-connector, @cubic-dev-ai)

Correct, and I should have caught it: ASKABLE_AGENTS.md documents this command as a machine-readable relay response, and the manifest's own discovery.machine field advertises it. Both transports share handleInteractiveCommand, so fencing the payload broke JSON.parse for any agent or catalog calling it.

renderCapabilitiesJson returns standalone parseable JSON again. Presentation moved to the transport via an optional presentJson hook that only the Slack actor supplies — the human surface fences, the machine surface does not. I took the shape of @cubic-dev-ai's suggestion rather than the literal diff, so Slack keeps the readable rendering that motivated the PR.

P3 — postReply JSDoc understates the behavior

Fixed; the docstring now describes both cases and says why threading is opt-in rather than default.

Validation: 48 tests in askable-gtm.test.mjs (3 new — relay gets raw fence-free JSON, Slack's fenced payload round-trips, an existing thread stays in that thread), full suite 324 pass / 0 fail, typecheck clean.

@codex review

@cubic-dev-ai

Copy link
Copy Markdown

Both P1s addressed in 8d65ea9. Both were correct, and the first would have shipped a real regression.

P1 — "Preserve the conversation key when starting a reply thread" (@chatgpt-codex-connector, @cubic-dev-ai)

Correct. conversationKeyForSlack keys a top-level message on the channel, so threading it in shared/slack.ts for every agent would have stranded inbox-buddy's opening turn under channel while the follow-up loaded under channel:threadTs — the prompt would lose the question it was answering. Exactly the blast radius I flagged in the description and then didn't actually contain.
...

@khaliqgant Both P1s check out against the diff — good fixes.

P1 #1 (conversation key / opt-in threading). The shape you chose is right. Making startThread opt-in in postReply and mirroring the same option in conversationKeyForSlack closes the regression cleanly: every existing caller (inbox-buddy included) passes nothing and keeps keying top-level continuity on the channel, while askable-gtm, which keeps no cross-turn Slack context, is the only opt-in and loses nothing by moving its unit to the thread. The threadTs = msg.threadTs ?? (options.startThread ? msg.ts : undefined) logic and matching key derivation also keep the "reply stays in an existing thread" path untouched. That's the right containment — the blast radius you flagged in the description is now explicit rather than accidental.

P1 #2 (relay JSON). Correct call. Fencing in handleInteractiveCommand (shared by both transports) would have broken any agent or catalog doing JSON.parse over relay — and worse, it would break silently, since a ```json prefix parses without a hard throw depending on the caller. Moving presentation behind the optional presentJson hook so only the Slack actor fences, while renderCapabilitiesJson stays standalone parseable JSON, is the right separation. The relay test asserting no fences plus the Slack round-trip test pin exactly the contract that was at risk.

P3 (JSDoc). The updated docstring on postReply/SlackReplyOptions now describes both the thread-stays case and why top-level threading is opt-in rather than default — that was the gap.

One small thing worth a second glance: presentJsonForSlack returns the modifier text outside the fence. If a future caller feeds that string back into something that tries to parse it as the payload, the lead line would trip it — but that's presentation-only and the tests correctly round-trip only the fenced slice, so it's fine as-is. No changes needed.

Nice catch on both — the threading one especially would have shipped a real inbox-buddy regression.

Tip: get faster answers by chatting with cubic’s review copilot in the review UI

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. 🚀

Reviewed commit:8d65ea9914

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@cubic-dev-aicubic-dev-aiBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 3 files (changes from recent commits).

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment threadtests/askable-gtm.test.mjs
khaliqgantand others added 2 commits September 1, 2026 12:54
The first live answer in Slack was a ~4,300-character wall. Reddit rows carry a
`title`, LinkedIn rows do not, so the renderer fell back to `body_text` — and
pasted five entire LinkedIn posts into one thread. `oneLine` collapses
whitespace but never truncates.
A citation is a pointer to a source, not a copy of it. Excerpt to 180
characters on a word boundary, and let the link carry the rest. The same answer
is now ~1,200 characters and scannable.
Also attribute LinkedIn rows by public author. Reddit rows show their
community; LinkedIn has none, so those lines carried no indication of who was
speaking — while `author.handle` was already parsed and is evidence the
capability manifest promises to surface.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SQpuAYyuSG3MWSZYnw5o1x
The test named "relay receives raw JSON while Slack receives the fenced form"
only asserted the relay half. Removing `presentJson` from the Slack actor left
it green — the exact regression it was written to catch.
Now drives `handleSlackMessage` as well and asserts the reply is threaded,
fenced, and carries byte-for-byte the same document relay received. Verified by
ablation: deleting `presentJson: presentJsonForSlack` fails this test.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SQpuAYyuSG3MWSZYnw5o1x
@khaliqgant

Copy link
Copy Markdown
MemberAuthor

@cubic-dev-ai P2 addressed in `e0a2b0f` — correct, and a fair hit.

The test asserted a claim its own name made twice and only proved once: it captured relay.dm output but never the Slack side, so removing presentJson from the Slack actor left it green. That is precisely the regression it existed to catch.

It now drives handleSlackMessage too, and asserts the Slack reply is threaded, fenced, and carries byte-for-byte the same document relay received — so the transport split is pinned from both ends rather than one.

Verified by ablation rather than assertion: deleting presentJson: presentJsonForSlack from the Slack actor now fails it (not ok 47), and restoring it passes. 50 tests in this file, 326 in the full suite, 0 failures.

Worth noting this is the third leaf-only test gap caught by review on this branch and its siblings today, all the same shape: a helper tested directly while the wiring that reaches it went unasserted. I've started ablating rather than trusting green.

@cubic-dev-ai

Copy link
Copy Markdown

@cubic-dev-ai P2 addressed in `e0a2b0f` — correct, and a fair hit.

The test asserted a claim its own name made twice and only proved once: it captured relay.dm output but never the Slack side, so removing presentJson from the Slack actor left it green. That is precisely the regression it existed to catch.

It now drives handleSlackMessage too, and asserts the Slack reply is threaded, fenced, and carries byte-for-byte the same document relay received — so the transport split is pinned from both ends rather than one.
...

@khaliqgant Agreed, that's a fair hit and e0a2b0f closes it properly. The old test only pinned the relay half of "relay gets raw JSON while Slack gets fenced" — the name promised both, the assertion proved one, and removing presentJson from the Slack actor slid right through green. That's exactly the leaf-only gap you describe.

The fix lands the right way: driving handleSlackMessage through the real handler and asserting the Slack reply is threaded, fenced, and byte-for-byte the same document relay received pins the transport split from both ends. Ablation-verified too, which is the honest way to prove a test guards a regression — deleting presentJson now fails, restoring passes. Nothing to add on the diff itself.

The pattern you're calling out is worth a habit, not just a patch: a test that names two sides and asserts one is a latent hole. Your "ablate rather than trust green" instinct is the right correction for this branch's whole cluster of same-shaped gaps.

Tip: get faster answers by chatting with cubic’s review copilot in the review UI

@cubic-dev-aicubic-dev-aiBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1 issue found across 2 files (changes from recent commits).

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="askable-gtm/agent.ts">
<violation number="1" location="askable-gtm/agent.ts:1510">
P3: When a post has a non-BMP character at the excerpt boundary, `truncateEvidence` splits its surrogate pair and sends malformed text to Slack. Count and slice Unicode code points instead of UTF-16 code units.</violation>
</file>

Tip: Review your code locally with the cubic CLI to iterate faster.

Re-trigger cubic

Comment threadaskable-gtm/agent.ts
Comment on lines +1510 to +1517
export function truncateEvidence(value: string, max = EVIDENCE_EXCERPT_MAX): string {
const single = oneLine(value);
if (single.length <= max) return single;
const clipped = single.slice(0, max);
const lastSpace = clipped.lastIndexOf(' ');
// Only honour the word boundary if it does not gut the excerpt.
const body = lastSpace > max * 0.6 ? clipped.slice(0, lastSpace) : clipped;
return `${body.replace(/[\s.,;:!?—-]+$/u, '')}…`;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: When a post has a non-BMP character at the excerpt boundary, truncateEvidence splits its surrogate pair and sends malformed text to Slack. Count and slice Unicode code points instead of UTF-16 code units.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At askable-gtm/agent.ts, line 1510:
<comment>When a post has a non-BMP character at the excerpt boundary, `truncateEvidence` splits its surrogate pair and sends malformed text to Slack. Count and slice Unicode code points instead of UTF-16 code units.</comment>
<file context>
@@ -1488,6 +1499,24 @@ function oneLine(value: string): string {
+ * to a source, not a copy of it: pasting five full LinkedIn posts into a Slack
+ * thread buries the very signal the answer exists to surface.
+ */
+export function truncateEvidence(value: string, max = EVIDENCE_EXCERPT_MAX): string {
+ const single = oneLine(value);
+ if (single.length <= max) return single;
</file context>
Suggested change
exportfunctiontruncateEvidence(value: string,max=EVIDENCE_EXCERPT_MAX): string{
constsingle=oneLine(value);
if(single.length<=max)returnsingle;
constclipped=single.slice(0,max);
constlastSpace=clipped.lastIndexOf(' ');
// Only honour the word boundary if it does not gut the excerpt.
constbody=lastSpace>max*0.6 ? clipped.slice(0,lastSpace) : clipped;
return`${body.replace(/[\s.,;:!?-]+$/u,'')}…`;
exportfunctiontruncateEvidence(value: string,max=EVIDENCE_EXCERPT_MAX): string{
constcharacters=[...oneLine(value)];
if(characters.length<=max)returncharacters.join('');
constclipped=characters.slice(0,max);
constlastSpace=clipped.lastIndexOf(' ');
// Only honour the word boundary if it does not gut the excerpt.
constbody=lastSpace>max*0.6
? clipped.slice(0,lastSpace).join('')
: clipped.join('');
return`${body.replace(/[\s.,;:!?-]+$/u,'')}…`;
}

Four lines of ceremony sat above every result: the question restated, the fetch
timestamp and raw coverage map, the credential/endpoint disclosure, and a
standing caveat about inference. All of it was either something the reader had
just typed or something they could see, and it pushed the actual findings
below the fold.
Two things survive the trim, because dropping them makes the answer dishonest
rather than merely terse:
- a missing source, now stated as a compact `(reddit unavailable this
request)` rather than a sentence. Returning LinkedIn-only results while
Reddit is 403 and saying nothing presents a partial view as a complete one,
which is the failure this persona exists to avoid.
- the access disclosure, but only when the credential is NOT the user's own.
The manifest marks managed access `disclosureRequired` because it is
metered and billable, and `unknown` discloses too — the gateway did not say
which path it used, and staying quiet about a possible charge is the wrong
way to be wrong.
Same answer as the screenshot that prompted this: ~4,300 chars → ~680.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SQpuAYyuSG3MWSZYnw5o1x

@cubic-dev-aicubic-dev-aiBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 2 files (changes from recent commits).

Tip: Review your code locally with the cubic CLI to iterate faster.

Re-trigger cubic

Comment threadaskable-gtm/agent.ts
Dropping the query header was right for the interactive path — the reader just
typed the question — but the renderer serves two callers, and I only reasoned
about one. `runWatchSweep` sends the same output as an unsolicited DM that can
land hours later beside other watches, so with the header gone the user could
not tell which saved query fired, or which id to unwatch.
The header belongs at the delivery site rather than in the renderer: the sweep
knows it is delivering unsolicited, the interactive path knows it is not.
Compact by design — query, cadence, id — so it identifies the update without
reintroducing the preamble that buried the results.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SQpuAYyuSG3MWSZYnw5o1x
@khaliqgant

Copy link
Copy Markdown
MemberAuthor

@cubic-dev-ai P2 addressed in db3b1e6 — correct, and a gap in my reasoning rather than a detail.

Dropping the query header was right for the interactive path (the reader just typed the question), but renderListenAnswer serves two callers and I only reasoned about one. runWatchSweep sends the same output as an unsolicited DM that can arrive hours later beside other watches — with the header gone, there was nothing to say which saved query fired or which id to unwatch.

Fixed at the delivery site rather than in the renderer, since that is where the distinction actually lives: the sweep knows it is delivering unsolicited, the interactive path knows it is not. The label is compact by design — New for “…” · every 6h · <id> — so it identifies the update without reintroducing the four lines of preamble that buried the results in the first place.

New test asserts both halves: a swept delivery carries query, cadence and id and is followed immediately by results; the interactive answer stays bare. 51 tests in this file, 327 in the full suite, 0 failures.

@cubic-dev-ai

Copy link
Copy Markdown

@cubic-dev-ai P2 addressed in db3b1e6 — correct, and a gap in my reasoning rather than a detail.

Dropping the query header was right for the interactive path (the reader just typed the question), but renderListenAnswer serves two callers and I only reasoned about one. runWatchSweep sends the same output as an unsolicited DM that can arrive hours later beside other watches — with the header gone, there was nothing to say which saved query fired or which id to unwatch.

Fixed at the delivery site rather than in the renderer, since that is where the distinction actually lives: the sweep knows it is delivering unsolicited, the interactive path knows it is not. The label is compact by design — New for “…” · every 6h · <id> — so it identifies the update without reintroducing the four lines of preamble that buried the results in the first place.
...

@khaliqgant Confirmed — db3b1e6 lands that one where it belongs. Putting the identifier at the delivery site is the right containment: the sweep is the only caller that knows it's unsolicited, so coupling it to renderListenAnswer (which serves the interactive path too) would have dragged the ceremony back into every answer. Keeping the renderer bare and letting runWatchSweep prepend renderWatchHeader keeps each path honest about its own context.

The shape reads well against the diff:

  • renderWatchHeader stays compact — query, cadence, id — so it names the watch without resurrecting the four-line preamble that motivated the trim.
  • Header is only prepended on fresh deliveries (fresh.length > 0), which is correct: a sweep with nothing new shouldn't announce itself.
  • renderListenAnswer dropping the query restatement (now _query) is safe for the interactive path, and the new test pins both halves — swept delivery carries query/cadence/id followed immediately by results, interactive stays bare.

Good distinction between "who is reading this" and "does this renderer know that" — the sweep knows, the renderer doesn't, so the label lives at the delivery point. Nothing to add.

Tip: get faster answers by chatting with cubic’s review copilot in the review UI

@khaliqgant
khaliqgant merged commit 2febc91 into mainSep 1, 2026
3 checks passed
@khaliqgant
khaliqgant deleted the fix/askable-gtm-slack-reply-ux branch September 1, 2026 11:28
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@khaliqgant