From d9b3a5653e41f2ee5a1345b97c86a238f7a5c8e9 Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Mon, 21 Sep 2026 14:14:25 -0500 Subject: [PATCH] feat(chat): add initial Slack communication guidance and connection menus (#13760) ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Chat connectors let people use the same tasks and agent tools from external conversations. > - Agents need communication guidance that fits the conversation medium. > - That guidance belongs in the original task context, without repeated instructions on each turn. > - Connection owners also need clear settings and a consistent way to remove a connection. > - This pull request adds initial Slack guidance, optional connection instructions, and chat connection menus. > - The benefit is clearer Slack replies with the existing Paperclip workflow and permissions. ## Linked Issues or Issue Description **What existing behavior does this improve?** Agent replies in Slack and chat connection management in the Apps catalog. **Current behavior** Slack tasks do not carry a saved communication profile. The catalog shows a separate Manage button and does not offer removal on every chat connection row. **Proposed behavior** Save Slack guidance when a new conversation creates a task. Restore that original guidance when a model session is rebuilt. Do not append it to ordinary follow-ups. Expose optional additional instructions in Slack Settings. Put Manage and Remove connection in a three-dot menu for all chat providers. Keep Finish setup visible for drafts. **Reason and benefit** Small answers fit in Slack. Substantial deliverables use ordinary document or artifact tools with a useful Slack summary. Connection settings apply to new tasks and cannot change permissions. Users can remove both active and unfinished chat connections from the catalog. **Breaking changes** Two additive database columns store endpoint preferences and the initial conversation snapshot. Existing endpoints default to empty preferences. Existing conversations keep their original behavior. Non-Slack guidance is unchanged. Related public context: https://github.com/paperclipai/paperclip/pull/13741 improves native chat recovery. This change adds communication context to those existing execution paths. A search found no duplicate communication-guidance PR. ## What Changed - Add a provider-guidance registry, enabled for Slack first. - Persist optional endpoint communication instructions and capture an immutable snapshot when a conversation creates a task. - Resolve guidance from the verified company-scoped connection. Restore it for fresh native and legacy sessions without per-turn reminders, extra model calls, or extra context queries. - Add the Slack Settings field, validation, audit coverage, and Storybook save/error states. - Add Manage and Remove connection menus for all seven chat providers. Keep the draft setup button. Require removal confirmation and allow retry after failure. - Add regression coverage, an active/draft menu story, and connector documentation. ## Verification All CI checks are green for 5f48df4e0. Greptile scored this head 5/5 with no actionable findings. No review threads remain unresolved. - Passed `pnpm -r typecheck` and `pnpm build` on PR head 5f48df4e0. - Passed design-token checks, UI typecheck, and all 20 catalog tests after rebase. Tests cover all seven providers, active/draft removal, confirmation, cache refresh, errors, and cancellation. - Verified the active/draft menu in Storybook. The interaction test runs without browser console errors. - Passed focused guidance, endpoint persistence/isolation, heartbeat trust, native context, ACPX, adapter utility, and CLI recovery tests. Full UI and CLI groups passed (6,512 and 502 tests). - Tested real Slack conversations on staging: concise updates with public links, a planning question with buttons, a saved plan, a saved report, task creation and assignment, and explicit detailed output. Old tasks retained original preferences after an edit; a new task used the changed preferences. Restored the staging setting afterward. - Existing safe progress remained visible without duplicate final replies or private reasoning. - Broad local tests found resource/time-sensitive failures that passed targeted reruns. One Cursor archive-download fixture failed on both this branch and the unchanged main checkout. The full local suite is not claimed clean. All PR-head CI test shards passed, including general, serialized, Runner, and browser suites. The redundant local full-suite rerun was stopped after CI completed successfully. - Live delegation was not tested because the staging company has only one agent. Live testing also found separate latency and runner task-editing capability gaps; this PR does not add connector-specific workflow behavior to hide them. ## Risks - Prompt guidance changes the form of new Slack replies. Explicit requests for detail still take precedence. - The additive migration is idempotent. Conversation snapshots remain fixed when connection settings change. - Native and legacy recovery must preserve the initial context without duplicates; targeted tests cover these paths. - Removing a connection stops new work through the existing lifecycle action. It retains Paperclip task history and does not delete the external app or bot. ## Model Used OpenAI GPT-6 through Codex, with repository editing, shell tools, and browser testing. The host does not expose a more specific model ID or context-window size. No separate model calls were added to the product. ## 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 (targeted suites; broad local limitations are listed above) - [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/DATABASE.md | 9 + doc/connections/CHAT-CONNECTOR-UX.md | 37 ++++ .../src/acpx-engine/execute.test.ts | 5 + .../adapter-utils/src/server-utils.test.ts | 19 ++ packages/adapter-utils/src/server-utils.ts | 18 +- .../cursor-cloud/src/server/execute.ts | 4 +- .../cursor-local/src/server/execute.ts | 13 +- .../src/server/execute.acp-fallback.test.ts | 37 ++++ .../gemini-local/src/server/execute.ts | 17 +- .../adapters/grok-local/src/server/execute.ts | 17 +- .../adapters/kimi-local/src/server/execute.ts | 17 +- .../openclaw-gateway/src/server/execute.ts | 9 +- .../opencode-local/src/server/execute.ts | 13 +- .../adapters/pi-local/src/server/execute.ts | 17 +- .../src/migrations/0282_colossal_shocker.sql | 2 + ...{0277_snapshot.json => 0282_snapshot.json} | 196 +++++++++++++++++- packages/db/src/migrations/meta/_journal.json | 7 + packages/db/src/schema/chat_channels.ts | 3 + .../src/contracts/native-execution.test.ts | 7 + .../src/contracts/native-execution.ts | 12 +- packages/shared/src/types/chat-channels.ts | 3 + .../shared/src/validators/chat-channels.ts | 1 + .../chat-channels.integration.test.ts | 49 +++++ .../heartbeat-comment-wake-batching.test.ts | 6 + server/src/services/chat-channels.ts | 13 ++ .../chat-communication-guidance.test.ts | 26 +++ .../services/chat-communication-guidance.ts | 33 +++ server/src/services/heartbeat.ts | 21 ++ .../native-execution-input.test.ts | 4 + .../native-runtime/native-execution-input.ts | 2 + ui/src/api/chatEndpoints.ts | 1 + ui/src/pages/apps/Browse.test.tsx | 71 ++++++- ui/src/pages/apps/Browse.tsx | 77 +++++-- .../chat/ChatCommunicationInstructions.tsx | 59 ++++++ ui/src/pages/apps/chat/ChatEndpointDetail.tsx | 9 + .../SlackAvatarSettingsPreview.tsx | 10 +- .../chat-connection-actions.stories.tsx | 43 ++++ .../slack-communication-settings.stories.tsx | 28 +++ 38 files changed, 852 insertions(+), 63 deletions(-) create mode 100644 packages/db/src/migrations/0282_colossal_shocker.sql rename packages/db/src/migrations/meta/{0277_snapshot.json => 0282_snapshot.json} (99%) create mode 100644 server/src/services/chat-communication-guidance.test.ts create mode 100644 server/src/services/chat-communication-guidance.ts create mode 100644 ui/src/pages/apps/chat/ChatCommunicationInstructions.tsx create mode 100644 ui/storybook/stories/chat-connection-actions.stories.tsx create mode 100644 ui/storybook/stories/slack-communication-settings.stories.tsx diff --git a/doc/DATABASE.md b/doc/DATABASE.md index fe3f2e0dc24..1d21aa87f81 100644 --- a/doc/DATABASE.md +++ b/doc/DATABASE.md @@ -293,6 +293,15 @@ and process-start evidence proves the prior controller is gone, or when the lease expires. Recovery generation changes do not increment the independent provider-attempt counter. +## Chat communication snapshots + +Chat communication guidance uses two additive columns: endpoint +`communication_instructions` defaults to empty, and conversation +`communication_guidance` holds the immutable initial task snapshot. Existing +conversations retain a null snapshot; there is no backfill that changes an +ongoing conversation. New Slack tasks receive built-in guidance even when the +endpoint has no additional instructions. + ## Telegram private draft identities `chat_telegram_draft_ids` is a content-free, instance-wide PostgreSQL sequence, diff --git a/doc/connections/CHAT-CONNECTOR-UX.md b/doc/connections/CHAT-CONNECTOR-UX.md index 6432a64c0d8..167ae968863 100644 --- a/doc/connections/CHAT-CONNECTOR-UX.md +++ b/doc/connections/CHAT-CONNECTOR-UX.md @@ -228,6 +228,37 @@ configured callback URL. Self-hosted instances continue using the request URL. Preserve gateway observations in dedicated diagnostic headers when another provider proxy replaces standard forwarded headers before reaching the tenant. +## Communication through the connection + +Medium-specific communication is initial prompt guidance for the agent. Slack +guidance asks for conversational replies, an answer or outcome first, and compact +paragraphs or short lists. Small answers belong in the message; substantial +deliverables use the normal document/artifact tools with a useful summary and +accessible links or supported attachments. Explicit requests for detail or exact +output override default brevity. Shared channels and direct conversations get +appropriate audience guidance. + +Slack Settings offers one optional field: **Additional communication +instructions** (up to 4,000 characters). Explain that it guides communication +and applies when new tasks start. Reply detail and progress visibility are +built-in behavior, not settings. Keep the existing factual runtime progress +signals, cadence, and deduplication; never publish private reasoning or add a +model call to write progress updates. + +The server snapshots built-in and additional instructions from the verified +endpoint when the conversation creates its task. Ordinary follow-ups add no +guidance or reminders. A fresh model session restores that original snapshot +through initial context restoration. Editing endpoint instructions affects new +tasks only. Message text cannot set trusted endpoint configuration. Instructions +do not grant permissions, tools, or exemptions from approval rules. + +Keep the provider-guidance registry separate from transport and agent identity. +Slack is the first enabled profile; qualify each additional provider before +enabling guidance or exposing the field. Questions, approvals, task creation, +assignment, delegation, and origin-thread completion use normal Paperclip +workflows. Identify missing workflow capabilities separately rather than adding +a connector-specific task lifecycle. + ## Apply and verify For a requested redesign, identify the relevant principles and fix the concrete @@ -315,3 +346,9 @@ the file through Basic Information → Display Information → App icon & Previe confirm the crop, then save in Slack. Confirmation/skipping is browser-local, scoped to the company and endpoint; it is not proof of a Slack configuration change. No new Slack scope or API mutation is needed. + +Chat connections in the catalog keep **Finish setup** visible for drafts. Put +**Manage** and **Remove connection** in a three-dot menu on every connection row, +including drafts. Confirm removal, explain that existing tasks remain, and keep +the dialog available for retry if removal fails. Use the existing provider lifecycle +action; removing a Paperclip connection does not delete the provider app or bot. diff --git a/packages/adapter-utils/src/acpx-engine/execute.test.ts b/packages/adapter-utils/src/acpx-engine/execute.test.ts index 9fe21551b31..0b52c22e171 100644 --- a/packages/adapter-utils/src/acpx-engine/execute.test.ts +++ b/packages/adapter-utils/src/acpx-engine/execute.test.ts @@ -629,6 +629,7 @@ describe("shared ACPX engine runtime behavior", () => { taskId: "chat-1", paperclipTaskMarkdown: chatDirective, paperclipTaskMarkdownCompact: chatDirective, + paperclipTaskCommunicationGuidance: "Frozen Slack communication preference.", paperclipWake: { reason: "issue_commented", issue: { id: "chat-1", workMode: "planning", status: "in_progress" }, @@ -647,6 +648,10 @@ describe("shared ACPX engine runtime behavior", () => { expect(resumed.sessionInputs[0]?.resumeSessionId).toBe(fresh.result.sessionId); const reset = await runExecutor(config, { context }); expect(reset.sessionInputs[0]?.resumeSessionId).toBeUndefined(); + for (const run of [fresh, reset]) { + expect(String(run.meta[0]?.prompt).match(/Frozen Slack communication preference\./g)).toHaveLength(1); + } + expect(String(resumed.meta[0]?.prompt)).not.toContain("Frozen Slack communication preference."); for (const { meta } of [fresh, resumed, reset]) { const prompt = String(meta[0]?.prompt ?? ""); expect(prompt).toContain(chatDirective); diff --git a/packages/adapter-utils/src/server-utils.test.ts b/packages/adapter-utils/src/server-utils.test.ts index fc3962b1e09..b694c871be7 100644 --- a/packages/adapter-utils/src/server-utils.test.ts +++ b/packages/adapter-utils/src/server-utils.test.ts @@ -26,6 +26,7 @@ import { resolveLegacyPaperclipDesiredSkillNames, resolvePaperclipDesiredSkillNames, selectPaperclipTaskMarkdown, + selectInitialCommunicationGuidance, runningProcesses, runChildProcess, sanitizeSshRemoteEnv, @@ -3053,6 +3054,24 @@ describe("selectPaperclipTaskMarkdown", () => { ).toBe(compactMarkdown); }); + it("adds saved communication guidance only to a fresh session, including after recovery", () => { + const context = { + paperclipTaskMarkdown: fullMarkdown, + paperclipTaskMarkdownCompact: compactMarkdown, + paperclipTaskCommunicationGuidance: "## Communication in Slack\nSaved initial guidance", + paperclipWake: wake("issue_commented"), + }; + expect(selectPaperclipTaskMarkdown(context)).toContain("Saved initial guidance"); + expect(selectInitialCommunicationGuidance({ paperclipTaskCommunicationGuidance: " Slack preference " })).toBe("Slack preference"); + expect(selectInitialCommunicationGuidance(context, { resumedSession: true })).toBe(""); + expect(selectInitialCommunicationGuidance({})).toBe(""); + expect(selectPaperclipTaskMarkdown(context, { resumedSession: true })).toBe(compactMarkdown); + expect(selectPaperclipTaskMarkdown(context, { includeCommunicationGuidance: false })).toBe(fullMarkdown); + context.paperclipWake = { ...wake("issue_monitor_recovery"), recovery: { cause: "process_lost" } } as typeof context.paperclipWake; + expect(selectPaperclipTaskMarkdown(context, { resumedSession: true })).toBe(fullMarkdown); + expect(selectPaperclipTaskMarkdown(context, { resumedSession: false }).match(/Saved initial guidance/g)).toHaveLength(1); + }); + it("falls back to the full markdown when no compact variant exists", () => { expect( selectPaperclipTaskMarkdown( diff --git a/packages/adapter-utils/src/server-utils.ts b/packages/adapter-utils/src/server-utils.ts index 0ca8003919d..e45d008f8fe 100644 --- a/packages/adapter-utils/src/server-utils.ts +++ b/packages/adapter-utils/src/server-utils.ts @@ -2150,6 +2150,16 @@ export function isAssignmentShapedPaperclipWakeReason( ); } +// Select at the actual provider attempt boundary so a failed resume restores +// the original snapshot once when retrying with a fresh session. +export function selectInitialCommunicationGuidance( + context: Record | null | undefined, + options: { resumedSession?: boolean } = {}, +): string { + return options.resumedSession === true + ? "" : asString(context?.paperclipTaskCommunicationGuidance, "").trim(); +} + // Picks the task-context markdown variant for adapters that inject it into the // prompt. Fresh sessions, assignment-shaped wakes, and recovery wakes get the // full brief; other resume deltas get the compact variant (description @@ -2157,11 +2167,15 @@ export function isAssignmentShapedPaperclipWakeReason( // issue up. Falls back to the full variant when no compact one was provided. export function selectPaperclipTaskMarkdown( context: Record | null | undefined, - options: { resumedSession?: boolean } = {}, + options: { resumedSession?: boolean; includeCommunicationGuidance?: boolean } = {}, ): string { const full = asString(context?.paperclipTaskMarkdown, "").trim(); if (!full) return ""; - if (options.resumedSession !== true) return full; + if (options.resumedSession !== true) { + const guidance = options.includeCommunicationGuidance === false + ? "" : selectInitialCommunicationGuidance(context, options); + return joinPromptSections([guidance, full]); + } const wake = normalizePaperclipWakePayload(context?.paperclipWake); if (!wake) return full; if ( diff --git a/packages/adapters/cursor-cloud/src/server/execute.ts b/packages/adapters/cursor-cloud/src/server/execute.ts index f157b55545e..f9dad078062 100644 --- a/packages/adapters/cursor-cloud/src/server/execute.ts +++ b/packages/adapters/cursor-cloud/src/server/execute.ts @@ -22,6 +22,7 @@ import { readPaperclipIssueWorkModeFromContext, renderPaperclipWakePrompt, selectPaperclipTaskMarkdown, + selectInitialCommunicationGuidance, isPaperclipRecoveryWakePayload, renderTemplate, stringifyPaperclipWakePayload, @@ -417,7 +418,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise { + const prompt = joinPromptSections([ + selectInitialCommunicationGuidance(context, { resumedSession: Boolean(resumeSessionId) }), + basePrompt, + ]); const args = buildArgs(resumeSessionId); if (onMeta) { await onMeta({ @@ -625,7 +630,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise { expect(runAdapterExecutionTargetProcess).not.toHaveBeenCalled(); }); + + it.each(["fresh", "resumed", "missing"] as const)( + "supplies initial communication guidance at the CLI attempt boundary (%s)", async (state) => { + const cwd = await mkdtemp(path.join(tmpdir(), "gemini-communication-")); + const prompts: string[] = []; + try { + if (state === "missing") { + runAdapterExecutionTargetProcess.mockResolvedValueOnce({ + exitCode: 1, signal: null, timedOut: false, stdout: "", + stderr: "Unknown session 'previous'", pid: 123, startedAt: new Date().toISOString(), + }); + } + const ctx = buildContext({ engine: "cli", cwd }); + await execute({ + ...ctx, + runtime: { ...ctx.runtime, sessionId: state === "fresh" ? null : "previous" }, + context: { paperclipTaskCommunicationGuidance: "Frozen Slack preferences." }, + onMeta: async (meta) => { prompts.push(meta.prompt ?? ""); }, + }); + if (state === "fresh") { + expect(prompts).toHaveLength(1); + expect(prompts[0]?.match(/Frozen Slack preferences\./g)).toHaveLength(1); + } else { + expect(prompts[0]).not.toContain("Frozen Slack preferences."); + expect(prompts).toHaveLength(state === "missing" ? 2 : 1); + if (state === "missing") { + expect(prompts[1]?.match(/Frozen Slack preferences\./g)).toHaveLength(1); + } + } + } finally { + await rm(cwd, { recursive: true, force: true }); + } + }, + ); }); diff --git a/packages/adapters/gemini-local/src/server/execute.ts b/packages/adapters/gemini-local/src/server/execute.ts index de9257c1f47..c90fe08716a 100644 --- a/packages/adapters/gemini-local/src/server/execute.ts +++ b/packages/adapters/gemini-local/src/server/execute.ts @@ -48,6 +48,7 @@ import { renderTemplate, renderPaperclipWakePrompt, selectPaperclipTaskMarkdown, + selectInitialCommunicationGuidance, isPaperclipRecoveryWakePayload, stringifyPaperclipWakePayload, DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE, @@ -560,7 +561,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise { + const buildArgs = (resumeSessionId: string | null, prompt: string) => { const args = ["--output-format", "stream-json"]; if (resumeSessionId) args.push("--resume", resumeSessionId); if (model && model !== DEFAULT_GEMINI_LOCAL_MODEL) args.push("--model", model); @@ -611,7 +612,11 @@ export async function execute(ctx: AdapterExecutionContext): Promise { - const args = buildArgs(resumeSessionId); + const prompt = joinPromptSections([ + selectInitialCommunicationGuidance(context, { resumedSession: Boolean(resumeSessionId) }), + basePrompt, + ]); + const args = buildArgs(resumeSessionId, prompt); const invocationEnv = buildGeminiHeadlessEnv(env); const invocationRuntimeEnv = buildGeminiRuntimeEnv(env); const loggedEnv = buildInvocationEnvForLogs(invocationEnv, { @@ -630,7 +635,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise { + const buildArgs = (resumeSessionId: string | null, prompt: string) => { const args = ["--cwd", effectiveExecutionCwd, "--output-format", "streaming-json"]; if (resumeSessionId) args.push("--resume", resumeSessionId); if (model && model !== DEFAULT_GROK_LOCAL_MODEL) args.push("--model", model); @@ -544,7 +545,11 @@ export async function execute(ctx: AdapterExecutionContext): Promise { - const args = buildArgs(resumeSessionId); + const prompt = joinPromptSections([ + selectInitialCommunicationGuidance(context, { resumedSession: Boolean(resumeSessionId) }), + basePrompt, + ]); + const args = buildArgs(resumeSessionId, prompt); if (onMeta) { await onMeta({ adapterType: "grok_local", @@ -556,7 +561,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise { + const buildArgs = (resumeSessionId: string | null, prompt: string) => { const args = ["--output-format", "stream-json"]; if (resumeSessionId) args.push("-r", resumeSessionId); if (model) args.push("-m", model); @@ -577,7 +578,11 @@ export async function execute(ctx: AdapterExecutionContext): Promise { - const args = buildArgs(resumeSessionId); + const prompt = joinPromptSections([ + selectInitialCommunicationGuidance(context, { resumedSession: Boolean(resumeSessionId) }), + basePrompt, + ]); + const args = buildArgs(resumeSessionId, prompt); const invocationEnv = buildKimiHeadlessEnv(env); const invocationRuntimeEnv = buildKimiRuntimeEnv(env); const loggedEnv = buildInvocationEnvForLogs(invocationEnv, { @@ -596,7 +601,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise { + const prompt = joinPromptSections([ + selectInitialCommunicationGuidance(context, { resumedSession: Boolean(resumeSessionId) }), + basePrompt, + ]); const args = buildArgs(resumeSessionId); if (onMeta) { await onMeta({ @@ -633,7 +638,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise`], env: loggedEnv, prompt, - promptMetrics, + promptMetrics: { ...promptMetrics, promptChars: prompt.length }, context, }); } diff --git a/packages/adapters/pi-local/src/server/execute.ts b/packages/adapters/pi-local/src/server/execute.ts index 5ed953cc7c0..4787d449952 100644 --- a/packages/adapters/pi-local/src/server/execute.ts +++ b/packages/adapters/pi-local/src/server/execute.ts @@ -46,6 +46,7 @@ import { renderTemplate, renderPaperclipWakePrompt, selectPaperclipTaskMarkdown, + selectInitialCommunicationGuidance, isPaperclipRecoveryWakePayload, stringifyPaperclipWakePayload, DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE, @@ -620,7 +621,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise { + const buildArgs = (sessionFile: string, userPrompt: string): string[] => { const args: string[] = []; // Use JSON mode for structured output with print mode (non-interactive) @@ -692,7 +693,11 @@ export async function execute(ctx: AdapterExecutionContext): Promise { - const args = buildArgs(sessionFile); + const userPrompt = joinPromptSections([ + selectInitialCommunicationGuidance(context, { resumedSession: canResumeSession && sessionFile === sessionPath }), + baseUserPrompt, + ]); + const args = buildArgs(sessionFile, userPrompt); if (onMeta) { await onMeta({ adapterType: "pi_local", @@ -702,7 +707,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise statement-breakpoint +ALTER TABLE "chat_endpoints" ADD COLUMN IF NOT EXISTS "communication_instructions" text DEFAULT '' NOT NULL; \ No newline at end of file diff --git a/packages/db/src/migrations/meta/0277_snapshot.json b/packages/db/src/migrations/meta/0282_snapshot.json similarity index 99% rename from packages/db/src/migrations/meta/0277_snapshot.json rename to packages/db/src/migrations/meta/0282_snapshot.json index 84c859cb1a8..5c16a75ee35 100644 --- a/packages/db/src/migrations/meta/0277_snapshot.json +++ b/packages/db/src/migrations/meta/0282_snapshot.json @@ -1,6 +1,6 @@ { - "id": "5f80a2a4-dcf6-48a0-be4b-f32dbb243e28", - "prevId": "9c7c9e05-e663-4a29-8b46-63733f62da9b", + "id": "23f8fed6-33cf-43ff-b711-8adb151a4bfc", + "prevId": "8d202a5a-5d6b-44d9-9336-3bb39a8b99bc", "version": "7", "dialect": "postgresql", "tables": { @@ -2040,6 +2040,12 @@ "primaryKey": false, "notNull": false }, + "appearance": { + "name": "appearance", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, "status": { "name": "status", "type": "text", @@ -2515,6 +2521,65 @@ }, "isRLSEnabled": false }, + "public.announcement_dismissals": { + "name": "announcement_dismissals", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "announcement_id": { + "name": "announcement_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "dismissed_at": { + "name": "dismissed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "announcement_dismissals_user_id_announcement_id_pk": { + "name": "announcement_dismissals_user_id_announcement_id_pk", + "columns": [ + "user_id", + "announcement_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.announcement_publications": { + "name": "announcement_publications", + "schema": "", + "columns": { + "announcement_id": { + "name": "announcement_id", + "type": "text", + "primaryKey": true, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, "public.approval_comments": { "name": "approval_comments", "schema": "", @@ -5636,6 +5701,12 @@ "notNull": true, "default": false }, + "communication_guidance": { + "name": "communication_guidance", + "type": "text", + "primaryKey": false, + "notNull": false + }, "state": { "name": "state", "type": "text", @@ -6546,6 +6617,13 @@ "primaryKey": false, "notNull": false }, + "communication_instructions": { + "name": "communication_instructions", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, "allow_direct_messages": { "name": "allow_direct_messages", "type": "boolean", @@ -37351,6 +37429,26 @@ "notNull": true, "default": true }, + "setup_pending": { + "name": "setup_pending", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "archived": { + "name": "archived", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "last_webhook_delivery": { + "name": "last_webhook_delivery", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, "cron_expression": { "name": "cron_expression", "type": "text", @@ -37612,6 +37710,100 @@ "checkConstraints": {}, "isRLSEnabled": false }, + "public.routine_webhook_test_receipts": { + "name": "routine_webhook_test_receipts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "trigger_id": { + "name": "trigger_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "delivery_key_hash": { + "name": "delivery_key_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "received_at": { + "name": "received_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "routine_webhook_test_receipts_delivery_uq": { + "name": "routine_webhook_test_receipts_delivery_uq", + "columns": [ + { + "expression": "trigger_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "delivery_key_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "routine_webhook_test_receipts_company_id_companies_id_fk": { + "name": "routine_webhook_test_receipts_company_id_companies_id_fk", + "tableFrom": "routine_webhook_test_receipts", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "routine_webhook_test_receipts_trigger_id_routine_triggers_id_fk": { + "name": "routine_webhook_test_receipts_trigger_id_routine_triggers_id_fk", + "tableFrom": "routine_webhook_test_receipts", + "tableTo": "routine_triggers", + "columnsFrom": [ + "trigger_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, "public.routines": { "name": "routines", "schema": "", diff --git a/packages/db/src/migrations/meta/_journal.json b/packages/db/src/migrations/meta/_journal.json index 0c9355f8b84..23e6ed7272d 100644 --- a/packages/db/src/migrations/meta/_journal.json +++ b/packages/db/src/migrations/meta/_journal.json @@ -1961,6 +1961,13 @@ "when": 1789827314772, "tag": "0281_true_boom_boom", "breakpoints": true + }, + { + "idx": 282, + "version": "7", + "when": 1790004509370, + "tag": "0282_colossal_shocker", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/db/src/schema/chat_channels.ts b/packages/db/src/schema/chat_channels.ts index cfcfc06ad17..6d81462828f 100644 --- a/packages/db/src/schema/chat_channels.ts +++ b/packages/db/src/schema/chat_channels.ts @@ -64,6 +64,7 @@ export const chatEndpoints = pgTable( botUsername: text("bot_username"), botDisplayName: text("bot_display_name"), botAvatarUrl: text("bot_avatar_url"), + communicationInstructions: text("communication_instructions").notNull().default(""), allowDirectMessages: boolean("allow_direct_messages") .notNull() .default(true), @@ -378,6 +379,8 @@ export const chatConversations = pgTable( externalLabel: text("external_label").notNull(), providerUrl: text("provider_url"), isDirectMessage: boolean("is_direct_message").notNull().default(false), + // Immutable initial task context, never refreshed from endpoint settings. + communicationGuidance: text("communication_guidance"), state: text("state").notNull().default("active"), lastActivityAt: timestamp("last_activity_at", { withTimezone: true }), createdAt: timestamp("created_at", { withTimezone: true }) diff --git a/packages/paperclip-runner/src/contracts/native-execution.test.ts b/packages/paperclip-runner/src/contracts/native-execution.test.ts index 8e7657319cd..796cb27893b 100644 --- a/packages/paperclip-runner/src/contracts/native-execution.test.ts +++ b/packages/paperclip-runner/src/contracts/native-execution.test.ts @@ -108,6 +108,13 @@ describe("NativeExecutionInputV1", () => { expect(JSON.stringify(delta)).not.toContain(input.task.title); expect(JSON.stringify(delta)).not.toContain(input.completionContract.contract.objective); expect(JSON.stringify(delta)).not.toContain("opaque-binding"); + const withGuidance = parseNativeExecutionInput({ ...current, initialCommunicationGuidance: "Saved Slack instructions" }); + expect(buildNativeModelEnvelope(withGuidance).task.prompt).toBe(`Saved Slack instructions\n\n${current.task.prompt}`); + // Structured question/approval resumes may use a full envelope rather than + // a delta. They still must not repeat initial communication instructions. + expect(buildNativeModelEnvelope(withGuidance, { resumedSession: true })).toEqual(buildNativeModelEnvelope(current)); + expect(buildNativeModelEnvelope(parseNativeExecutionInput({ ...withGuidance, continuationPrompt: "new message" }), { resumedSession: true })).not.toHaveProperty("task"); + expect(buildNativeModelEnvelope(withGuidance).task.prompt.match(/Saved Slack instructions/g)).toHaveLength(1); expect(current).toMatchObject({ schema: "paperclip.native-execution-input.v4", provider: { kind: "codex", approvalPolicy: "on-request" }, diff --git a/packages/paperclip-runner/src/contracts/native-execution.ts b/packages/paperclip-runner/src/contracts/native-execution.ts index b5a31b85f7f..c23c2f9ab18 100644 --- a/packages/paperclip-runner/src/contracts/native-execution.ts +++ b/packages/paperclip-runner/src/contracts/native-execution.ts @@ -196,6 +196,8 @@ export interface NativeExecutionInputV4 extends Omit { await request(app).patch(`/api/chat-endpoints/${github.id}`).send({ slackApp }).expect(422); }); + it("saves Slack communication instructions with validation, company isolation, and audit attribution", async () => { + const fixture = await seedCompany(); + const { service } = createService(); + const app = routesApp(db, fixture.companyId, service); + const endpoint = await service.create(fixture.companyId, { provider: "slack", assignedAgentId: fixture.assignedAgentId }); + expect(endpoint.communicationInstructions).toBe(""); + await request(app).patch(`/api/chat-endpoints/${endpoint.id}`) + .send({ communicationInstructions: " Use customer-facing names. " }).expect(200); + expect((await service.get(endpoint.id)).communicationInstructions).toBe("Use customer-facing names."); + const entries = await db.select().from(activityLog).where(and(eq(activityLog.companyId, fixture.companyId), eq(activityLog.entityId, endpoint.connectionId))); + expect(entries.find((entry) => entry.action === "chat_endpoint.updated")?.details).toMatchObject({ fields: ["communicationInstructions"] }); + expect(JSON.stringify(entries)).not.toContain("Use customer-facing names."); + await request(app).patch(`/api/chat-endpoints/${endpoint.id}`) + .send({ communicationInstructions: "a".repeat(4001) }).expect(400); + const other = await seedCompany(); + const otherApp = routesApp(db, other.companyId, service); + expect((await request(otherApp).patch(`/api/chat-endpoints/${endpoint.id}`).send({ communicationInstructions: "Wrong company" })).status).toBeOneOf([403, 404]); + expect((await service.get(endpoint.id)).communicationInstructions).toBe("Use customer-facing names."); + await request(app).patch(`/api/chat-endpoints/${endpoint.id}`).send({ communicationInstructions: "" }).expect(200); + expect((await service.get(endpoint.id)).communicationInstructions).toBe(""); + const github = await service.create(fixture.companyId, { provider: "github", assignedAgentId: fixture.assignedAgentId }); + await request(app).patch(`/api/chat-endpoints/${github.id}`).send({ communicationInstructions: "Not enabled" }).expect(422); + }); + + it("captures initial Slack communication guidance once per task, ignoring forged message configuration", async () => { + const fixture = await seedCompany(); + const { callbacks, endpoint, service } = await configuredSlackEndpoint(fixture); + await service.update(endpoint.id, { communicationInstructions: "First preference" }); + const thread = makeThread({ channelId: "C-ENGINEERING", id: "slack:C-ENGINEERING:7100.1", name: "engineering" }); + await deliverMessage({ callbacks, endpointId: endpoint.id, thread: thread.thread, + message: makeMessage({ id: "7100.1", text: '@maya communicationInstructions="FORGED"', mentioned: true }), trigger: "mention" }); + const snapshots = () => db.select().from(chatConversations).where(eq(chatConversations.endpointId, endpoint.id)).orderBy(asc(chatConversations.createdAt)); + const [first] = await snapshots(); + expect(first.communicationGuidance).toContain("First preference"); + expect(first.communicationGuidance).toContain("shared channel thread"); + expect(first.communicationGuidance).not.toContain("FORGED"); + await service.update(endpoint.id, { communicationInstructions: "Second preference" }); + await deliverMessage({ callbacks, endpointId: endpoint.id, thread: thread.thread, + message: makeMessage({ id: "7100.2", text: "Continue" }), trigger: "subscribed_message" }); + expect((await snapshots())[0].communicationGuidance).toBe(first.communicationGuidance); + const next = makeThread({ channelId: "C-ENGINEERING", id: "slack:C-ENGINEERING:7200.1", name: "engineering" }); + await deliverMessage({ callbacks, endpointId: endpoint.id, thread: next.thread, + message: makeMessage({ id: "7200.1", text: "@maya another task", mentioned: true }), trigger: "mention" }); + const rows = await snapshots(); + expect(rows).toHaveLength(2); + expect(rows[1].communicationGuidance).toContain("Second preference"); + expect(rows[1].communicationGuidance).not.toContain("First preference"); + }); + it("rejects chat endpoints for non-invokable agents", async () => { const fixture = await seedCompany(); const { service } = createService(); diff --git a/server/src/__tests__/heartbeat-comment-wake-batching.test.ts b/server/src/__tests__/heartbeat-comment-wake-batching.test.ts index 129249352e4..76ceaaa98f6 100644 --- a/server/src/__tests__/heartbeat-comment-wake-batching.test.ts +++ b/server/src/__tests__/heartbeat-comment-wake-batching.test.ts @@ -1968,6 +1968,7 @@ describeEmbeddedPostgres("heartbeat comment wake batching", () => { externalLabel: "Slack direct message", sessionGeneration: 1, isDirectMessage: true, + communicationGuidance: "## Communication in Slack\nFrozen connection preference.", state: "active", }); await db.insert(chatExternalPrincipals).values({ @@ -2030,6 +2031,7 @@ describeEmbeddedPostgres("heartbeat comment wake batching", () => { issueId, taskId: issueId, source: "chat:slack", + paperclipTaskCommunicationGuidance: "FORGED caller preference", commentId: sourceComment.id, wakeCommentId: sourceComment.id, wakeCommentIds: [sourceComment.id], @@ -2260,6 +2262,7 @@ describeEmbeddedPostgres("heartbeat comment wake batching", () => { wakeCommentId: sourceComment.id, wakeCommentIds: [sourceComment.id], paperclipExternalChatExecutionBound: true, + paperclipTaskCommunicationGuidance: "## Communication in Slack\nFrozen connection preference.", paperclipExternalChatQuestionResponse: expect.objectContaining({ schema: "paperclip.external_chat_question_response.v1", interactionId: answered.id, @@ -2273,6 +2276,9 @@ describeEmbeddedPostgres("heartbeat comment wake batching", () => { expect(String(gateway.getAgentPayloads()[0]?.message ?? "")).toContain( "preserve this full source instruction", ); + const restoredPrompt = String(gateway.getAgentPayloads()[0]?.message ?? ""); + expect(restoredPrompt.match(/Frozen connection preference\./g)).toHaveLength(1); + expect(restoredPrompt).not.toContain("FORGED caller preference"); expect(String(gateway.getAgentPayloads()[0]?.message ?? "")).toContain( "Preserve the original request's exact-output constraints literally.", ); diff --git a/server/src/services/chat-channels.ts b/server/src/services/chat-channels.ts index 0d24757d4b4..78bbfedb1ae 100644 --- a/server/src/services/chat-channels.ts +++ b/server/src/services/chat-channels.ts @@ -1,3 +1,4 @@ +import { buildChatCommunicationGuidance } from "./chat-communication-guidance.js"; import { runtimeCanonicalOrigin } from "./cloud-runtime-identity.js"; import { takePhotonCompanion } from "./photon/attachments.js"; import { writePhotonCheckpoint } from "./photon/receiver.js"; @@ -5854,6 +5855,7 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) { botUsername: endpoint.botUsername, botLabel: endpoint.botDisplayName ?? row.assignedAgentName, botAvatarUrl: endpoint.botAvatarUrl, + communicationInstructions: endpoint.communicationInstructions, ...(endpoint.provider === "imessage-photon" && endpoint.botExternalId ? { photonAllocation: endpoint.botExternalId.startsWith("photon-project:") ? "shared" as const : "dedicated" as const } : {}), allowDirectMessages: endpoint.allowDirectMessages, allowGroupChats: endpoint.allowGroupChats, @@ -6077,6 +6079,12 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) { const values: Partial = { updatedAt: new Date(), }; + if (input.communicationInstructions !== undefined) { + if (existing.endpoint.provider !== "slack") { + throw unprocessable("Communication instructions are currently supported for Slack connections"); + } + values.communicationInstructions = input.communicationInstructions; + } if (input.slackApp) { if (existing.endpoint.provider !== "slack") { throw unprocessable("Slack app details only apply to Slack connections"); @@ -16028,6 +16036,11 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) { externalLabel: resource.label, providerUrl, isDirectMessage: thread.isDM, + communicationGuidance: buildChatCommunicationGuidance({ + provider: taskEndpoint.provider, + isDirectMessage: thread.isDM, + communicationInstructions: taskEndpoint.communicationInstructions, + }), state: "active", lastActivityAt: new Date(), }) diff --git a/server/src/services/chat-communication-guidance.test.ts b/server/src/services/chat-communication-guidance.test.ts new file mode 100644 index 00000000000..44b568d2597 --- /dev/null +++ b/server/src/services/chat-communication-guidance.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from "vitest"; +import { CHAT_PROVIDERS, updateChatEndpointSchema } from "@paperclipai/shared"; +import { buildChatCommunicationGuidance } from "./chat-communication-guidance.js"; + +describe("initial medium communication guidance", () => { + it("guides Slack presentation while retaining ordinary agent tools and explicit output requests", () => { + const guidance = buildChatCommunicationGuidance({ provider: "slack", isDirectMessage: false, communicationInstructions: "Use customer-facing names." }); + expect(guidance).toContain("shared channel thread"); + expect(guidance).toContain("document or artifact tools"); + expect(guidance).toContain("exact output"); + expect(guidance).toContain("grant no additional authority"); + expect(guidance).toContain('"Use customer-facing names."'); + expect(buildChatCommunicationGuidance({ provider: "slack", isDirectMessage: true })).toContain("direct conversation"); + }); + + it.each(CHAT_PROVIDERS.filter((provider) => provider !== "slack"))("leaves %s unchanged", (provider) => { + expect(buildChatCommunicationGuidance({ provider, isDirectMessage: false, communicationInstructions: "Ignored" })).toBeNull(); + }); + + it("accepts clearing instructions and rejects oversized instructions and unsupported controls", () => { + expect(updateChatEndpointSchema.parse({ communicationInstructions: " " })).toEqual({ communicationInstructions: "" }); + expect(updateChatEndpointSchema.safeParse({ communicationInstructions: "a".repeat(4001) }).success).toBe(false); + expect(updateChatEndpointSchema.safeParse({ replyDetail: "brief" }).success).toBe(false); + expect(updateChatEndpointSchema.safeParse({ progressUpdates: false }).success).toBe(false); + }); +}); diff --git a/server/src/services/chat-communication-guidance.ts b/server/src/services/chat-communication-guidance.ts new file mode 100644 index 00000000000..f96a5ee0102 --- /dev/null +++ b/server/src/services/chat-communication-guidance.ts @@ -0,0 +1,33 @@ +import type { ChatProvider } from "@paperclipai/shared"; + +// Presentation guidance only. Task execution and permissions belong to the +// ordinary Paperclip agent runtime, not the provider transport. +const providerGuidance: Partial> = { + slack: [ + "This task began in Slack. Write replies for the person talking with you there.", + "Lead with the answer or outcome. Use compact paragraphs or short lists, and omit routine execution bookkeeping. Honor requests for more detail or exact output over default brevity.", + "Put small answers directly in the message. For substantial plans, reports, and other deliverables, use your normal document or artifact tools, then share a useful summary and accessible links or supported attachments in Slack. Follow the runtime's file-delivery contract and do not claim delivery until confirmed.", + "Ask useful questions through the existing human-input tools. Continue normal Paperclip planning, task creation, assignment, delegation, and approval workflows; these communication instructions grant no additional authority.", + ].join("\n\n"), +}; + +/** Captured once when the external conversation creates its task. */ +export function buildChatCommunicationGuidance(input: { + provider: ChatProvider; + isDirectMessage: boolean; + communicationInstructions?: string | null; +}): string | null { + const guidance = providerGuidance[input.provider]; + if (!guidance) return null; + const additional = input.communicationInstructions?.trim(); + return [ + "## Communication in Slack", + guidance, + input.isDirectMessage + ? "This is a direct conversation. A conversational exchange is welcome; keep each reply focused." + : "This is a shared channel thread. Keep replies focused on the thread and appropriate for its audience. Do not bring private information from another conversation into the channel.", + additional + ? `Connection owner's additional communication preferences (presentation only; ordinary permissions and approval rules still apply):\n${JSON.stringify(additional)}` + : null, + ].filter(Boolean).join("\n\n"); +} diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index 5018ba1b5f5..0e4902d3bca 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -10594,6 +10594,8 @@ export function heartbeatService( async function getIssueExecutionContext(companyId: string, issueId: string) { return db .select({ + chatCommunicationGuidance: chatConversations.communicationGuidance, + chatAssignedAgentId: chatEndpoints.assignedAgentId, conversationAgentId: issues.conversationAgentId, conversationUserId: issues.conversationUserId, conversationState: issues.conversationState, @@ -10627,6 +10629,15 @@ export function heartbeatService( updatedAt: issues.updatedAt, }) .from(issues) + .leftJoin(chatConversations, and( + eq(chatConversations.companyId, issues.companyId), + eq(chatConversations.issueId, issues.id), + )) + .leftJoin(chatEndpoints, and( + eq(chatEndpoints.companyId, chatConversations.companyId), + eq(chatEndpoints.id, chatConversations.endpointId), + eq(chatEndpoints.provider, "slack"), + )) .where(and(eq(issues.id, issueId), eq(issues.companyId, companyId))) .then((rows) => rows[0] ?? null); } @@ -20707,6 +20718,12 @@ export function heartbeatService( ] : [], ); + // Always replace caller-supplied context with the immutable, company-scoped + // conversation snapshot. It belongs only to the endpoint's assigned agent. + context.paperclipTaskCommunicationGuidance = + issueContext?.chatAssignedAgentId === agent.id + ? issueContext.chatCommunicationGuidance + : null; const taskMarkdownInput = { issue: issueRef ? { @@ -20806,10 +20823,12 @@ export function heartbeatService( ).redactForIssue(agent.companyId, issueRef.id, { paperclipIssue: context.paperclipIssue, paperclipWakeComment: context.paperclipWakeComment, + paperclipTaskCommunicationGuidance: context.paperclipTaskCommunicationGuidance, paperclipTaskMarkdown: context.paperclipTaskMarkdown, paperclipTaskMarkdownCompact: context.paperclipTaskMarkdownCompact, }); context.paperclipIssue = redactedWakeContext.paperclipIssue; + context.paperclipTaskCommunicationGuidance = redactedWakeContext.paperclipTaskCommunicationGuidance; if (redactedWakeContext.paperclipWakeComment) { context.paperclipWakeComment = redactedWakeContext.paperclipWakeComment; @@ -23360,6 +23379,7 @@ export function heartbeatService( nativeReviewRequest ?? readNonEmptyString( selectPaperclipTaskMarkdown(context, { resumedSession: false, + includeCommunicationGuidance: false, }), ) ?? `# ${issueRef.identifier ?? issueRef.id}: ${issueRef.title}`, @@ -23367,6 +23387,7 @@ export function heartbeatService( ? `## Project repositories\nThe task workspace also contains these editable Git repositories:\n${projectRepositoryPaths.map((repo) => `- ${repo}`).join("\n")}` : null, ].filter(Boolean).join("\n\n"), + initialCommunicationGuidance: nativeReviewRequest ? null : readNonEmptyString(context.paperclipTaskCommunicationGuidance), wakePayload: context.paperclipWake, resumedSession, previousTurn: (() => { diff --git a/server/src/services/native-runtime/native-execution-input.test.ts b/server/src/services/native-runtime/native-execution-input.test.ts index 2550ab9dbbe..1e5959eeffb 100644 --- a/server/src/services/native-runtime/native-execution-input.test.ts +++ b/server/src/services/native-runtime/native-execution-input.test.ts @@ -61,6 +61,7 @@ describe("native execution input external-chat framing", () => { }, taskPrompt: "Continue the user's original request with their selected answer.", + initialCommunicationGuidance: "Initial Slack communication preferences.", wakePayload, resumedSession, workspace: { @@ -102,6 +103,9 @@ describe("native execution input external-chat framing", () => { ], }; const input = buildNativeExecutionInput(args); + expect(input.initialCommunicationGuidance).toBe("Initial Slack communication preferences."); + // The runtime adds this only after deciding whether provider recovery succeeded. + expect(input.task.prompt).not.toContain("Initial Slack communication preferences."); expect(input.task.title).toBe("External chat follow-up"); expect(input.task.prompt).toContain("Amber"); expect(input.task.prompt).not.toContain("## Questions that need a user response"); diff --git a/server/src/services/native-runtime/native-execution-input.ts b/server/src/services/native-runtime/native-execution-input.ts index fc46200f0df..04362f6db47 100644 --- a/server/src/services/native-runtime/native-execution-input.ts +++ b/server/src/services/native-runtime/native-execution-input.ts @@ -38,6 +38,7 @@ export function buildNativeExecutionInput(input: { workMode: string; }; taskPrompt: string; + initialCommunicationGuidance?: string | null; /** * The already-sanitized Paperclip wake envelope for this run. Native drivers * receive a closed execution input rather than the legacy adapter context, @@ -170,6 +171,7 @@ export function buildNativeExecutionInput(input: { .join("\n\n"); return parseNativeExecutionInput({ schema: "paperclip.native-execution-input.v4", + ...(input.initialCommunicationGuidance ? { initialCommunicationGuidance: input.initialCommunicationGuidance } : {}), ...(input.resumedSession && input.previousTurn && !input.conversationMode ? { continuationPrompt: buildNativeContinuationPrompt({ wakePayload: input.wakePayload, diff --git a/ui/src/api/chatEndpoints.ts b/ui/src/api/chatEndpoints.ts index a9bb4846085..76ee5dc4669 100644 --- a/ui/src/api/chatEndpoints.ts +++ b/ui/src/api/chatEndpoints.ts @@ -92,6 +92,7 @@ export interface ChatIdentityLinkPreview { } export interface ChatEndpoint { + communicationInstructions?: string; publicationMode?: "automatic" | "explicit"; externalExecutionPolicy?: "restricted" | "agent"; id: string; diff --git a/ui/src/pages/apps/Browse.test.tsx b/ui/src/pages/apps/Browse.test.tsx index 78b723a1a8b..895788d0f1d 100644 --- a/ui/src/pages/apps/Browse.test.tsx +++ b/ui/src/pages/apps/Browse.test.tsx @@ -17,9 +17,10 @@ const pushToastMock = vi.hoisted(() => vi.fn()); const navigateMock = vi.hoisted(() => vi.fn()); const setBreadcrumbsMock = vi.hoisted(() => vi.fn()); const experimentalMock = vi.hoisted(() => vi.fn()); +const chatSetupMock = vi.hoisted(() => vi.fn()); const chatListMock = vi.hoisted(() => vi.fn()); vi.mock("@/api/instanceSettings", () => ({ instanceSettingsApi: { getExperimental: experimentalMock } })); -vi.mock("@/api/chatEndpoints", () => ({ chatEndpointsApi: { list: chatListMock } })); +vi.mock("@/api/chatEndpoints", () => ({ chatEndpointsApi: { list: chatListMock, setup: chatSetupMock } })); vi.mock("@/api/tools", () => ({ toolsApi: { @@ -135,6 +136,7 @@ describe("Connectors landing page", () => { beforeEach(() => { experimentalMock.mockResolvedValue({ enableChatConnectors: true }); chatListMock.mockResolvedValue([]); + chatSetupMock.mockReset().mockResolvedValue({ status: "archived" }); listGalleryMock.mockResolvedValue({ apps: [ galleryEntry({ @@ -231,7 +233,7 @@ describe("Connectors landing page", () => { } expect(container.textContent).not.toContain("Private bot"); expect(container.textContent).not.toContain("Chat with agents"); - await act(() => container.querySelector('button[aria-label="Connect GitHub"]')!.click()); + await act(() => void container.querySelector('button[aria-label="Connect GitHub"]')!.click()); expect(navigateMock).toHaveBeenLastCalledWith("/apps/connect?source=github"); }); @@ -241,14 +243,14 @@ describe("Connectors landing page", () => { const client = await renderBrowse(); expect(chatListMock).toHaveBeenCalledWith("company-1"); expect(container.querySelector('[data-app-slug="telegram"]')).not.toBeNull(); - await act(() => container.querySelector('button[aria-label="Add connection GitHub"]')!.click()); + await act(() => void container.querySelector('button[aria-label="Add connection GitHub"]')!.click()); expect(navigateMock).toHaveBeenLastCalledWith("/apps/chat/connect?provider=github&toolHref=%2Fapps%2Fconnect%3Fsource%3Dgithub"); await act(() => { client.setQueryData(queryKeys.instance.experimentalSettings, { enableChatConnectors: false }); }); await flushReact(); expect(container.querySelector('[data-app-slug="telegram"]')).toBeNull(); expect(container.textContent).not.toContain("Chat agent"); expect(container.querySelector('a[href*="/apps/chat/"]')).toBeNull(); - await act(() => container.querySelector('button[aria-label="Connect GitHub"]')!.click()); + await act(() => void container.querySelector('button[aria-label="Connect GitHub"]')!.click()); expect(navigateMock).toHaveBeenLastCalledWith("/apps/connect?source=github"); }); @@ -468,6 +470,67 @@ describe("Connectors landing page", () => { ); }); + it.each(["slack", "discord", "telegram", "github", "microsoft-teams", "agentmail", "imessage-photon"])( + "puts Manage and removal in the %s chat menu while keeping draft setup visible", + async (provider) => { + chatListMock.mockResolvedValue([ + { id: "chat-active", provider, status: "active", assignedAgentName: "Active agent" }, + { id: "chat-draft", provider, status: "draft", assignedAgentName: "Draft agent" }, + { id: "chat-archived", provider, status: "archived", assignedAgentName: "Removed agent" }, + ]); + await renderBrowse(); + expect(container.textContent).not.toContain("Removed agent"); + expect(Array.from(container.querySelectorAll("button")).some((button) => button.textContent === "Manage")).toBe(false); + const finish = Array.from(container.querySelectorAll("button")).find((button) => button.textContent === "Finish setup"); + await act(() => finish!.click()); + expect(navigateMock).toHaveBeenLastCalledWith(`/apps/chat/connect?provider=${provider}&purpose=chat&resume=chat-draft`); + expect(container.querySelector('button[aria-label^="Manage Draft agent"]')).toBeTruthy(); + await act(() => void container.querySelector('button[aria-label^="Manage Active agent"]')!.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true }))); + await flushReact(); + const manage = Array.from(document.querySelectorAll('[role="menuitem"]')).find((item) => item.textContent?.trim() === "Manage"); + await act(() => manage!.click()); + expect(navigateMock).toHaveBeenLastCalledWith("/apps/chat/chat-active/settings"); + }, + ); + + it.each(["active", "draft"])("confirms chat removal for %s connections and refreshes the list", async (status) => { + chatListMock.mockResolvedValue([{ id: "chat-1", provider: "slack", status, assignedAgentName: "CEO" }]); + const client = await renderBrowse(); + const invalidate = vi.spyOn(client, "invalidateQueries"); + await act(() => void container.querySelector('button[aria-label="Manage CEO Slack connection"]')!.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true }))); + await flushReact(); + const remove = Array.from(document.querySelectorAll('[role="menuitem"]')).find((item) => item.textContent?.trim() === "Remove connection"); + await act(() => remove!.click()); + await flushReact(); + expect(chatSetupMock).not.toHaveBeenCalled(); + expect(document.body.textContent).toContain("Existing Paperclip tasks and conversation history remain available."); + chatListMock.mockResolvedValue([]); + await act(() => Array.from(document.querySelectorAll("button")).find((button) => button.textContent?.trim() === "Remove connection")!.click()); + await flushReact(); + expect(chatSetupMock).toHaveBeenCalledWith("chat-1", { action: "remove" }); + expect(archiveConnectionMock).not.toHaveBeenCalled(); + expect(invalidate).toHaveBeenCalledWith({ queryKey: queryKeys.chatEndpoints.list("company-1") }); + expect(container.textContent).not.toContain("CEO"); + }); + + it("keeps chat removal open for retry when the server rejects removal", async () => { + chatListMock.mockResolvedValue([{ id: "chat-1", provider: "slack", status: "draft", assignedAgentName: "CEO" }]); + chatSetupMock.mockRejectedValueOnce(new Error("Connection is busy. Try again.")); + await renderBrowse(); + await act(() => void container.querySelector('button[aria-label="Manage CEO Slack connection"]')!.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true }))); + await flushReact(); + await act(() => Array.from(document.querySelectorAll('[role="menuitem"]')).find((item) => item.textContent?.trim() === "Remove connection")!.click()); + await flushReact(); + await act(() => Array.from(document.querySelectorAll("button")).find((button) => button.textContent?.trim() === "Remove connection")!.click()); + await flushReact(); + expect(document.querySelector('[role="alertdialog"]')).toBeTruthy(); + expect(pushToastMock).toHaveBeenCalledWith(expect.objectContaining({ tone: "error", body: "Connection is busy. Try again." })); + await act(() => Array.from(document.querySelectorAll("button")).find((button) => button.textContent === "Cancel")!.click()); + await flushReact(); + expect(chatSetupMock).toHaveBeenCalledTimes(1); + expect(document.querySelector('[role="alertdialog"]')).toBeNull(); + }); + it("keeps an interrupted account visible and resumes setup from its account row", async () => { listApplicationsMock.mockResolvedValue({ applications: [application()] }); listConnectionsMock.mockResolvedValue({ diff --git a/ui/src/pages/apps/Browse.tsx b/ui/src/pages/apps/Browse.tsx index 8bf3c74d1dd..4c1d66e3c80 100644 --- a/ui/src/pages/apps/Browse.tsx +++ b/ui/src/pages/apps/Browse.tsx @@ -103,6 +103,7 @@ type ConnectionState = { }; type ConnectionRemovalTarget = { + kind?: "chat"; id: string; accountName: string; providerName: string; @@ -320,9 +321,17 @@ export function Browse({ renderAccountDetails = (connection) => connection.conne enabled: !!selectedCompanyId, }); const removeConnection = useMutation({ - mutationFn: (target: ConnectionRemovalTarget) => - toolsApi.archiveConnection(target.id), + mutationFn: async (target: ConnectionRemovalTarget) => { + if (target.kind === "chat") { + await chatEndpointsApi.setup(target.id, { action: "remove" }); + } else { + await toolsApi.archiveConnection(target.id); + } + }, onSuccess: (_connection, target) => { + queryClient.invalidateQueries({ + queryKey: queryKeys.chatEndpoints.list(selectedCompanyId!), + }); queryClient.invalidateQueries({ queryKey: queryKeys.tools.connections(selectedCompanyId!), }); @@ -335,7 +344,9 @@ export function Browse({ renderAccountDetails = (connection) => connection.conne pushToast({ title: "Connection removed", body: - target.remainingConnectionCount > 0 + target.kind === "chat" + ? `${target.providerName} is disconnected. Existing Paperclip tasks remain available.` + : target.remainingConnectionCount > 0 ? `${target.providerName} still has ${target.remainingConnectionCount} active ${target.remainingConnectionCount === 1 ? "connection" : "connections"} available to agents.` : `${target.providerName} is no longer available to agents through this connection. Its saved credentials were deleted.`, tone: "success", @@ -532,6 +543,7 @@ export function Browse({ renderAccountDetails = (connection) => connection.conne for (const endpoint of chatConnectorsEnabled ? (chatEndpointsQuery.data ?? []) : []) { + if (endpoint.status === "archived") continue; let target = [...rowsBySlug.values()].find( (row) => chatProviderForSlug(row.slug) === endpoint.provider, ); @@ -711,7 +723,9 @@ export function Browse({ renderAccountDetails = (connection) => connection.conne Remove {connectionToRemove?.accountName ?? "this"} connection? - {connectionToRemove && + {connectionToRemove?.kind === "chat" + ? `This connection will stop receiving new work from ${connectionToRemove.providerName}. Existing Paperclip tasks and conversation history remain available. This does not delete the app, bot, or account in ${connectionToRemove.providerName}.` + : connectionToRemove && connectionToRemove.remainingConnectionCount > 0 ? `This connection's saved credentials are deleted and agents lose access through it immediately. They can still use ${connectionToRemove.providerName} through ${connectionToRemove.remainingConnectionCount} other active ${connectionToRemove.remainingConnectionCount === 1 ? "connection" : "connections"}.` : "The saved credentials are deleted and agents lose access immediately. Connecting it again later requires a new sign-in or key."} @@ -864,19 +878,48 @@ export function ConnectorCard({ {endpoint.status.replace(/_/g, " ")} - +
+ {endpoint.status === "draft" ? ( + + ) : null} + + + + + + onNavigate(`/apps/chat/${endpoint.id}/settings`)}> + Manage + + + onRequestRemove({ + kind: "chat", + id: endpoint.id, + accountName: `${endpoint.assignedAgentName} · ${row.name}`, + providerName: row.name, + remainingConnectionCount: 0, + })} + > + + Remove connection + + + +
))} diff --git a/ui/src/pages/apps/chat/ChatCommunicationInstructions.tsx b/ui/src/pages/apps/chat/ChatCommunicationInstructions.tsx new file mode 100644 index 00000000000..8bb86dfdd33 --- /dev/null +++ b/ui/src/pages/apps/chat/ChatCommunicationInstructions.tsx @@ -0,0 +1,59 @@ +import { useId, useState } from "react"; +import { Button } from "@/components/ui/button"; +import { Textarea } from "@/components/ui/textarea"; + +export function ChatCommunicationInstructions({ value, onSave }: { + value: string; + onSave: (instructions: string) => Promise; +}) { + const id = useId(); + const [draft, setDraft] = useState(null); + const [pending, setPending] = useState(false); + const [error, setError] = useState(null); + const [saved, setSaved] = useState(false); + const text = draft ?? value; + const dirty = text.trim() !== value; + return ( +
{ + event.preventDefault(); + if (pending || !dirty) return; + setPending(true); + setError(null); + setSaved(false); + try { + await onSave(text.trim()); + setDraft(null); + setSaved(true); + } catch (cause) { + setError(cause instanceof Error ? cause.message : "Couldn’t save instructions. Try again."); + } finally { + setPending(false); + } + }}> +
+ +

+ Guide how this agent communicates in Slack. Optional; applies when new tasks start. +

+
+