feat(integrations): propagate Slack sender identity into the agent prompt#224
feat(integrations): propagate Slack sender identity into the agent prompt#224oss-agent-shin wants to merge 4 commits into
Conversation
…ompt
When a user @-mentioned the bot or DM'd it, the harness saw only the
stripped message text — no idea who wrote it. Agents that want to address
the user back ("hi <@U…>") or @-mention them in a thread reply had to
guess, and Shin's system prompt's promise that "the current user's Slack
handle and user ID will be passed in via context" never held.
This wires the sender all the way through:
* `IntegrationSender` lives on `IntegrationEvent` (new_task / followup /
message). Always carries provider + id; handle and display_name are
best-effort. New helper `withSenderHeader()` (core/sender.ts) renders
a one-line `[from: <@U…> handle=… name="…" via slack]` prefix.
* Slack webhook adapter now captures `event.user` and best-effort
resolves it via `users.info` (`core/slack/users.ts` with a 5-minute
LRU). Missing scope / API errors degrade silently to id-only.
* `users:read` added to both the manifest and the OAuth scopes list.
Existing installs without the scope continue to work — they just lose
the friendly handle until reinstall.
* Dispatcher threads `sender` through both spawn and followup paths.
* `CreateSessionBody` and `SendMessageBody` accept an optional
`sender`; the session-create and message routes apply the header
before handing the prompt to the harness. Direct API / UI callers
that omit `sender` see zero behavior change.
The `parts[]` path on `/sessions/{id}/message` intentionally skips
header injection — UI callers building harness parts explicitly opt out.
Also brings `files:read` and `reactions:write` into oauth.ts's SCOPES
list so OAuth-flow installs match what manifest-flow installs already
declare.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Greptile SummaryThis PR wires Slack sender identity — user id, handle, and display name — from the webhook adapter all the way to the agent's harness prompt via a sanitized
Confidence Score: 4/5Safe to merge after moving The single actionable defect is in
|
| Filename | Overview |
|---|---|
| src/server/integrations/providers/slack/users.ts | New LRU-cached users.info resolver; decrypt is called outside the try-catch, so a crypto failure breaks all webhook parsing for the install rather than degrading gracefully. |
| src/server/integrations/core/sender.ts | New file; sanitization correctly strips structural delimiters and control characters before embedding user-controlled values into the header. |
| src/server/integrations/providers/slack/webhook.ts | Adds buildSender and threads sender through the single parse path covering both app_mention and DM events; correct. |
| src/server/integrations/core/dispatcher.ts | Sender is correctly forwarded through all three dispatch paths: new_task, followup, and message. |
| src/server/types.ts | Adds optional sender: SenderBody to both CreateSessionBody and SendMessageBody Zod schemas with length-bounded fields. |
Reviews (2): Last reviewed commit: "Merge remote-tracking branch 'origin/mai..." | Re-trigger Greptile
… in header display_name/handle/id/provider are user-controlled (Slack profile fields or direct-API sender values). Strip header delimiters ([, ], ") and collapse control chars/newlines before embedding in the [from: ...] header so a hostile value can't break out and inject free-standing prompt text. Also bound each field's length in the SenderBody zod schema.
The cache was documented as LRU but evicted FIFO — readCache never touched insertion order, so a hot early entry was dropped before a cold recent one. Promote hits by delete+re-insert so writeCache evicts the genuinely least-recently-used key.
…ntity # Conflicts: # src/server/integrations/core/types.ts # src/server/integrations/providers/slack/oauth.ts # src/server/integrations/providers/slack/webhook.ts
|
@greptileai review |
| const cached = readCache(team_id, user_id); | ||
| if (cached) return cached; | ||
|
|
||
| const token = decrypt(install.access_token); | ||
|
|
||
| try { |
There was a problem hiding this comment.
decrypt is called at line 104, before the try block begins at line 106. If decrypt throws for any reason (key rotation, corrupted ciphertext in the DB, unexpected token format), the exception propagates up through buildSender and straight out of parse, causing the webhook handler to return a 5xx. Slack treats 5xx as retriable, so every subsequent message from that install would also fail hard and retry infinitely until the underlying issue is fixed — rather than simply falling back to id-only sender resolution as intended. Moving the decryption inside the try block restores the graceful-degradation contract the function promises.
| const cached = readCache(team_id, user_id); | |
| if (cached) return cached; | |
| const token = decrypt(install.access_token); | |
| try { | |
| const cached = readCache(team_id, user_id); | |
| if (cached) return cached; | |
| try { | |
| const token = decrypt(install.access_token); |
Summary
When a user @-mentions the bot or DMs it, the agent's harness receives only the stripped message text — it has no idea who wrote it. Agents that want to address the user back (
hi <@U…>) or @-mention them in a thread reply have to guess. Shin's system prompt's promise that "the current user's Slack handle and user ID will be passed in via context" never actually held — there was no wiring for it.This PR threads sender identity from the Slack webhook all the way to the harness prompt.
What's new
IntegrationSendertype on every inbound event variant (new_task,followup,message). Always carriesprovider+id;handleanddisplay_nameare best-effort.withSenderHeader()helper (src/server/integrations/core/sender.ts) renders a one-line prefix the harness can read:event.userand best-effort resolves it viausers.info(src/server/integrations/providers/slack/users.ts, with a 5-minute LRU keyed by(team, user)).users:readscope added to the manifest ANDoauth.tsSCOPES so the OAuth flow requests it too. Existing installs without the scope continue to work — the lookup degrades silently to id-only.senderthrough both the spawn and followup paths.CreateSessionBody+SendMessageBodyaccept an optionalsender; the routes apply the header before handing the prompt to the harness. Direct API / UI callers that omitsendersee zero behavior change.Also cleaned up an inconsistency where
oauth.tsSCOPES was missingfiles:readandreactions:write(they were in the manifest but not in the OAuth flow's request).Why a header (and not e.g. a separate harness field)?
A header on the user-visible text part is the minimum-blast-radius change — no harness wire-format changes, works with the existing claude-agent-sdk and opencode harnesses, and the agent's system prompt can simply describe how to parse it.
What this fixes for Shin (and any agent with similar prompt)
Shin's system prompt says:
With this change, on every Slack-originated turn the agent now sees
[from: <@U…> handle=… name="…" via slack]at the top of the prompt — enough to actually fulfill that instruction.Test plan
users:readis granted) and verifyusers.infolookups succeed in logs.[from: <@U…> …]header (checkSession.historyafter the first turn)./sessions/{id}/message.users:read): verify lookup fails gracefully and the header still shows the raw id only.POST /api/v1/managed_agents/agents/{id}/sessionwithoutsender— verify the prompt is unmodified.🤖 Generated with Claude Code