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 <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip authored and GitHub committed 2026-09-21 14:14:25 -05:00
1 parent b82661b561
commit d9b3a5653e
38 files changed
+852 -63

No files matched your search

+9
View File
@@ -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,
+37
View File
@@ -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.
@@ -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);
@@ -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(
+16 -2
View File
@@ -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<string, unknown> | 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<string, unknown> | 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 (
@@ -22,6 +22,7 @@ import {
readPaperclipIssueWorkModeFromContext,
renderPaperclipWakePrompt,
selectPaperclipTaskMarkdown,
selectInitialCommunicationGuidance,
isPaperclipRecoveryWakePayload,
renderTemplate,
stringifyPaperclipWakePayload,
@@ -417,7 +418,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
};
const instructions = await buildInstructionsPrefix(config, onLog);
const taskContextNote = context.conversationMode === true
? selectPaperclipTaskMarkdown(context, { resumedSession: canReuseSession })
? selectPaperclipTaskMarkdown(context, { resumedSession: canReuseSession, includeCommunicationGuidance: false })
: "";
const wakePrompt = renderPaperclipWakePrompt(context.paperclipWake, {
conversationMode: context.conversationMode === true,
@@ -434,6 +435,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
: renderTemplate(promptTemplate, templateData).trim();
const paperclipEnvNote = renderPaperclipEnvNote(remoteEnv);
const prompt = joinPromptSections([
selectInitialCommunicationGuidance(context, { resumedSession: canReuseSession }),
instructions.prefix,
renderedBootstrapPrompt,
wakePrompt,
@@ -45,6 +45,7 @@ import {
renderTemplate,
renderPaperclipWakePrompt,
selectPaperclipTaskMarkdown,
selectInitialCommunicationGuidance,
isPaperclipRecoveryWakePayload,
stringifyPaperclipWakePayload,
DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE,
@@ -571,7 +572,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
? renderTemplate(bootstrapPromptTemplate, templateData).trim()
: "";
const taskContextNote = context.conversationMode === true
? selectPaperclipTaskMarkdown(context, { resumedSession: Boolean(sessionId) })
? selectPaperclipTaskMarkdown(context, { resumedSession: Boolean(sessionId), includeCommunicationGuidance: false })
: "";
const wakePrompt = renderPaperclipWakePrompt(context.paperclipWake, {
conversationMode: context.conversationMode === true,
@@ -584,7 +585,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
: renderTemplate(promptTemplate, templateData);
const sessionHandoffNote = asString(context.paperclipSessionHandoffMarkdown, "").trim();
const paperclipEnvNote = renderPaperclipEnvNote(env);
const prompt = joinPromptSections([
const basePrompt = joinPromptSections([
instructionsPrefix,
renderedBootstrapPrompt,
wakePrompt,
@@ -594,7 +595,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
renderedPrompt,
]);
const promptMetrics = {
promptChars: prompt.length,
promptChars: basePrompt.length,
instructionsChars,
bootstrapPromptChars: renderedBootstrapPrompt.length,
wakePromptChars: wakePrompt.length,
@@ -615,6 +616,10 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
};
const runAttempt = async (resumeSessionId: string | null) => {
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<AdapterExec
commandArgs: args,
env: loggedEnv,
prompt,
promptMetrics,
promptMetrics: { ...promptMetrics, promptChars: prompt.length },
context,
});
}
@@ -1,4 +1,7 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
const {
ensureAdapterExecutionTargetCommandResolvable,
@@ -113,4 +116,38 @@ describe("gemini_local ACP startup fallback", () => {
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 });
}
},
);
});
@@ -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<AdapterExec
? renderTemplate(bootstrapPromptTemplate, templateData).trim()
: "";
const taskContextNote = context.conversationMode === true
? selectPaperclipTaskMarkdown(context, { resumedSession: Boolean(sessionId) })
? selectPaperclipTaskMarkdown(context, { resumedSession: Boolean(sessionId), includeCommunicationGuidance: false })
: "";
const wakePrompt = renderPaperclipWakePrompt(context.paperclipWake, {
conversationMode: context.conversationMode === true,
@@ -574,7 +575,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
const sessionHandoffNote = asString(context.paperclipSessionHandoffMarkdown, "").trim();
const paperclipEnvNote = renderPaperclipEnvNote(env);
const apiAccessNote = renderApiAccessNote(env);
const prompt = joinPromptSections([
const basePrompt = joinPromptSections([
instructionsPrefix,
renderedBootstrapPrompt,
wakePrompt,
@@ -585,7 +586,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
renderedPrompt,
]);
const promptMetrics = {
promptChars: prompt.length,
promptChars: basePrompt.length,
instructionsChars: instructionsPrefix.length,
bootstrapPromptChars: renderedBootstrapPrompt.length,
wakePromptChars: wakePrompt.length,
@@ -595,7 +596,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
heartbeatPromptChars: renderedPrompt.length,
};
const buildArgs = (resumeSessionId: string | null) => {
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<AdapterExec
};
const runAttempt = async (resumeSessionId: string | null) => {
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<AdapterExec
)),
env: loggedEnv,
prompt,
promptMetrics,
promptMetrics: { ...promptMetrics, promptChars: prompt.length },
context,
});
}
@@ -35,6 +35,7 @@ import {
renderTemplate,
renderPaperclipWakePrompt,
selectPaperclipTaskMarkdown,
selectInitialCommunicationGuidance,
isPaperclipRecoveryWakePayload,
resolveLegacyPaperclipDesiredSkillNames,
stringifyPaperclipWakePayload,
@@ -492,7 +493,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
context,
};
const taskContextNote = context.conversationMode === true
? selectPaperclipTaskMarkdown(context, { resumedSession: Boolean(sessionId) })
? selectPaperclipTaskMarkdown(context, { resumedSession: Boolean(sessionId), includeCommunicationGuidance: false })
: "";
const wakePrompt = renderPaperclipWakePrompt(context.paperclipWake, {
conversationMode: context.conversationMode === true,
@@ -506,7 +507,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
const sessionHandoffNote = asString(context.paperclipSessionHandoffMarkdown, "").trim();
const paperclipEnvNote = renderPaperclipEnvNote(env);
const apiAccessNote = renderApiAccessNote(env);
const prompt = joinPromptSections([
const basePrompt = joinPromptSections([
wakePrompt,
taskContextNote,
sessionHandoffNote,
@@ -515,7 +516,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
renderedPrompt,
]);
const promptMetrics = {
promptChars: prompt.length,
promptChars: basePrompt.length,
wakePromptChars: wakePrompt.length,
taskContextChars: taskContextNote.length,
sessionHandoffChars: sessionHandoffNote.length,
@@ -523,7 +524,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
heartbeatPromptChars: renderedPrompt.length,
};
const buildArgs = (resumeSessionId: string | null) => {
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<AdapterExec
};
const runAttempt = async (resumeSessionId: string | null) => {
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<AdapterExec
)),
env: loggedEnv,
prompt,
promptMetrics,
promptMetrics: { ...promptMetrics, promptChars: prompt.length },
context,
});
}
@@ -40,6 +40,7 @@ import {
renderTemplate,
renderPaperclipWakePrompt,
selectPaperclipTaskMarkdown,
selectInitialCommunicationGuidance,
isPaperclipRecoveryWakePayload,
stringifyPaperclipWakePayload,
DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE,
@@ -517,7 +518,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
? renderTemplate(bootstrapPromptTemplate, templateData).trim()
: "";
const taskContextNote = context.conversationMode === true
? selectPaperclipTaskMarkdown(context, { resumedSession: Boolean(sessionId) })
? selectPaperclipTaskMarkdown(context, { resumedSession: Boolean(sessionId), includeCommunicationGuidance: false })
: "";
const wakePrompt = renderPaperclipWakePrompt(context.paperclipWake, {
conversationMode: context.conversationMode === true,
@@ -531,7 +532,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
const sessionHandoffNote = asString(context.paperclipSessionHandoffMarkdown, "").trim();
const paperclipEnvNote = renderPaperclipEnvNote(env);
const apiAccessNote = renderApiAccessNote(env);
const prompt = joinPromptSections([
const basePrompt = joinPromptSections([
instructionsPrefix,
renderedBootstrapPrompt,
wakePrompt,
@@ -542,7 +543,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
renderedPrompt,
]);
const promptMetrics = {
promptChars: prompt.length,
promptChars: basePrompt.length,
instructionsChars: instructionsPrefix.length,
bootstrapPromptChars: renderedBootstrapPrompt.length,
wakePromptChars: wakePrompt.length,
@@ -552,7 +553,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
heartbeatPromptChars: renderedPrompt.length,
};
const buildArgs = (resumeSessionId: string | null) => {
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<AdapterExec
};
const runAttempt = async (resumeSessionId: string | null) => {
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<AdapterExec
)),
env: loggedEnv,
prompt,
promptMetrics,
promptMetrics: { ...promptMetrics, promptChars: prompt.length },
context,
});
}
@@ -12,6 +12,8 @@ import {
readPaperclipIssueWorkModeFromContext,
renderPaperclipWakePrompt,
selectPaperclipTaskMarkdown,
selectInitialCommunicationGuidance,
joinPromptSections,
stringifyPaperclipWakePayload,
} from "@paperclipai/adapter-utils/server-utils";
import crypto, { randomUUID } from "node:crypto";
@@ -1117,7 +1119,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
: structuredWakePrompt,
resolveClaimedApiKeyPath(ctx.config.claimedApiKeyPath),
ctx.context.conversationMode === true
? selectPaperclipTaskMarkdown(ctx.context, { resumedSession: Boolean(ctx.runtime?.sessionId) })
? selectPaperclipTaskMarkdown(ctx.context, { resumedSession: Boolean(ctx.runtime?.sessionId), includeCommunicationGuidance: false })
: undefined,
);
@@ -1133,7 +1135,10 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
});
const templateMessage = nonEmpty(payloadTemplate.message) ?? nonEmpty(payloadTemplate.text);
const message = templateMessage ? appendWakeText(templateMessage, wakeText) : wakeText;
const message = joinPromptSections([
selectInitialCommunicationGuidance(ctx.context, { resumedSession: Boolean(ctx.runtime?.sessionId) }),
templateMessage ? appendWakeText(templateMessage, wakeText) : wakeText,
]);
const agentParams = buildAgentParams({
payloadTemplate,
@@ -41,6 +41,7 @@ import {
renderTemplate,
renderPaperclipWakePrompt,
selectPaperclipTaskMarkdown,
selectInitialCommunicationGuidance,
isPaperclipRecoveryWakePayload,
stringifyPaperclipWakePayload,
DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE,
@@ -574,7 +575,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
? renderTemplate(bootstrapPromptTemplate, templateData).trim()
: "";
const taskContextNote = context.conversationMode === true
? selectPaperclipTaskMarkdown(context, { resumedSession: Boolean(sessionId) })
? selectPaperclipTaskMarkdown(context, { resumedSession: Boolean(sessionId), includeCommunicationGuidance: false })
: "";
const wakePrompt = renderPaperclipWakePrompt(context.paperclipWake, {
conversationMode: context.conversationMode === true,
@@ -586,7 +587,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
? ""
: renderTemplate(promptTemplate, templateData);
const sessionHandoffNote = asString(context.paperclipSessionHandoffMarkdown, "").trim();
const prompt = joinPromptSections([
const basePrompt = joinPromptSections([
instructionsPrefix,
renderedBootstrapPrompt,
wakePrompt,
@@ -595,7 +596,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
renderedPrompt,
]);
const promptMetrics = {
promptChars: prompt.length,
promptChars: basePrompt.length,
instructionsChars: instructionsPrefix.length,
bootstrapPromptChars: renderedBootstrapPrompt.length,
wakePromptChars: wakePrompt.length,
@@ -623,6 +624,10 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
};
const runAttempt = async (resumeSessionId: string | null) => {
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<AdapterExec
commandArgs: [...args, `<stdin prompt ${prompt.length} chars>`],
env: loggedEnv,
prompt,
promptMetrics,
promptMetrics: { ...promptMetrics, promptChars: prompt.length },
context,
});
}
@@ -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<AdapterExec
? renderTemplate(bootstrapPromptTemplate, templateData).trim()
: "";
const taskContextNote = context.conversationMode === true
? selectPaperclipTaskMarkdown(context, { resumedSession: canResumeSession })
? selectPaperclipTaskMarkdown(context, { resumedSession: canResumeSession, includeCommunicationGuidance: false })
: "";
const wakePrompt = renderPaperclipWakePrompt(context.paperclipWake, {
conversationMode: context.conversationMode === true,
@@ -632,7 +633,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
? ""
: renderTemplate(promptTemplate, templateData);
const sessionHandoffNote = asString(context.paperclipSessionHandoffMarkdown, "").trim();
const userPrompt = joinPromptSections([
const baseUserPrompt = joinPromptSections([
renderedBootstrapPrompt,
wakePrompt,
taskContextNote,
@@ -641,7 +642,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
]);
const promptMetrics = {
systemPromptChars: renderedSystemPromptExtension.length,
promptChars: userPrompt.length,
promptChars: baseUserPrompt.length,
bootstrapPromptChars: renderedBootstrapPrompt.length,
wakePromptChars: wakePrompt.length,
taskContextChars: taskContextNote.length,
@@ -665,7 +666,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
return notes;
})();
const buildArgs = (sessionFile: string): string[] => {
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<AdapterExec
};
const runAttempt = async (sessionFile: string) => {
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<AdapterExec
commandArgs: args,
env: loggedEnv,
prompt: userPrompt,
promptMetrics,
promptMetrics: { ...promptMetrics, promptChars: userPrompt.length },
context,
});
}
@@ -0,0 +1,2 @@
ALTER TABLE "chat_conversations" ADD COLUMN IF NOT EXISTS "communication_guidance" text;--> statement-breakpoint
ALTER TABLE "chat_endpoints" ADD COLUMN IF NOT EXISTS "communication_instructions" text DEFAULT '' NOT NULL;
@@ -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": "",
+7
View File
@@ -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
}
]
}
+3
View File
@@ -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 })
@@ -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" },
@@ -196,6 +196,8 @@ export interface NativeExecutionInputV4 extends Omit<NativeExecutionInputV3, "sc
provider: NativeProviderConfigV4;
/** Used only after the runtime proves provider-session recovery succeeded. */
continuationPrompt?: string | null;
/** Restored only when starting a fresh provider session, never on a resume. */
initialCommunicationGuidance?: string | null;
}
export type NativeExecutionInput = NativeExecutionInputV1 | NativeExecutionInputV2 | NativeExecutionInputV3 | NativeExecutionInputV4;
@@ -292,7 +294,7 @@ export function parseNativeExecutionInput(value: unknown): NativeExecutionInput
"credentialBindings",
...(isV2 ? ["executionMode", "planningContext"] : []),
...(isV3 ? ["runtimeContext"] : []),
...(isV4 ? ["continuationPrompt"] : []),
...(isV4 ? ["continuationPrompt", "initialCommunicationGuidance"] : []),
], "input");
if (!isV2 && input.schema !== NATIVE_EXECUTION_INPUT_SCHEMA_V1) {
throw new NativeExecutionInputError(
@@ -717,6 +719,7 @@ export function parseNativeExecutionInput(value: unknown): NativeExecutionInput
...withRuntimeContext,
schema: NATIVE_EXECUTION_INPUT_SCHEMA,
...(input.continuationPrompt !== undefined ? { continuationPrompt: nullableText(input.continuationPrompt, "input.continuationPrompt") } : {}),
...(input.initialCommunicationGuidance !== undefined ? { initialCommunicationGuidance: nullableText(input.initialCommunicationGuidance, "input.initialCommunicationGuidance") } : {}),
provider: parsedProvider as NativeProviderConfigV4,
};
}
@@ -753,7 +756,12 @@ export function buildNativeModelEnvelope(input: NativeExecutionInput, options?:
}
return {
schema: NATIVE_MODEL_ENVELOPE_SCHEMA,
task: structuredClone(input.task),
task: {
...structuredClone(input.task),
prompt: !options?.resumedSession && "initialCommunicationGuidance" in input && input.initialCommunicationGuidance
? `${input.initialCommunicationGuidance}\n\n${input.task.prompt}`
: input.task.prompt,
},
executionMode: input.executionMode,
planningContext: structuredClone(input.planningContext),
workspace: input.provider.kind === "claude_managed" || input.provider.kind === "aws_agentcore"
@@ -217,6 +217,8 @@ export type ChannelPublicationMode = "automatic" | "explicit";
export type ExternalMessageExecutionPolicy = "restricted" | "agent";
export interface ChatEndpoint {
/** Additional presentation instructions captured only for newly created tasks. */
communicationInstructions?: string;
id: string;
companyId: string;
connectionId: string;
@@ -474,6 +476,7 @@ export interface CreateChatEndpointInput {
export interface UpdateChatEndpointInput {
slackApp?: SlackAppConfiguration;
communicationInstructions?: string;
allowDirectMessages?: boolean;
allowGroupChats?: boolean;
allowUnlinkedPeople?: boolean;
@@ -84,6 +84,7 @@ export const slackAppConfigurationSchema = z.object({
export const updateChatEndpointSchema = z
.object({
communicationInstructions: multilineTextSchema.pipe(z.string().trim().max(4000)).optional(),
slackApp: slackAppConfigurationSchema.optional(),
allowDirectMessages: z.boolean().optional(),
allowGroupChats: z.boolean().optional(),
@@ -2968,6 +2968,55 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => {
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();
@@ -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.",
);
+13
View File
@@ -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<typeof chatEndpoints.$inferInsert> = {
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(),
})
@@ -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);
});
});
@@ -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<Record<ChatProvider, string>> = {
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");
}
+21
View File
@@ -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: (() => {
@@ -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");
@@ -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,
+1
View File
@@ -92,6 +92,7 @@ export interface ChatIdentityLinkPreview {
}
export interface ChatEndpoint {
communicationInstructions?: string;
publicationMode?: "automatic" | "explicit";
externalExecutionPolicy?: "restricted" | "agent";
id: string;
+67 -4
View File
@@ -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<HTMLButtonElement>('button[aria-label="Connect GitHub"]')!.click());
await act(() => void container.querySelector<HTMLButtonElement>('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<HTMLButtonElement>('button[aria-label="Add connection GitHub"]')!.click());
await act(() => void container.querySelector<HTMLButtonElement>('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<HTMLButtonElement>('button[aria-label="Connect GitHub"]')!.click());
await act(() => void container.querySelector<HTMLButtonElement>('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<HTMLElement>('[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<HTMLElement>('[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<HTMLElement>('[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({
+60 -17
View File
@@ -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?
</AlertDialogTitle>
<AlertDialogDescription>
{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({
<span className="text-xs text-muted-foreground">
{endpoint.status.replace(/_/g, " ")}
</span>
<Button
size="sm"
variant="outline"
onClick={() =>
onNavigate(
endpoint.status === "draft"
? `/apps/chat/connect?provider=${endpoint.provider}&purpose=chat&resume=${endpoint.id}`
: `/apps/chat/${endpoint.id}/settings`,
)
}
>
{endpoint.status === "draft" ? "Finish setup" : "Manage"}
</Button>
<div className="flex items-center gap-2">
{endpoint.status === "draft" ? (
<Button
size="sm"
variant="outline"
onClick={() => onNavigate(`/apps/chat/connect?provider=${endpoint.provider}&purpose=chat&resume=${endpoint.id}`)}
>
Finish setup
</Button>
) : null}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon-sm"
aria-label={`Manage ${endpoint.assignedAgentName} ${row.name} connection`}
>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onSelect={() => onNavigate(`/apps/chat/${endpoint.id}/settings`)}>
Manage
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
variant="destructive"
onSelect={() => onRequestRemove({
kind: "chat",
id: endpoint.id,
accountName: `${endpoint.assignedAgentName} · ${row.name}`,
providerName: row.name,
remainingConnectionCount: 0,
})}
>
<Trash2 />
Remove connection
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
))}
</div>
@@ -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<void>;
}) {
const id = useId();
const [draft, setDraft] = useState<string | null>(null);
const [pending, setPending] = useState(false);
const [error, setError] = useState<string | null>(null);
const [saved, setSaved] = useState(false);
const text = draft ?? value;
const dirty = text.trim() !== value;
return (
<form className="space-y-3" aria-labelledby={`${id}-label`} onSubmit={async (event) => {
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);
}
}}>
<div className="space-y-1">
<label id={`${id}-label`} htmlFor={id} className="text-sm font-semibold">Additional communication instructions</label>
<p id={`${id}-help`} className="text-sm text-muted-foreground">
Guide how this agent communicates in Slack. Optional; applies when new tasks start.
</p>
</div>
<Textarea
id={id}
aria-describedby={`${id}-help`}
value={text}
disabled={pending}
maxLength={4000}
rows={4}
placeholder="For example: Use our product names and explain technical terms for a nontechnical audience."
onChange={(event) => { setDraft(event.target.value); setSaved(false); setError(null); }}
/>
{error && <p role="alert" className="text-sm text-destructive">{error}</p>}
<div className="flex items-center justify-between gap-3">
<div>
{dirty ? <Button type="button" variant="ghost" disabled={pending} onClick={() => { setDraft(null); setError(null); setSaved(false); }}>Cancel</Button>
: saved ? <span role="status" className="text-sm text-muted-foreground">Saved. Applies to new tasks.</span> : null}
</div>
<Button type="submit" disabled={!dirty || pending}>{pending ? "Saving…" : "Save instructions"}</Button>
</div>
</form>
);
}
@@ -1,4 +1,5 @@
import { defaultSlackAppName } from "./slack-app-name";
import { ChatCommunicationInstructions } from "./ChatCommunicationInstructions";
import { SlackAvatarSettings } from "./SlackAvatarStep";
import { agentsApi } from "@/api/agents";
import { agentAvatarUrl } from "@/lib/agent-avatar-url";
@@ -418,6 +419,14 @@ function Settings({
avatarUrl={agentAvatarUrl(resolveAgentAppearance(avatarAgent.data?.appearance, endpoint.assignedAgentId), 512, 1, "rest")}
/>
)}
{endpoint.provider === "slack" && <ChatCommunicationInstructions
key={endpoint.id}
value={endpoint.communicationInstructions ?? ""}
onSave={async (communicationInstructions) => {
const next = await chatEndpointsApi.update(endpointId, { communicationInstructions });
queryClient.setQueryData(queryKeys.chatEndpoints.detail(endpointId), next);
}}
/>}
{endpoint.provider === "telegram" && (
<div className="space-y-2">
<h2 className="text-lg font-semibold">Telegram group command</h2>
@@ -1,9 +1,13 @@
import { useState } from "react";
import { ChatCommunicationInstructions } from "@/pages/apps/chat/ChatCommunicationInstructions";
import { Copy, ExternalLink } from "lucide-react";
import { Button } from "@/components/ui/button";
import { SlackAvatarSettings } from "@/pages/apps/chat/SlackAvatarStep";
import avatar from "./ceo-cliptoon.png";
export function SlackAvatarSettingsPreview() {
export function SlackAvatarSettingsPreview({ initialInstructions = "", failFirstSave = false }: { initialInstructions?: string; failFirstSave?: boolean }) {
const [instructions, setInstructions] = useState(initialInstructions);
const [failSave, setFailSave] = useState(failFirstSave);
return (
<div className="min-h-screen bg-background text-foreground">
<header className="flex gap-3 border-b border-border px-6 py-4 text-sm">
@@ -48,6 +52,10 @@ export function SlackAvatarSettingsPreview() {
appName="ceo-paperclip"
avatarUrl={avatar}
/>
<ChatCommunicationInstructions value={instructions} onSave={async (next) => {
if (failSave) { setFailSave(false); throw new Error("Couldn’t save instructions. Try again."); }
setInstructions(next);
}} />
<section className="space-y-4">
<h2 className="text-lg font-semibold">
Where this agent can work
@@ -0,0 +1,43 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { expect, fn, userEvent, within } from "storybook/test";
import { ConnectorCard } from "@/pages/apps/Browse";
import type { ChatEndpoint } from "@/api/chatEndpoints";
const endpoint: ChatEndpoint = {
id: "slack-active", companyId: "demo", provider: "slack", status: "active",
assignedAgentId: "ceo", assignedAgentName: "CEO", providerAccountLabel: "Paperclip",
allowUnlinkedPeople: false,
};
const meta = {
title: "Connections/Chat connection actions",
component: ConnectorCard,
parameters: { layout: "padded" },
args: {
row: {
key: "slack", slug: "slack", name: "Slack", brandKey: "slack",
description: "Give agents Slack tools or let people start and continue Paperclip work from Slack.",
entry: null, applications: [], connections: [],
chatEndpoints: [endpoint, { ...endpoint, id: "slack-draft", status: "draft", assignedAgentName: "Carl" }],
},
userProfileById: new Map(), chatConnectorsEnabled: true,
onNavigate: fn(), onRequestRemove: fn(),
},
} satisfies Meta<typeof ConnectorCard>;
export default meta;
type Story = StoryObj<typeof meta>;
export const ActiveAndDraft: Story = {
play: async ({ canvasElement, args }) => {
const canvas = within(canvasElement);
await expect(canvas.getByRole("button", { name: "Finish setup" })).toBeVisible();
await userEvent.click(canvas.getByRole("button", { name: "Manage CEO Slack connection" }));
const body = within(document.body);
await expect(body.getByRole("menuitem", { name: "Manage" })).toBeVisible();
await userEvent.click(body.getByRole("menuitem", { name: "Remove connection" }));
await expect(args.onRequestRemove).toHaveBeenCalledWith(expect.objectContaining({ kind: "chat", id: "slack-active" }));
await userEvent.click(canvas.getByRole("button", { name: "Manage Carl Slack connection" }));
await expect(body.getByRole("menuitem", { name: "Remove connection" })).toBeVisible();
await userEvent.keyboard("{Escape}");
},
};
@@ -0,0 +1,28 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { expect, userEvent, within } from "storybook/test";
import { SlackAvatarSettingsPreview } from "../prototypes/slack-avatar/SlackAvatarSettingsPreview";
const meta = {
title: "Connections/Slack/Communication in Settings",
component: SlackAvatarSettingsPreview,
parameters: { layout: "fullscreen", docs: { description: { component: "Production communication field in a Settings fixture. Saves use local story state; instructions apply to new tasks. Reply detail and progress are built-in behavior." } } },
} satisfies Meta<typeof SlackAvatarSettingsPreview>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {};
export const WithInstructions: Story = { args: { initialInstructions: "Use our product names and explain technical terms for a nontechnical audience." } };
export const Mobile: Story = { globals: { viewport: { value: "mobile1", isRotated: false } } };
export const SaveAndRetry: Story = {
args: { failFirstSave: true },
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const field = canvas.getByRole("textbox", { name: "Additional communication instructions" });
await userEvent.type(field, "Use customer-facing product names.");
await userEvent.click(canvas.getByRole("button", { name: "Save instructions" }));
await expect(canvas.getByRole("alert")).toHaveTextContent("Couldn’t save instructions");
await expect(field).toHaveValue("Use customer-facing product names.");
await userEvent.click(canvas.getByRole("button", { name: "Save instructions" }));
await expect(canvas.getByRole("status")).toHaveTextContent("Saved. Applies to new tasks.");
await expect(canvas.getByRole("button", { name: "Save instructions" })).toBeDisabled();
},
};