Skip to content

feat(agent): route a Slack event to the run that owns the channel - #193

Open
github-actions[bot] wants to merge 21 commits into
mainfrom
feat/slack-event-resume
Open

feat(agent): route a Slack event to the run that owns the channel#193
github-actions[bot] wants to merge 21 commits into
mainfrom
feat/slack-event-resume

Conversation

@github-actions

@github-actions github-actions Bot commented Aug 30, 2026

Copy link
Copy Markdown
Contributor

Summary

A Slack Connect invitation is stored as a typed AgentAction result, so replay, history, and the timeline keep invite_id and url.

Problem

The invite existed only as a Slack side-effect. A retried call could not prove Slack already sent the invitation. History returned an untyped JSON blob. The timeline had no invite link.

Solution

AgentAction.result is a Prisma JSON column. @crm/validation/agent-action is the only read and write path.

The invite result is flat: type, invite_id, url, email, kind. externalId is Slack's invite_id when Slack returns one, otherwise the channel id.

Other actions keep the fields they already had:

  • slack.message.postchannel, ts
  • slack.channel.openchannelId
  • crm.activity.createactivityId

Files

  • packages/validation/src/agent-action.ts
  • packages/validation/src/index.ts
  • packages/validation/package.json
  • packages/validation/test/agent-action.spec.ts
  • packages/db/prisma/schema.prisma
  • packages/db/prisma/migrations/20260830083000_agent_action_result/migration.sql
  • apps/agent/agent/lib/run-runtime.ts
  • apps/agent/agent/lib/slack-invite.ts
  • apps/agent/agent/lib/slack-api.ts
  • apps/api/src/agent/agents.contracts.ts
  • apps/api/src/agent/agent-runs.service.ts
  • apps/app/components/agent-builder/agent-history.tsx
  • apps/agent/test/slack-invite.integration.spec.ts
  • apps/agent/test/slack-channel-actions.integration.spec.ts

How to verify

  1. Run bun run db:test so crm_test has agentAction.result.
  2. Run the three specs below. The channel-actions spec stores invite_id I1 and url, then replays the same call and keeps both.
  3. Run bun run lint:slop. Oxlint must report nothing.
  4. Open a run history row for a Connect invite. The timeline shows the invite URL.

Commands and results

CRM_TELEMETRY_DISABLED=1 bun test test/agent-action.spec.ts

Ran in packages/validation. 7 pass, 0 fail. 20.00ms.

CRM_TELEMETRY_DISABLED=1 bun test apps/agent/test/slack-invite.integration.spec.ts

6 pass, 0 fail. Includes invite_id: "I1". 285.00ms.

CRM_TELEMETRY_DISABLED=1 bun test apps/agent/test/slack-channel-actions.integration.spec.ts

8 pass, 0 fail. AgentAction row and replay keep invite_id I1 and url. 349.00ms.

bun run lint:slop

$ oxlint — exit 0, no findings.

Pre-push on 70da103:

  • bun run check-types — 13 successful
  • bun run lint — turbo lint successful
  • bun run lint:slop — oxlint, no findings
  • bun run test — 10 successful, 10 total

ripgrim added 16 commits August 26, 2026 11:36
Proves the load-bearing assumption behind the customer-onboarding flow:
an event that arrives from outside the CRM can wake a run that is already
parked, rather than starting a second one.

The mechanism already existed and was not being used this way.
dispatchAgentRun sends with `continuationToken: runToken(run.id)`, and
per docs/agent.md eve hands a continuation token back only when the
session is parked and will accept another turn. So resuming is the same
send with the same token.

resumeAgentRun is that send, with the guards a webhook needs, because a
Slack event arrives whenever Slack feels like it:

- a finished run is never restarted by a late event
- a run with no session yet is left alone
- an agent that is no longer LIVE is refused
- an unknown run is ignored, not thrown
- a refused send is an outcome, not an exception
- the run's own status is never touched; the runner owns that

Nine integration tests cover each. It does not decide which run an event
belongs to: AgentRun has no slackChannelId, and adding one is a schema
decision rather than spike material.

Not wired to anything yet. The Slack Events endpoint, the channel-to-run
lookup and the new action types are the next steps, and they are ordinary
work now that this holds.
Closes the gap the spike left open. An inbound event knows a channel id;
it needs a run id.

- AgentRun gains slackChannelId, indexed with status, so the lookup is
  one query rather than a scan.
- runOnSlackChannel returns only a live run, so a finished run's channel
  stops routing and a late event lands nowhere.
- claimSlackChannel writes the channel once. A run cannot be reassigned,
  so two channels cannot both point at the same run.
- The newest live run wins when a channel is genuinely reused.

Adds verifySlackSignature in @crm/auth. Slack's events endpoint is a
public POST, so the signature is the only thing standing between a
stranger and resuming somebody's run. It fails closed with no secret,
refuses a body changed by one byte, refuses another secret's signature,
and refuses a replay outside the five-minute window in either direction.
timingSafeEqual, not ===.

Migration written by hand and verified against a throwaway Postgres:
every migration applied, then `migrate diff` reports no difference. The
local database could not author it because it carries the HubSpot
migration from another branch.

25 tests. Still not wired to an HTTP route.
Adds the inbound half. AgentRun gains slackChannelId so an event that
knows a channel can find the run that owns it; slackEventInbox is the
landing table, keyed on Slack's event_id so a redelivery is a no-op
rather than a second resume.

@crm/validation/slack-events parses the envelope and answers the only
two questions the ingest needs: is this from us, and is it worth waking
an agent for. Both matter — a bot_message that woke the agent would have
it answering its own post, forever.

Migration generated by `prisma migrate dev` against a scratch database,
not written by hand. The local crm database carries another branch's
migration, which is why migrate dev refused to author against it; a
throwaway database is the way round that, not a hand-rolled file.

crm_test was rebuilt: it held a failed record from the hand-written
migration this replaces.

17 validation tests, 14 resume tests. Still no HTTP route.
POST /webhooks/slack/events. Answers Slack's setup handshake, verifies
every other request, writes an inbox row and pokes the agent.

The API decides nothing, per the rule in AGENTS.md: it stores the event
and lets the agent work out which run it belongs to and what it means.
That also happens to be what keeps the handler inside Slack's three
second budget.

Refuses by default. With no SLACK_SIGNING_SECRET the endpoint rejects
everything rather than trusting the caller, because it is a public POST
and the signature is the only thing between a stranger and resuming
somebody's run.

Answers 200 to a payload it cannot parse, to an event type we do not act
on, and to a redelivery, so Slack stops retrying instead of hammering a
shape we will never handle. Ignores anything from our own bot; without
that the agent answers its own posts forever.

The raw body is collected by a small middleware on that path alone. The
app runs with bodyParser false and express is not one of its declared
dependencies, so importing express.raw would have broken createApp at
runtime — as it did, until the tracking-collector spec caught it.

10 endpoint tests, driven by signed fixtures. No Slack workspace needed
to run them.
Closes the loop. A stored event finds the run that owns its channel and
resumes it on that run's own continuation token, so the agent carries on
from where it parked rather than starting again.

Every event settles, including the ones that go nowhere. An event whose
channel owns no live run is marked processed with a reason, not left to
be retried forever. A row already processed is skipped, so a second drain
after a redelivery does nothing. Both are the difference between an inbox
and a backlog.

The drain hangs off POST /internal/crm/dispatch, which the API already
pokes on every stored event. It is deliberately not routed through the
channel's receive: receive must return a session, and an event that
resumes nothing has none to give.

app_mention joins the actionable set. It arrives alongside
message.channels when the bot is in the channel, so it changes nothing
today, but a mention in a channel the bot has not joined is exactly how
a customer asks for help.

describe() is what the agent actually reads. It names the channel, the
user and the text, and truncates at 2000 characters so one pasted log
cannot fill a turn.

9 integration tests.
open_slack_channel is the first half of the onboarding flow. A deployed
agent names the channel in plain words, gets a tidied Slack name, and the
run claims the channel so every later message and join wakes that same
run. Without this nothing ever sets AgentRun.slackChannelId and the inbox
resumes nobody.

A name already in use gives back the existing channel instead of an
error, so a retried run lands in the channel it made the first time.

claimSlackChannel now says which channel the run watches. The claim is
write-once, so a run that opens a second channel used to be told it
succeeded while its events went elsewhere. The tool reports the truth.

app_mention is deduplicated against message. Slack sends both for one
human sentence when the bot is in the channel, which resumed the run
twice for one thing said once. A unique index on (channelId, messageTs)
stops the second at the door, and the agent is told it was mentioned
rather than spoken to.

19 tests across validation, the API and the agent.
Closes the standing gap. conversations.connect:write has been requested
from every Slack workspace since the connection shipped and nothing used
it, so a customer could never reach the channel the agent made.

One tool covers both kinds of person. An address Slack already knows is
added with conversations.invite. An address it does not know gets a Slack
Connect invitation, and the tool hands back the invitation link. The
agent does not have to know which somebody is.

already_in_channel counts as invited, so re-running the flow is quiet
rather than an error. A lookup failure that is not a missing person stops
the invitation instead of falling through to Connect, because
"reconnect Slack" and "this person is external" need different answers.

Every Slack call now goes through one caller in slack-api.ts, which owns
the timeout, the rate-limit retry and the parse. Four hand-rolled fetches
in slack-membership.ts went with it.

6 tests.
Slack stores its request URL once. A quick cloudflared tunnel invents a
new hostname every restart, so the stored URL goes stale and delivery
stops with no error anywhere: the endpoint is simply never called again.

tunnel:slack runs a named tunnel. It creates the tunnel if it is missing,
points the DNS record at it, prints the request URL and runs it.
Re-running is safe, and the hostname never changes, so Slack is
configured once.

The script refuses clearly rather than half-working: no hostname, no
cloudflared, and not signed in each say what to do next.

docs/setup.md also records that Socket Mode swallows event delivery while
still showing the request URL as Verified. That cost an afternoon.
The tools to open a channel and invite people existed with nothing
telling an agent the order to use them in, so the one ordering that
matters was left to chance: the channel must be opened with
open_slack_channel first, because a channel opened any other way is a
channel the run does not watch, and every later reply is lost.

The skill also says that waiting is the work. An agent that treats a
parked run as an unfinished job polls, reschedules, or reports success
that has not happened. A Slack Connect invitation takes a person a day to
accept, and the run is supposed to sit there.

retireExhausted now joins a once-evaluated subquery, the same shape
claimDue already used, instead of IN (SELECT ... LIMIT ...). The planner
is free to re-execute a sublink, so the row cap was a request rather than
a guarantee. Two queries doing the same job now read the same way.
…ustomer

The first live test found the real gap: a deployed run executes inside
the agent_runner subagent, which has its own sandboxed tool list, so the
root-level tools were never reachable. The run identified the deal and
the buyer, then wrote a summary because it had nothing to act with.

Copying the tools down would have skipped the discipline every other
external action follows. Opening a channel and inviting people are now
manifest-approved actions with AgentAction rows, claimed and settled by
idempotency key, so a retried run rejoins the channel it already made
instead of making a second one and inviting the customer twice.

Three guards move failure earlier, where somebody can fix it:

- A manifest with a Slack action but no slack:workspace resource no
  longer parses. It used to deploy and then fail on every single run.
- AGENT_ACTION_EXECUTORS and AGENT_ACTION_DEPENDENCIES are exhaustive
  over AgentActionType, so a new action cannot ship without a tool and a
  connection requirement. Both refused to compile until they were filled
  in, and the builder's draft schema had to learn the actions too.
- DraftAction was a hand-written twin of its own Zod schema and had
  already drifted. It is now inferred from the schema.

The event chain is verified end to end against the running agent: a
closed deal queues the task, the EVENT trigger matches, and the run is
created. The second run stops at the dependency preflight with "Connect
Slack in Settings → Connections", which is correct — Slack has never been
connected in this workspace, and the guard refuses before spending a
model call.

Test cleanup deletes AgentAction rows before runs. Without that the agent
definition survived, the user delete failed on its restricted foreign
key, and six orphaned users broke an unrelated auth spec.

7 tests.
A customer channel with only the customer in it is not a channel anybody
uses. The person who closed the deal has to be there from the start, and
asking the model to remember that would make it optional.

The run's input already names the record the event fired for, so the deal
is known without asking the agent for it. SlackMemberMatch turns the CRM
owner into a Slack user id, and the owner is invited with
conversations.invite, which works on a free workspace. Slack Connect does
not, so the customer half still needs a paid plan.

An owner Slack cannot match is reported, not thrown. The channel is
already open by then, and losing it to a missing account would leave a
real Slack channel with no run watching it.

Run input is parsed with a schema rather than read out of the Json column
by hand.
…rive

Slack refuses to save an app manifest that subscribes to app_mention
without this scope: "app_mention event is missing scope(s)". The event
was already in the actionable set, so a mention in a channel was meant to
wake a parked run and silently could not.

Found by creating the staging app from our own scope list. The manifest
would not validate.
@vercel

vercel Bot commented Aug 30, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
crm-agent Ready Ready Preview Aug 30, 2026 10:05am
crm-api Ready Ready Preview Aug 30, 2026 10:05am
crm-app Ready Ready Preview Aug 30, 2026 10:05am

Request Review

@ripgrim

ripgrim commented Aug 30, 2026

Copy link
Copy Markdown
Collaborator

CRM-13

Persist the provider reply on AgentAction so a Slack Connect invite can be verified after the fact.

CRM-13 commits: 8387329, a12b23a. Branch HEAD: d1549da.

Files

  • packages/validation/src/agent-action.ts
  • packages/validation/test/agent-action.spec.ts
  • packages/validation/src/index.ts
  • packages/validation/package.json
  • packages/db/prisma/schema.prisma
  • packages/db/prisma/migrations/20260830083000_agent_action_result/migration.sql
  • apps/agent/agent/lib/slack-invite.ts
  • apps/agent/agent/lib/run-runtime.ts
  • apps/agent/test/slack-invite.integration.spec.ts
  • apps/agent/test/slack-channel-actions.integration.spec.ts
  • apps/api/src/agent/agents.contracts.ts
  • apps/api/src/agent/agent-runs.service.ts
  • apps/app/components/agent-builder/agent-history.tsx

How to verify

  1. Apply migrations (bun run db:deploy / bun run db:test).
  2. Invite an address Slack does not know. The AgentAction row stores result.invite_id and result.url. externalId is the invite id.
  3. Replay the same tool call. The tool result still includes invite_id and url.
  4. Open the run timeline. The invite URL is the receipt, as a link.

Tests (actual)

cd packages/validation && bun test test/agent-action.spec.ts
7 pass, 0 fail
cd apps/agent && CRM_TELEMETRY_DISABLED=1 bun test test/slack-invite.integration.spec.ts test/slack-channel-actions.integration.spec.ts
14 pass, 0 fail, 30 expect() calls
bun run lint:slop
oxlint exit 0

check-types passed for @crm/validation, agent, api, app, and @crm/db.

The history contract and the run store both parse the same shape.
Naming it in two places lets them drift. One export, used on write
and every read.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

17 issues found and verified against the latest diff

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="packages/auth/src/slack-scopes.ts">

<violation number="1" location="packages/auth/src/slack-scopes.ts:36">
P3: Adding `app_mentions:read` makes the documented scope count incorrect. Update `docs/connections.md` to describe the current 17-scope catalog.</violation>
</file>

<file name="apps/agent/agent/lib/slack-api.ts">

<violation number="1" location="apps/agent/agent/lib/slack-api.ts:30">
P2: When Slack or an intermediary returns a non-2xx response with a valid-looking success envelope, `read` reports the operation as successful because it ignores `response.ok`. Require an HTTP-success response before returning `ok: true` (while retaining the rate-limit handling).</violation>
</file>

<file name="packages/validation/src/slack-events.ts">

<violation number="1" location="packages/validation/src/slack-events.ts:49">
P2: When a user sends only the app mention, `isActionable` resumes the run despite no request text. Strip Slack mention tokens before checking for remaining text.</violation>
</file>

<file name="apps/agent/agent/lib/slack-events.ts">

<violation number="1" location="apps/agent/agent/lib/slack-events.ts:88">
P2: When a batch contains multiple events for one channel, `Promise.all` resumes the same run concurrently and can deliver a later Slack message before an earlier one. Process events for each run serially in `receivedAt` order.</violation>
</file>

<file name="docs/setup.md">

<violation number="1" location="docs/setup.md:70">
P2: This section implies the named tunnel is all inbound Slack needs, but without SLACK_SIGNING_SECRET in the API process the events endpoint refuses every request, including Slack's initial URL verification. Add a step to set SLACK_SIGNING_SECRET (the Signing Secret from the Slack app's Basic Information page), or the reader reproduces the exact silent-failure failure mode the section warns about.</violation>
</file>

<file name="apps/agent/test/slack-events.integration.spec.ts">

<violation number="1" location="apps/agent/test/slack-events.integration.spec.ts:220">
P3: The "drains every pending event" test asserts only `resumed >= 2`, so it never verifies that its own three events were fully drained or that the C-nobody event was settled (processedAt set). Because drainSlackEvents reads every unprocessed inbox row in the shared DB with no team filter, the count also mixes unrelated rows, forcing the weak inequality. Assert the exact expected resume count and assert the C-nobody event has processedAt set so the test actually proves the drain behavior.</violation>
</file>

<file name="apps/agent/agent/lib/run-runtime.ts">

<violation number="1" location="apps/agent/agent/lib/run-runtime.ts:143">
P2: When an invite call has multiple addresses, replaying its idempotency key drops the `invited` and `refused` arrays and preserves only one selected invite. Persist the complete outcome set or reconstruct those fields on replay.</violation>

<violation number="2" location="apps/agent/agent/lib/run-runtime.ts:1160">
P2: When a live channel is already owned by a newer run, this reports `watching: true` for an older run that cannot receive its events. Compute `watching` from the actual routed owner or reject reuse by another live run.</violation>

<violation number="3" location="apps/agent/agent/lib/run-runtime.ts:1161">
P2: When the automatic deal-owner invitation throws, this catch silently marks channel opening successful without inviting the owner. Preserve the failure or return an explicit failed owner outcome so the run can retry or report it.</violation>
</file>

<file name="apps/agent/test/slack-invite.integration.spec.ts">

<violation number="1" location="apps/agent/test/slack-invite.integration.spec.ts:39">
P2: These tests depend on the spec's own slack account being the only (or the most recently updated) slack account in the DB, because slackAccessToken() picks the newest slack account globally. Test 6 deletes only the fixed ACCOUNT_ID and asserts 'Slack is not connected.', so any other slack account left in a shared test DB (or created in parallel by the other slack `.integration.spec` files in this directory) makes it flaky, and the success-path tests can select the wrong account. Use a unique per-run id (e.g. crypto.randomUUID() suffixed, as slack-channel-actions.integration.spec.ts does) instead of fixed constants.</violation>
</file>

<file name="apps/agent/agent/subagents/agent_builder/lib/draft-input.ts">

<violation number="1" location="apps/agent/agent/subagents/agent_builder/lib/draft-input.ts:59">
P1: When a builder request needs Slack channel onboarding, this schema accepts `slack.channel.open` and `slack.channel.invite`, but the builder instructions still list only three executable action types. Update the builder prompt and integration guidance so the model can emit and save these capabilities.</violation>
</file>

<file name="apps/api/src/slack/slack-events.controller.ts">

<violation number="1" location="apps/api/src/slack/slack-events.controller.ts:61">
P2: When the signed body is not valid JSON, `JSON.parse(body || "null")` throws a SyntaxError and the endpoint returns 500, so Slack retries the delivery. The endpoint elsewhere fails open with 200 on shapes it cannot read. Wrap the parse so malformed JSON also returns `{ ok: true }` (or an explicit 200) instead of throwing.</violation>
</file>

<file name="apps/agent/agent/lib/agent-actions.ts">

<violation number="1" location="apps/agent/agent/lib/agent-actions.ts:11">
P2: When a run declares `slack.channel.open` or `slack.channel.invite` but never calls it, the required-action failure is persisted as a CRM action. Classify all Slack action types as `provider: "slack"` in `requiredActionFailure` so the action ledger retains the correct provider.</violation>
</file>

<file name="scripts/slack-tunnel.sh">

<violation number="1" location="scripts/slack-tunnel.sh:11">
P2: An inline `.env` comment becomes part of `hostname`. Cloudflare rejects the DNS route. Strip comments before routing.</violation>
</file>

<file name="apps/agent/agent/lib/run-resume.ts">

<violation number="1" location="apps/agent/agent/lib/run-resume.ts:124">
P2: When `channelId` is blank, `claimSlackChannel` persists an empty Slack channel ID even though routing treats blank IDs as absent. Reject blank IDs before `updateMany` so malformed channel responses cannot create inconsistent, non-routable ownership.</violation>
</file>

<file name="apps/agent/agent/lib/slack-membership.ts">

<violation number="1" location="apps/agent/agent/lib/slack-membership.ts:203">
P2: When inventory has retired an old row with this name, the `name_taken` fallback can return that stale channel instead of the visible channel. Restrict this lookup to `available: true`.</violation>

<violation number="2" location="apps/agent/agent/lib/slack-membership.ts:240">
P1: When Slack reuses an existing channel, this early return skips joining the bot to it. Join the returned channel and propagate a failed `JoinOutcome` before reporting the channel as opened.</violation>
</file>

Tip: instead of fixing issues one by one fix them all with cubic

Re-trigger cubic

Comment thread apps/api/src/agent/agent-trigger.service.ts Outdated
Comment thread apps/agent/agent/lib/slack-events.ts Outdated
if (!parsed.success)
return { error: "Slack sent back something unreadable." };
if (!outcome.ok) {
if (outcome.error === NAME_TAKEN) return channelNamed(name);

@cubic-dev-ai cubic-dev-ai Bot Aug 30, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P1: When Slack reuses an existing channel, this early return skips joining the bot to it. Join the returned channel and propagate a failed JoinOutcome before reporting the channel as opened.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/agent/agent/lib/slack-membership.ts, line 240:

<comment>When Slack reuses an existing channel, this early return skips joining the bot to it. Join the returned channel and propagate a failed `JoinOutcome` before reporting the channel as opened.</comment>

<file context>
@@ -238,25 +229,23 @@ export async function createSlackChannel(
-	if (!parsed.success)
-		return { error: "Slack sent back something unreadable." };
+	if (!outcome.ok) {
+		if (outcome.error === NAME_TAKEN) return channelNamed(name);
+		return { error: explain(outcome.error) };
+	}
</file context>
Fix with cubic

Comment thread apps/agent/test/custom-agent-runtime.spec.ts Outdated
}),
}),
z.object({
type: z.literal(AGENT_ACTION_TYPES.SLACK_CHANNEL_OPEN),

@cubic-dev-ai cubic-dev-ai Bot Aug 30, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P1: When a builder request needs Slack channel onboarding, this schema accepts slack.channel.open and slack.channel.invite, but the builder instructions still list only three executable action types. Update the builder prompt and integration guidance so the model can emit and save these capabilities.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/agent/agent/subagents/agent_builder/lib/draft-input.ts, line 59:

<comment>When a builder request needs Slack channel onboarding, this schema accepts `slack.channel.open` and `slack.channel.invite`, but the builder instructions still list only three executable action types. Update the builder prompt and integration guidance so the model can emit and save these capabilities.</comment>

<file context>
@@ -55,8 +55,20 @@ const action = z.discriminatedUnion("type", [
 		}),
 	}),
+	z.object({
+		type: z.literal(AGENT_ACTION_TYPES.SLACK_CHANNEL_OPEN),
+		provider: z.literal("slack"),
+		summary: z.string().trim().min(1).max(240),
</file context>
Fix with cubic

Comment thread apps/agent/agent/subagents/agent_builder/lib/draft-input.ts
Comment thread apps/api/src/agent/agent-runs.service.ts Outdated
}

const envelope = schemas.slackEvents.slackEnvelope.safeParse(
JSON.parse(body || "null"),

@cubic-dev-ai cubic-dev-ai Bot Aug 30, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2: When the signed body is not valid JSON, JSON.parse(body || "null") throws a SyntaxError and the endpoint returns 500, so Slack retries the delivery. The endpoint elsewhere fails open with 200 on shapes it cannot read. Wrap the parse so malformed JSON also returns { ok: true } (or an explicit 200) instead of throwing.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/api/src/slack/slack-events.controller.ts, line 61:

<comment>When the signed body is not valid JSON, `JSON.parse(body || "null")` throws a SyntaxError and the endpoint returns 500, so Slack retries the delivery. The endpoint elsewhere fails open with 200 on shapes it cannot read. Wrap the parse so malformed JSON also returns `{ ok: true }` (or an explicit 200) instead of throwing.</comment>

<file context>
@@ -0,0 +1,90 @@
+		}
+
+		const envelope = schemas.slackEvents.slackEnvelope.safeParse(
+			JSON.parse(body || "null"),
+		);
+
</file context>
Fix with cubic

sensitive: false,
},
{
scope: "app_mentions:read",

@cubic-dev-ai cubic-dev-ai Bot Aug 30, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P3: Adding app_mentions:read makes the documented scope count incorrect. Update docs/connections.md to describe the current 17-scope catalog.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/auth/src/slack-scopes.ts, line 36:

<comment>Adding `app_mentions:read` makes the documented scope count incorrect. Update `docs/connections.md` to describe the current 17-scope catalog.</comment>

<file context>
@@ -32,6 +32,12 @@ export const SLACK_SCOPES: readonly SlackScope[] = [
 		sensitive: false,
 	},
+	{
+		scope: "app_mentions:read",
+		group: "read",
+		grant: "See messages that mention it, so somebody can ask it for help",
</file context>
Fix with cubic


const resumed = await drainSlackEvents(send);

expect(resumed).toBeGreaterThanOrEqual(2);

@cubic-dev-ai cubic-dev-ai Bot Aug 30, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P3: The "drains every pending event" test asserts only resumed >= 2, so it never verifies that its own three events were fully drained or that the C-nobody event was settled (processedAt set). Because drainSlackEvents reads every unprocessed inbox row in the shared DB with no team filter, the count also mixes unrelated rows, forcing the weak inequality. Assert the exact expected resume count and assert the C-nobody event has processedAt set so the test actually proves the drain behavior.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/agent/test/slack-events.integration.spec.ts, line 220:

<comment>The "drains every pending event" test asserts only `resumed >= 2`, so it never verifies that its own three events were fully drained or that the C-nobody event was settled (processedAt set). Because drainSlackEvents reads every unprocessed inbox row in the shared DB with no team filter, the count also mixes unrelated rows, forcing the weak inequality. Assert the exact expected resume count and assert the C-nobody event has processedAt set so the test actually proves the drain behavior.</comment>

<file context>
@@ -0,0 +1,272 @@
+
+		const resumed = await drainSlackEvents(send);
+
+		expect(resumed).toBeGreaterThanOrEqual(2);
+	});
+});
</file context>
Fix with cubic

@ripgrim

ripgrim commented Aug 30, 2026

Copy link
Copy Markdown
Collaborator

Tex first pass fail (localhost :3000 slug crm)

Drilled Customer Onboarding on local, not trycrm.ai.

What I ran

  • Connect invite through inviteToRunSlackChannel on channel C0BT779MMJT (address Slack did not know).
  • Same callId again (replay).
  • Member invite of a workspace user.

Result

Case persisted invite_id url replay
Connect yes, SUCCEEDED yes (I0BT7FVJPFH) missing same invite_id
Member yes, SUCCEEDED none (expected) none n/a

Slack conversations.inviteShared defaults external_limited=true, which omits url. inviteGuest posts { channel, emails } and never sets external_limited: false. So the provider reply has no url to persist. Unit tests mock a url Slack did not send.

UI

apps/app/components/agent-builder/agent-history.tsx actionReceipt:

  • links only when result.url exists
  • otherwise dumps externalId

Live SSR/history for the new run: invite id in the payload, no join.slack.com / result.url. Row cannot show invite_id and a clickable url.

Old Tripwire invite rows still have result: null (pre-persist). Receipt is the channel id.

Done looks like

  1. Connect invite stores invite_id and a url (likely external_limited: false on inviteShared, unless Slack still withholds it).
  2. Timeline row shows both: invite_id text and a clickable url.
  3. Member invite still succeeds with no url.
  4. Replay returns the same ids.

Mac Chrome screenshot of the live page was blocked from this environment (localhost is on the laptop; box desktop cannot see it). Evidence is live REST + SSR, not a pixel shot. Put before/after on this PR once the row actually renders both.

conversations.inviteShared defaults external_limited to true, so Slack omits url. The request now sets external_limited false. The timeline shows invite_id and the url Slack returned.
@ripgrim

ripgrim commented Aug 30, 2026

Copy link
Copy Markdown
Collaborator

Tex retest pass (42b85a0, localhost :3000 slug crm)

New Connect invite after the fix (not I0BT7FVJPFH):

  • stored invite_id I0BUH8V1K40 and a join.slack.com url
  • replay of the same call returned the same ids
  • member invite of a workspace user succeeded with no url
  • old Connect invite I0BT7FVJPFH still has no url

History API and the agent page SSR payload both include invite_id + url on the new row. actionReceipt renders both when url exists.

Live Chrome pixel shots of the expanded row were not captured from this environment (Mac Chrome window capture is blocked). Functional path is green.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

1 issue found across 5 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="apps/agent/agent/lib/slack-invite.ts">

<violation number="1" location="apps/agent/agent/lib/slack-invite.ts:73">
P2: For conversations.inviteShared, external_limited defaults to true (limited invite) and Slack only returns url/conf_code when it is false, so false is the correct value to obtain the invite URL. But Slack's docs state that when the workspace disallows fully-shared invites, external_limited=false returns restricted_action, so any workspace that previously received a working limited invite will now fail the whole action instead. The code already surfaces that as a failure via explain, but consider confirming this hard-fail-on-restricted_action tradeoff is intended for workspaces that only permit limited invites.</violation>
</file>

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

Fix all with cubic | Re-trigger cubic

const outcome = await slackPost(
token,
"conversations.inviteShared",
{ channel: channelId, emails: [email], external_limited: false },

@cubic-dev-ai cubic-dev-ai Bot Aug 30, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2: For conversations.inviteShared, external_limited defaults to true (limited invite) and Slack only returns url/conf_code when it is false, so false is the correct value to obtain the invite URL. But Slack's docs state that when the workspace disallows fully-shared invites, external_limited=false returns restricted_action, so any workspace that previously received a working limited invite will now fail the whole action instead. The code already surfaces that as a failure via explain, but consider confirming this hard-fail-on-restricted_action tradeoff is intended for workspaces that only permit limited invites.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/agent/agent/lib/slack-invite.ts, line 73:

<comment>For conversations.inviteShared, external_limited defaults to true (limited invite) and Slack only returns url/conf_code when it is false, so false is the correct value to obtain the invite URL. But Slack's docs state that when the workspace disallows fully-shared invites, external_limited=false returns restricted_action, so any workspace that previously received a working limited invite will now fail the whole action instead. The code already surfaces that as a failure via explain, but consider confirming this hard-fail-on-restricted_action tradeoff is intended for workspaces that only permit limited invites.</comment>

<file context>
@@ -70,7 +70,7 @@ async function inviteGuest(
 		token,
 		"conversations.inviteShared",
-		{ channel: channelId, emails: [email] },
+		{ channel: channelId, emails: [email], external_limited: false },
 		schemas.slack.inviteShared,
 	);
</file context>
Fix with cubic

… an event

A public Slack POST was unbounded, a failed inbox write looked like a duplicate, and two drains could resume the same row.
The events path now uses Express raw middleware. Without Express as a direct dependency, createApp cannot start in tests.
@ripgrim

ripgrim commented Aug 30, 2026

Copy link
Copy Markdown
Collaborator

Tex retest pass (bc1a4b3)

Inbox-lease migration already applied locally.

CRM-13 path still holds on a new Connect invite: invite_id I0BTQS5M00H + join.slack.com url, replay matched, member invite no url. History REST 200 with both rows.

Reed list, as drilled:

  • fire-deal-closed.ts gone
  • raw Slack POST over 64kb: PayloadTooLargeError (Nest surfaced 500, body was not kept)
  • small Slack POST: 401 verify, as expected
  • drain claims via leasedUntil before resume (code + migration)
  • inbox insert rethrows unless P2002
  • bad result maps to null in history

:3000 was down this pass, so no live app screenshot. API was started only for the webhook/history checks, then stopped.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

2 issues found across 16 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="apps/api/test/slack-events.spec.ts">

<violation number="1" location="apps/api/test/slack-events.spec.ts:187">
P2: This test asserts on the source text of create-app.ts (via Bun.file) rather than any runtime behavior, so it passes even when the body cap is misconfigured and breaks on any unrelated formatting or refactor. Exercise the real endpoint instead: build the app, POST an over-limit body to SLACK_EVENTS_PATH, and assert a 413, plus a normal payload that still verifies the signature.</violation>
</file>

<file name="apps/api/src/agent/agent-runs.service.ts">

<violation number="1" location="apps/api/src/agent/agent-runs.service.ts:450">
P2: `listedActionResult` swallows every parse failure and returns null, so a result that fails `readAgentActionResult` (an action type missing from the validation registry, stale/corrupt JSON, or a type mismatch) is silently dropped from run history. The list response cannot then distinguish "no result" from "result we could not parse," which hides exactly the invite URLs and other details this PR is meant to surface, and leaves no trace that data is being lost. Log the caught error (for example a warning) before returning null so malformed stored results are visible instead of silently disappearing.</violation>
</file>

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

Fix all with cubic | Re-trigger cubic

});

describe("the Slack events body cap", () => {
it("uses Express raw middleware with an explicit size limit, then verifies", async () => {

@cubic-dev-ai cubic-dev-ai Bot Aug 30, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2: This test asserts on the source text of create-app.ts (via Bun.file) rather than any runtime behavior, so it passes even when the body cap is misconfigured and breaks on any unrelated formatting or refactor. Exercise the real endpoint instead: build the app, POST an over-limit body to SLACK_EVENTS_PATH, and assert a 413, plus a normal payload that still verifies the signature.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/api/test/slack-events.spec.ts, line 187:

<comment>This test asserts on the source text of create-app.ts (via Bun.file) rather than any runtime behavior, so it passes even when the body cap is misconfigured and breaks on any unrelated formatting or refactor. Exercise the real endpoint instead: build the app, POST an over-limit body to SLACK_EVENTS_PATH, and assert a 413, plus a normal payload that still verifies the signature.</comment>

<file context>
@@ -182,3 +182,17 @@ describe("the Slack events endpoint", () => {
 });
+
+describe("the Slack events body cap", () => {
+	it("uses Express raw middleware with an explicit size limit, then verifies", async () => {
+		const source = await Bun.file(
+			new URL("../src/create-app.ts", import.meta.url),
</file context>
Fix with cubic

}
}

function listedActionResult(type: string, value: Prisma.JsonValue | null) {

@cubic-dev-ai cubic-dev-ai Bot Aug 30, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2: listedActionResult swallows every parse failure and returns null, so a result that fails readAgentActionResult (an action type missing from the validation registry, stale/corrupt JSON, or a type mismatch) is silently dropped from run history. The list response cannot then distinguish "no result" from "result we could not parse," which hides exactly the invite URLs and other details this PR is meant to surface, and leaves no trace that data is being lost. Log the caught error (for example a warning) before returning null so malformed stored results are visible instead of silently disappearing.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/api/src/agent/agent-runs.service.ts, line 450:

<comment>`listedActionResult` swallows every parse failure and returns null, so a result that fails `readAgentActionResult` (an action type missing from the validation registry, stale/corrupt JSON, or a type mismatch) is silently dropped from run history. The list response cannot then distinguish "no result" from "result we could not parse," which hides exactly the invite URLs and other details this PR is meant to surface, and leaves no trace that data is being lost. Log the caught error (for example a warning) before returning null so malformed stored results are visible instead of silently disappearing.</comment>

<file context>
@@ -446,3 +446,11 @@ export class AgentRunsService {
 	}
 }
+
+function listedActionResult(type: string, value: Prisma.JsonValue | null) {
+	try {
+		return readAgentActionResult(type, value);
</file context>
Fix with cubic

@ripgrim

ripgrim commented Aug 30, 2026

Copy link
Copy Markdown
Collaborator

Tex post-copy pass (40d08e8)

New Connect invite stored summary Invited 1 person, invite_id I0BTM2FKCVC, and a join.slack.com url.

Timeline receipt in actionReceipt: invite id, then a Link whose text is Invite link and whose href is still the url. No url still shows just the invite id.

History REST 200. Agent page SSR includes Invited 1 person. Old rows still have stored Invite 1 to C0… summaries (not rewritten). Invite link is client-rendered on expand, so it is not in the initial HTML.

Cal strings for errors, transcript, and builder grants are in the diff.

Sign up for free to 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