mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-23 17:33:51 +02:00
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 for5f48df4e0. Greptile scored this head 5/5 with no actionable findings. No review threads remain unresolved. - Passed `pnpm -r typecheck` and `pnpm build` on PR head5f48df4e0. - 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:
1 parent
b82661b561
commit
d9b3a5653e
38 files changed
+852
-63
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
+194
-2
@@ -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
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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.",
|
||||
);
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -92,6 +92,7 @@ export interface ChatIdentityLinkPreview {
|
||||
}
|
||||
|
||||
export interface ChatEndpoint {
|
||||
communicationInstructions?: string;
|
||||
publicationMode?: "automatic" | "explicit";
|
||||
externalExecutionPolicy?: "restricted" | "agent";
|
||||
id: string;
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
};
|
||||
Reference in new issue
Block a user