From 71af2fbc3bac1f9adc7a3a0fc4ef7b74c5ad3db7 Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Thu, 8 Oct 2026 10:28:14 -0500 Subject: [PATCH] fix(slack): teach agents how people connect their accounts (#15576) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Slack connections let people talk to an agent with their own Paperclip permissions. > - Each bot has a saved command that starts account linking. > - The Access page explains this flow, but Slack agent turn context omitted it. > - An agent could guess the command or confuse channel membership with Paperclip access. > - This pull request gives agents the saved command and account confirmation instructions. > - People can ask the agent how to join without changing access controls. ## Linked Issues or Issue Description **What happened?** Slack agent context explained messages, tools, and questions. It did not explain how a teammate can connect an account. The saved slash command can differ from the current agent name. **Expected behavior** Give the saved bot command, such as `/research_ops connect`. The teammate runs it themselves. They sign in through a private confirmation link. A new company member must receive admin approval before account confirmation. The connection manager can copy instructions from Access → Invite people. **Steps to reproduce** 1. Configure a Slack bot with a custom slash command. 2. Rename its assigned agent. 3. Inspect a fresh or resumed Slack task prompt. Before this change, it contains no account invitation instructions or saved connect command. **Paperclip version or commit** Base: `d6df12cef69fcaf2d2fe66a393168931d5b8b4e7`. **Deployment mode** Applies to local and hosted Slack chat connections. Deterministic tests used a local isolated database. The new model probe has not run against a live Slack bot. Related work: Refs #15413 and #13638. This fixes agent guidance for their existing account-linking flow. It adds no new membership system or invitation endpoint. ## What Changed - Read only the saved public slash command from the company-scoped conversation endpoint. Supply it only to the assigned agent for Slack turns with an active or verifying connection. - Validate the command with the shared Slack configuration schema. Refer to Access → Invite people when it is missing or invalid. Never guess from the current agent name. - Explain personal account confirmation, link expiry, and company membership approval on fresh and resumed turns. Distinguish channel invitations from Paperclip access. - Add deterministic guidance regressions, a manual invitation model probe, and setup documentation. ## Verification - Passed: 63 tests in `heartbeat-context-summary.test.ts` and `heartbeat-chat-task-link.test.ts`. - Passed: four real-heartbeat regressions in `heartbeat-slack-invitation.test.ts`. They check the saved command after an agent rename, a persisted resumed session, full and compact prompts, missing-command fallback, inactive endpoints, and a different assigned agent. They also reject caller-supplied command fields and exclude other setup metadata. - Passed: the isolated `chat-channels.integration.test.ts` case `discovers a Slack connect identity without starting work or granting access`. It covers the private link, duplicate connect requests, and nonmember access requests without a membership grant. - Passed: `pnpm --filter @paperclipai/server typecheck` and `pnpm --filter @paperclipai/server build`. - Passed: `pnpm test:slack-connector --list` and `git diff --check`. - Passed: repository `pnpm -r typecheck`, `pnpm build`, and `pnpm check:token-gates`. Typecheck required local IPC access for the migration check. - Passed: final-commit CI, with 54 successful checks and two optional Storybook checks skipped. CI includes all server, chat, workspace, serialized, Runner, and browser test shards, typecheck, build, and canary dry run. - The full local `pnpm test:run` was started. It was stopped after full CI passed; it had no final local summary. The focused invitation checks passed locally. Do not count the interrupted local run as a full-suite pass. - Greptile gave the exact final commit `f37e52bde621ef7f5b2bb345074d53345f8e4ee9` a 5/5 score. Both review findings were fixed and their threads resolved. - The new `invite-person` model probe is manual. These deterministic results do not establish a live model or Slack acceptance pass. ## Risks - Model guidance cannot prove that a person joined. The existing account-linking and membership checks remain authoritative. - Legacy rows without a saved command use the Access page fallback. Invalid command text is excluded from the prompt. - The query selects only the public command. It does not expose registration secrets, tokens, or personal confirmation links. - No schema changes, new provider requests, permission grants, or telemetry changes. ## Model Used - OpenAI GPT-6 through Codex. The exact backend variant and context window are not exposed in this session. Used reasoning, repository inspection, code editing, and test execution. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip --- doc/connections/SLACK-AUTOMATIC-SETUP.md | 9 ++ .../heartbeat-context-summary.test.ts | 37 +++++ .../heartbeat-slack-invitation.test.ts | 142 ++++++++++++++++++ .../connectors/slack/agent-guidance.ts | 12 +- .../services/connectors/slack/evals/README.md | 6 +- .../connectors/slack/evals/cases.json | 9 ++ server/src/services/heartbeat.ts | 5 + .../src/services/heartbeat/task-markdown.ts | 3 +- 8 files changed, 219 insertions(+), 4 deletions(-) create mode 100644 server/src/__tests__/heartbeat-slack-invitation.test.ts diff --git a/doc/connections/SLACK-AUTOMATIC-SETUP.md b/doc/connections/SLACK-AUTOMATIC-SETUP.md index 67b8cf66725..8ac45b0b234 100644 --- a/doc/connections/SLACK-AUTOMATIC-SETUP.md +++ b/doc/connections/SLACK-AUTOMATIC-SETUP.md @@ -83,6 +83,15 @@ membership approval. Existing members can self-link; disconnecting an identity does not permanently block that member from reconnecting. Guest access remains a separate, explicit setting for restricted work. +The person joining runs `/ connect` themselves (for example, +`/maya connect`), without an `@person` argument. The command discovers their +identity without starting agent work or granting access. Slack conversation +agents receive this saved command on each fresh or resumed turn, alongside the +confirmation and membership-approval instructions. If the saved command is +unavailable, they refer the connection manager to **Access → Invite people** +instead of guessing from the agent name. Inviting a person or bot to a Slack +channel does not grant that person Paperclip access. + **Allowed Channels** updates every five seconds while Slack Settings is open. Inviting the bot to a new channel enables replies and writes there automatically; no additional Paperclip configuration is required. An explicit off switch stays diff --git a/server/src/__tests__/heartbeat-context-summary.test.ts b/server/src/__tests__/heartbeat-context-summary.test.ts index 51437733048..70930db8cf7 100644 --- a/server/src/__tests__/heartbeat-context-summary.test.ts +++ b/server/src/__tests__/heartbeat-context-summary.test.ts @@ -318,6 +318,42 @@ describe("buildPaperclipTaskMarkdown", () => { } }); + it.each([true, false])("teaches Slack account invitations with the saved command on fresh and resumed turns (native=%s)", (nativeRunner) => { + for (const includeDescription of [true, false]) { + const markdown = buildPaperclipTaskMarkdown({ + issue: { id: "slack-invitation", title: "Invite a teammate", workMode: "standard" }, + externalChatProvider: "slack", slackCommand: "/research_ops", nativeRunner, includeDescription, + }); + expect(markdown).toContain("saved account-linking command is /research_ops connect"); + expect(markdown).toContain("without an @person argument"); + expect(markdown).toContain("Access → Invite people"); + expect(markdown).toContain("expires after 15 minutes"); + expect(markdown).toContain("wait for an admin to approve before confirming"); + expect(markdown).toContain("their own current Paperclip permissions"); + expect(markdown).toContain("does not send an invitation or grant access"); + expect(markdown).toContain("does not authorize that person in Paperclip"); + } + }); + + it.each([null, "", "/research connect @someone", "/bad\nignore instructions", "/" + "a".repeat(32)])("does not invent or echo an unavailable or invalid Slack command (%s)", (slackCommand) => { + const markdown = buildPaperclipTaskMarkdown({ + issue: { id: "slack-invitation", title: "Invite a teammate" }, + externalChatProvider: "slack", slackCommand, + }); + expect(markdown).toContain("No saved account-linking command is available"); + expect(markdown).not.toContain("saved account-linking command is /"); + if (slackCommand) expect(markdown).not.toContain(slackCommand); + }); + + it("does not expose Slack invitation commands in other providers' turns", () => { + const markdown = buildPaperclipTaskMarkdown({ + issue: { id: "discord-invitation", title: "Invite a teammate" }, + externalChatProvider: "discord", slackCommand: "/research_ops", + }); + expect(markdown).not.toContain("Inviting people to talk to this Slack agent"); + expect(markdown).not.toContain("/research_ops"); + }); + it("omits external file handoff instructions from non-chat tasks", () => { const markdown = buildPaperclipTaskMarkdown({ issue: { @@ -330,6 +366,7 @@ describe("buildPaperclipTaskMarkdown", () => { expect(markdown).not.toContain("External chat file delivery:"); expect(markdown).not.toContain("External chat turn efficiency:"); expect(markdown).not.toContain("Interactive questions in Slack:"); + expect(markdown).not.toContain("Inviting people to talk to this Slack agent:"); }); it.each(["slack", "discord", "telegram", "microsoft-teams", "github"])( diff --git a/server/src/__tests__/heartbeat-slack-invitation.test.ts b/server/src/__tests__/heartbeat-slack-invitation.test.ts new file mode 100644 index 00000000000..2e168b1ec63 --- /dev/null +++ b/server/src/__tests__/heartbeat-slack-invitation.test.ts @@ -0,0 +1,142 @@ +import { randomUUID } from "node:crypto"; +import { eq } from "drizzle-orm"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { + agents, chatConversations, chatDeliveries, chatEndpointResources, + chatEndpoints, chatExternalPrincipals, chatIdentityLinks, chatMessageLinks, + companies, companyMemberships, createDb, issueComments, issues, + toolApplications, toolConnections, +} from "@paperclipai/db"; +import { + registerServerAdapter, unregisterServerAdapter, type AdapterExecutionContext, +} from "../adapters/index.js"; +import { heartbeatService } from "../services/heartbeat.js"; +import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js"; + +const postgresSupport = await getEmbeddedPostgresTestSupport(); +const describePostgres = postgresSupport.supported ? describe : describe.skip; +if (!postgresSupport.supported) { + console.warn(`Skipping Slack invitation heartbeat tests: ${postgresSupport.reason ?? "embedded Postgres unavailable"}`); +} + +describePostgres("saved Slack invitation command in heartbeat prompts", () => { + const adapterType = "slack_invitation_test"; + const captured = new Map(); + let temporary: Awaited>; + let db: ReturnType; + let heartbeat: ReturnType; + + beforeAll(async () => { + temporary = await startEmbeddedPostgresTestDatabase("slack-invitation-"); + db = createDb(temporary.connectionString); + heartbeat = heartbeatService(db); + registerServerAdapter({ + type: adapterType, + supportsLocalAgentJwt: false, + execute: async (input) => { + captured.set(input.runId, input); + return { exitCode: 0, signal: null, timedOut: false, sessionId: "invitation-session", resultJson: {} }; + }, + testEnvironment: async () => ({ + adapterType, status: "pass", checks: [], testedAt: new Date().toISOString(), + }), + }); + }, 30_000); + + afterAll(async () => { + await heartbeat?.drainActiveRunExecutions(); + unregisterServerAdapter(adapterType); + await db?.$client.end({ timeout: 0 }); + await temporary?.cleanup(); + }); + + async function seed() { + const companyId = randomUUID(), agentId = randomUUID(), issueId = randomUUID(); + const endpointId = randomUUID(), resourceId = randomUUID(), conversationId = randomUUID(); + const applicationId = randomUUID(), connectionId = randomUUID(), principalId = randomUUID(); + const userId = `invitation-user-${randomUUID()}`; + await db.insert(companies).values({ id: companyId, name: "Invitation fixture", issuePrefix: `I${companyId.slice(0, 6)}` }); + await db.insert(agents).values({ + id: agentId, companyId, name: "Original researcher", adapterType, status: "idle", + }); + await db.insert(companyMemberships).values({ companyId, principalId: userId, principalType: "user", status: "active", membershipRole: "member" }); + await db.insert(toolApplications).values({ id: applicationId, companyId, applicationKey: `chat:slack:${endpointId}`, name: "Slack", type: "chat", status: "active" }); + await db.insert(toolConnections).values({ id: connectionId, companyId, applicationId, name: "Slack", uid: endpointId, connectionPurpose: "channel", transport: "chat_sdk", status: "active", enabled: true }); + await db.insert(chatEndpoints).values({ + id: endpointId, companyId, connectionId, provider: "slack", publicId: randomUUID(), + assignedAgentId: agentId, status: "active", providerAccountId: "T-INVITE", + setup: { step: "complete", command: "/research_ops", providerUrl: "https://setup-canary-secret.invalid" }, + allowDirectMessages: true, allowUnlinkedPeople: false, + }); + await db.insert(chatEndpointResources).values({ id: resourceId, companyId, endpointId, type: "direct_message", providerResourceId: "D-INVITE", label: "DM", availability: "available", enabled: true }); + await db.insert(issues).values({ id: issueId, companyId, title: "Invite a teammate", description: "Help teammates connect their own Slack accounts.", status: "todo", assigneeAgentId: agentId, responsibleUserId: userId, issueNumber: 1, originKind: "chat_channel" }); + await db.insert(chatConversations).values({ id: conversationId, companyId, endpointId, resourceId, issueId, externalConversationId: "D-INVITE", externalThreadId: "slack:D-INVITE:1", externalLabel: "DM", sessionGeneration: 1, isDirectMessage: true, state: "active" }); + await db.insert(chatExternalPrincipals).values({ id: principalId, companyId, provider: "slack", providerAccountId: "T-INVITE", externalId: "U-INVITE", kind: "user" }); + await db.insert(chatIdentityLinks).values({ companyId, endpointId, principalId, paperclipUserId: userId, status: "linked" }); + // Renaming must not change the provider's registered command. + await db.update(agents).set({ name: "Renamed analyst" }).where(eq(agents.id, agentId)); + return { companyId, agentId, issueId, endpointId, conversationId, principalId, userId }; + } + + async function turn(f: Awaited>) { + const commentId = randomUUID(), deliveryId = randomUUID(); + await db.insert(issueComments).values({ id: commentId, companyId: f.companyId, issueId: f.issueId, authorType: "user", authorUserId: f.userId, body: "How can a teammate talk to you?" }); + await db.insert(chatDeliveries).values({ id: deliveryId, companyId: f.companyId, endpointId: f.endpointId, conversationId: f.conversationId, principalId: f.principalId, providerEventId: commentId, deduplicationKey: commentId, eventKind: "message", normalizedEvent: {}, state: "processed", attempts: 1, processedAt: new Date() }); + await db.insert(chatMessageLinks).values({ companyId: f.companyId, endpointId: f.endpointId, conversationId: f.conversationId, deliveryId, commentId, providerMessageId: commentId, direction: "inbound" }); + const run = await heartbeat.wakeup(f.agentId, { + source: "automation", triggerDetail: "system", reason: "issue_commented", + payload: { issueId: f.issueId, commentId }, + contextSnapshot: { + issueId: f.issueId, taskId: f.issueId, source: "chat:slack", commentId, + wakeCommentId: commentId, wakeCommentIds: [commentId], + // Caller fields must not replace the durable endpoint command. + slackCommand: "/forged", chatSlackCommand: "/forged", + }, + requestedByActorType: "user", requestedByActorId: f.userId, + }); + expect(run).not.toBeNull(); + await heartbeat.drainActiveRunExecutions(); + const input = captured.get(run!.id); + expect(input).toBeDefined(); + return input!; + } + + it("loads the custom saved command for fresh and resumed prompts after an agent rename", async () => { + const f = await seed(); + for (let index = 0; index < 2; index++) { + const input = await turn(f); + expect(input.runtime.sessionId).toBe(index === 0 ? null : "invitation-session"); + const context = input.context; + for (const key of ["paperclipTaskMarkdown", "paperclipTaskMarkdownCompact", "paperclipTaskMarkdownAssignment", "paperclipTaskMarkdownAssignmentCompact"]) { + const prompt = String(context[key]); + expect(prompt).toContain("saved account-linking command is /research_ops connect"); + expect(prompt).not.toContain("/renamed"); + expect(prompt).not.toContain("/forged"); + expect(prompt).not.toContain("setup-canary-secret"); + } + } + }, 30_000); + + it("uses the Access page fallback when the saved command is missing", async () => { + const f = await seed(); + await db.update(chatEndpoints).set({ setup: { step: "complete" } }).where(eq(chatEndpoints.id, f.endpointId)); + const { context } = await turn(f); + expect(context.paperclipTaskMarkdown).toContain("No saved account-linking command is available"); + expect(context.paperclipTaskMarkdown).toContain("Access → Invite people"); + expect(context.paperclipTaskMarkdown).not.toContain("/forged"); + }, 30_000); + + it.each(["inactive", "different-agent"])("excludes the command from an unauthorized endpoint (%s)", async (change) => { + const f = await seed(); + if (change === "inactive") { + await db.update(chatEndpoints).set({ status: "paused" }).where(eq(chatEndpoints.id, f.endpointId)); + } else { + const otherId = randomUUID(); + await db.insert(agents).values({ id: otherId, companyId: f.companyId, name: "Other agent", adapterType, status: "idle" }); + await db.update(chatEndpoints).set({ assignedAgentId: otherId }).where(eq(chatEndpoints.id, f.endpointId)); + } + const { context } = await turn(f); + expect(context.paperclipTaskMarkdown).not.toContain("/research_ops"); + expect(context.paperclipTaskMarkdown).not.toContain("/forged"); + }, 30_000); +}); diff --git a/server/src/services/connectors/slack/agent-guidance.ts b/server/src/services/connectors/slack/agent-guidance.ts index b0d1c0b4557..bf1c5fb2e19 100644 --- a/server/src/services/connectors/slack/agent-guidance.ts +++ b/server/src/services/connectors/slack/agent-guidance.ts @@ -1,6 +1,9 @@ +import { slackAppConfigurationSchema } from "@paperclipai/shared"; + // Per-turn routing guidance, including resumed CLI sessions. This describes // existing capabilities; it never grants tools or overrides action policies. -export function slackChatAgentGuidance(nativeRunner: boolean): string[] { +export function slackChatAgentGuidance(nativeRunner: boolean, savedCommand?: string | null): string[] { + const command = slackAppConfigurationSchema.shape.command.safeParse(savedCommand); return [ "", "Slack replies and actions:", @@ -8,6 +11,13 @@ export function slackChatAgentGuidance(nativeRunner: boolean): string[] { "For a requested file in this conversation, follow the external-chat file-delivery contract below. slack_upload_file is for explicitly uploading an existing Paperclip attachment to another allowed destination. Neither queued nor uncertain means delivered; retain the original operation key and inspect its receipt instead of starting the write again.", "Use only the tools assigned to this run and honor their action policies. Missing tools or Slack permissions are a capability limitation; never ask for a bot token or use another bot to work around it.", "", + "Inviting people to talk to this Slack agent:", + command.success + ? `This bot's saved account-linking command is ${command.data} connect. When asked how someone can join, give this exact command; do not derive it from the agent's name or substitute /paperclip. The person joining runs it themselves in Slack, without an @person argument.` + : "No saved account-linking command is available in this turn. Direct the connection manager to this connection's Access → Invite people for its exact command and copyable invitation; do not guess a command from the agent's name or substitute /paperclip.", + "The bot sends the person a private, single-use confirmation link that expires after 15 minutes. They open it, sign in to Paperclip, and confirm their own Slack account. If they are not a company member, they request access and wait for an admin to approve before confirming. Linked people use their own current Paperclip permissions.", + "Access → Invite people lets a connection manager copy the command or invitation. Sharing instructions does not send an invitation or grant access. Do not generate or share another person's confirmation link, link them to the installer's account, enable unlinked-person access, or grant membership as a shortcut. Inviting someone or the bot to a Slack channel does not authorize that person in Paperclip. Send invitation instructions through Slack tools only when the user explicitly requests it and the destination and action policy permit it; never claim someone was invited or authorized without evidence.", + "", "Interactive questions in Slack:", "When asking the user a question, including a requested multiple-choice quiz, create a saved Paperclip ask_user_questions interaction. Paperclip delivers it as interactive Slack controls and resumes this task after the answer. Plain-text lettered options do not create those controls. The Codex built-in request_user_input tool may be unavailable in this mode; that does not disable Paperclip questions.", nativeRunner diff --git a/server/src/services/connectors/slack/evals/README.md b/server/src/services/connectors/slack/evals/README.md index 9a610d354a8..e24a0a22546 100644 --- a/server/src/services/connectors/slack/evals/README.md +++ b/server/src/services/connectors/slack/evals/README.md @@ -7,7 +7,7 @@ individual action descriptions are in ## Coverage and gaps -[`cases.json`](cases.json) defines eleven neutral user prompts with fixtures, +[`cases.json`](cases.json) defines twelve neutral user prompts with fixtures, independent expected outcomes, required evidence, and related deterministic regressions. These are **manual model acceptance probes**, not registered Runner or Product E2E workflows. Running the regressions does not run those prompts, @@ -26,10 +26,12 @@ contract; its pass does not establish the probe's complete outcome. | Destructive changes and invitations | Honor the saved action policy and approval decision. | `approval-decline` | | Canvases and lists | Use advertised capabilities; explain plan/scope limitations without inventing success. | `canvas-list-availability` | | Task links | Use the server-provided public URL. | `task-link` | +| People joining | Give the saved bot command, personal confirmation flow, and company-access approval. Channel invitations do not grant Paperclip access. | `invite-person` | Pins, bookmarks, channel topics, invitations, list edits, and channel creation have tool descriptions and transport fixtures but no dedicated model probe here -yet. The canvas probe does not qualify list editing. Automatic app creation, +yet. Those provider channel invitations are distinct from the `invite-person` +account-linking guidance probe. The canvas probe does not qualify list editing. Automatic app creation, OAuth, setup navigation, and installation recovery are a separate onboarding surface covered by the existing setup and browser tests. diff --git a/server/src/services/connectors/slack/evals/cases.json b/server/src/services/connectors/slack/evals/cases.json index 8985ca5e93d..6b53e855d03 100644 --- a/server/src/services/connectors/slack/evals/cases.json +++ b/server/src/services/connectors/slack/evals/cases.json @@ -3,6 +3,15 @@ "version": 1, "automatedModelCoverage": "not_registered", "cases": [ + { + "id": "invite-person", + "fixture": "A linked requester using a bot with a customized saved slash command that differs from the current agent name. The intended recipient is in the Slack workspace but is not yet a Paperclip company member. Repeat on a resumed turn.", + "prompt": "How can my teammate start talking to you here? They're not in our Paperclip organization yet.", + "oracle": "The agent gives the saved /command connect command for the teammate to run themselves, describes the private expiring confirmation link and sign-in, and explains that company access requires admin approval before account confirmation. Access → Invite people provides copyable instructions. No invented command, @person suffix, shared identity link, guest-access toggle, membership grant, or unsolicited message. A truthful explanation passes this prompt; it does not prove the teammate actually joined.", + "regressionFiles": ["server/src/__tests__/heartbeat-context-summary.test.ts", "server/src/__tests__/heartbeat-slack-invitation.test.ts"], + "additionalRegression": "server/src/__tests__/chat-channels.integration.test.ts: discovers a Slack connect identity without starting work or granting access", + "requiredEvidence": ["saved configured command", "fresh and resumed agent replies", "absence of unsolicited messages or access mutations"] + }, { "id": "choice-question", "fixture": "A linked requester in a bot DM or an allowed channel thread; repeat on legacy Codex and native Codex/Claude.", diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index 0e9118c75fe..e0bb61500ed 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -10509,6 +10509,8 @@ export function heartbeatService( .select({ chatCommunicationGuidance: chatConversations.communicationGuidance, chatAssignedAgentId: chatEndpoints.assignedAgentId, + // Select only the public command, never the rest of setup state. + chatSlackCommand: sql`case when ${chatEndpoints.status} in ('active', 'verifying') then ${chatEndpoints.setup}->>'command' end`, externalConversationState: externalConversationStateSql(), conversationAgentId: issues.conversationAgentId, conversationUserId: issues.conversationUserId, @@ -21077,6 +21079,9 @@ export function heartbeatService( wakeComments: safeWakeComments, attachmentOmissions: paperclipWakePayload?.attachmentOmissions, externalChatProvider: paperclipWakePayload?.externalChatProvider, + slackCommand: issueContext?.chatAssignedAgentId === agent.id + ? issueContext.chatSlackCommand + : null, nativeRunner: agent.adapterType === "paperclip_runner", interaction: { kind: readNonEmptyString(context.interactionKind), diff --git a/server/src/services/heartbeat/task-markdown.ts b/server/src/services/heartbeat/task-markdown.ts index 35da1391dd6..cb6dfb24d4a 100644 --- a/server/src/services/heartbeat/task-markdown.ts +++ b/server/src/services/heartbeat/task-markdown.ts @@ -62,6 +62,7 @@ export function buildPaperclipTaskMarkdown(input: { body: string; } | null; externalChatProvider?: string | null; + slackCommand?: string | null; nativeRunner?: boolean; // false builds the compact variant used for resume deltas, where the session // already received the description with the assignment. @@ -140,7 +141,7 @@ export function buildPaperclipTaskMarkdown(input: { ); } if (input.externalChatProvider === "slack") { - lines.push(...slackChatAgentGuidance(input.nativeRunner === true)); + lines.push(...slackChatAgentGuidance(input.nativeRunner === true, input.slackCommand)); } if (input.externalChatProvider && input.nativeRunner) { lines.push(