Files
PaperClipAI/server/src/__tests__/slack-conversation-lifecycle.test.ts
T
DottaandPaperclip 3ec7309d91 feat(voice): add experimental Speko task connection runtime (#15727)
First PR: runtime. UI: #15728. Qualification: #15729.

## Thinking Path

> - Paperclip manages agent work through tasks and governed connections.
> - Chat connections already bind external conversations to those tasks.
> - Speko handles voice and telephony, while the selected Paperclip
agent does the work.
> - A call needs durable session authority, ordered follow-ups, and safe
result delivery.
> - This PR adds an explicit voice transport to the existing connection
runtime.
> - The UI and qualification suites follow in linked PRs. Speko stays
experimental.

## Linked Issues or Issue Description

**Problem or motivation**

Phone and browser voice conversations cannot use the chat connection
task lifecycle. A separate voice runtime is needed to preserve task
authority, retries, and reply history.

**Proposed solution**

Add company-scoped voice sessions, signed callbacks, durable replies,
inbound intake, and an agent-facing outbound-call tool. Reuse existing
tasks, queues, secrets, and publications.

**Roadmap alignment**

This extends the experimental chat connector entry points under Agent
Chat and Connected Apps. The project owner requested this feature.

Refs #11418 for the original provider catalog proposal. This
implementation uses the newer chat connection infrastructure rather than
an MCP catalog-only integration. Refs #15721 for remote OpenCode
startup.

## What Changed

- Add additive voice schema, shared contracts, session APIs, and
provider callbacks.
- Bind browser, inbound, and outbound sessions to an endpoint, task, and
selected agent.
- Add low-trust guest intake in an explicitly selected sandbox and keep
ordinary approval rules.
- Submit task work through session-scoped tools and push approved
replies through Speko POST /v1/calls/{id}/messages.
- Distinguish provider acceptance from confirmed spoken playback.
Reconcile reports without downloading recordings.
- Add protocol, migration, persistence, provider, and
delivery-diagnostic tests.

## Verification

- Master 3389bea708 is integrated. Database/shared builds, server
typecheck, migration upgrade/replay checks, and 46
communication-guidance/provider/protocol tests passed.
- The final persistence suite passed 85 real-database tests. Five
focused native continuation/authorization regressions passed. Five
callback ownership/rotation tests passed after the last runtime repair.
- Review repairs cover reconnect ownership, current caller authority,
duplicate delivery, multipart answers, task-page answers, guest question
handling, and saved sandbox recovery.
- A real OpenCode/OpenRouter DeepSeek task ran in Daytona and saved a
correct disk-space answer. This did not establish live-call playback.
- Full local typecheck/build passed before the final repairs; focused
typechecks passed after them. Latest-head CI runs the repository gates.
- The separate integrated browser qualification passed two journeys and
failed browser answer retrieval. The feature remains experimental. The
report in #15729 records this failure and outstanding real-provider
acceptance.
- No lockfiles are included. Speko migrations follow master as
0324–0330.

## Risks

- Voice sessions add migrations and provider-dependent lifecycle
behavior. Migrations preserve existing chat and AI transports.
- Callback origins must be reachable HTTPS. Provider keys and signing
secrets remain vaulted.
- Unverified callers receive task-scoped low-trust authority. Caller ID
does not grant private task access.
- Outbound telephony incurs provider charges. Provider acceptance is not
proof that the caller heard an answer.
- Keep the feature behind the existing experimental chat setting until
the real browser, inbound, outbound, and delayed-result acceptance
matrix passes.

## Model Used

OpenAI Codex, GPT-6, with tool use and code execution. The exact serving
model ID and context window are not exposed in this session.

## 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
- [ ] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-10-09 21:54:11 -05:00

301 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import express from "express";
import request from "supertest";
import { issueRoutes } from "../routes/issues.js";
import { randomUUID } from "node:crypto";
import { and, eq } from "drizzle-orm";
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
import {
activityLog, agents, agentWakeupRequests, approvals, chatConversations, chatEndpoints,
chatMessageLinks, chatPublications, companies, createDb, heartbeatRuns, issueApprovals,
issueComments, issueRelations, issueThreadInteractions, issues, nativeRunFinalizations,
toolApplications, toolConnections,
} from "@paperclipai/db";
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
import { settleSlackConversation } from "../services/slack-conversation-lifecycle.js";
import { externalConversationStateSql, resumeExternalConversation } from "../services/slack-conversation-state.js";
import { executionIssueCondition } from "../services/issue-visibility.js";
import { dashboardService } from "../services/dashboard.js";
import { attentionService } from "../services/attention.js";
import { companySearchQuerySchema } from "@paperclipai/shared";
import { companySearchService } from "../services/company-search.js";
import { recoveryService } from "../services/recovery/service.js";
import { issueService } from "../services/issues.js";
const support = await getEmbeddedPostgresTestSupport();
(support.supported ? describe : describe.skip)("Slack conversation idle lifecycle", () => {
let database: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>>;
let db: ReturnType<typeof createDb>;
beforeAll(async () => {
database = await startEmbeddedPostgresTestDatabase("paperclip-slack-idle-");
db = createDb(database.connectionString);
}, 90000);
afterAll(async () => {
await db?.$client.end({ timeout: 0 });
await database?.cleanup();
});
async function fixture(native = false) {
const companyId = randomUUID();
const agentId = randomUUID();
const issueId = randomUUID();
const endpointId = randomUUID();
const conversationId = randomUUID();
const runId = randomUUID();
const wakeId = randomUUID();
const responseId = randomUUID();
const publicationId = randomUUID();
const applicationId = randomUUID();
const connectionId = randomUUID();
const start = new Date(Date.now() - 10000);
await db.insert(companies).values({ id: companyId, name: "Slack", issuePrefix: companyId.slice(0, 8) });
await db.insert(agents).values({ id: agentId, companyId, name: "Carl", role: "general", adapterType: "codex_local" });
await db.insert(issues).values({ id: issueId, companyId, title: "you there?", status: "in_progress", assigneeAgentId: agentId, originKind: "chat_channel" });
await db.insert(toolApplications).values({ id: applicationId, companyId, name: "Slack", type: "native" });
await db.insert(toolConnections).values({ id: connectionId, companyId, applicationId, name: "Slack", uid: connectionId, transport: "chat_sdk", connectionPurpose: "channel" });
await db.insert(chatEndpoints).values({ id: endpointId, companyId, connectionId, provider: "slack", publicId: endpointId, assignedAgentId: agentId, status: "active" });
await db.insert(chatConversations).values({ id: conversationId, companyId, endpointId, issueId, externalConversationId: "channel", externalThreadId: runId, externalLabel: "#test" });
await db.insert(issueComments).values({ id: wakeId, companyId, issueId, authorType: "user", authorUserId: "local-board", body: "you there?", createdAt: start });
await db.insert(chatMessageLinks).values({ companyId, endpointId, conversationId, commentId: wakeId, providerMessageId: wakeId, direction: "inbound" });
await db.insert(heartbeatRuns).values({ id: runId, companyId, agentId, status: "succeeded", runtimeMode: native ? "native" : "legacy", nativeIssueId: native ? issueId : null,
resultJson: native ? { finalizationReasonCode: "external_chat_response_waiting" } : null,
createdAt: new Date(start.getTime() + 1), startedAt: start, finishedAt: new Date(),
contextSnapshot: { issueId, source: "chat:slack", wakeCommentId: wakeId, wakeCommentIds: [wakeId] } });
if (native) await db.insert(nativeRunFinalizations).values({ companyId, issueId, runId, phase: "committed" });
await db.insert(issueComments).values({ id: responseId, companyId, issueId, authorType: "agent", authorAgentId: agentId, createdByRunId: runId, body: "Yes, I’m here.",
metadata: { version: 1, authorizationReason: "allow_chat_run_presentation", sections: [] } });
await db.insert(chatPublications).values({ id: publicationId, companyId, endpointId, conversationId, issueId, commentId: responseId,
idempotencyKey: `comment:${responseId}:${endpointId}`, payload: { text: "Yes, I’m here." }, state: "published", providerMessageId: responseId, publishedAt: new Date() });
return { companyId, agentId, issueId, endpointId, conversationId, runId, wakeId, responseId, publicationId, start };
}
async function state(f: Awaited<ReturnType<typeof fixture>>) {
return (await db.select({ status: issues.status, externalConversationState: externalConversationStateSql() })
.from(issues).where(eq(issues.id, f.issueId)))[0];
}
const settle = (f: Awaited<ReturnType<typeof fixture>>) => settleSlackConversation(db, f.companyId, f.issueId);
it.each([false, true])("settles a published answered turn once (native=%s)", async (native) => {
const f = await fixture(native);
expect(await settle(f)).toBe(true);
expect(await state(f)).toEqual({ status: "in_review", externalConversationState: "waiting" });
expect(await settle(f)).toBe(false);
expect(await db.select().from(activityLog).where(and(eq(activityLog.entityId, f.issueId), eq(activityLog.action, "issue.updated")))).toHaveLength(1);
expect(await db.select().from(issues).where(and(eq(issues.id, f.issueId), executionIssueCondition()))).toHaveLength(0);
expect((await issueService(db).getById(f.issueId))?.externalConversationState).toBe("waiting");
expect(await issueService(db).list(f.companyId)).toHaveLength(0);
expect((await dashboardService(db).summary(f.companyId)).tasks.open).toBe(0);
expect((await attentionService(db).list(f.companyId)).items.filter((item) => item.issueId === f.issueId)).toHaveLength(0);
const results = await companySearchService(db).search(f.companyId, companySearchQuerySchema.parse({ q: "you there" }));
expect(results.results.find((result) => result.issue?.id === f.issueId)?.issue?.externalConversationState).toBe("waiting");
expect((await issueService(db).list(f.companyId, { q: "you there" }))[0]?.externalConversationState).toBe("waiting");
expect((await issueService(db).listReviewAttention(f.companyId, [{ id: f.issueId, companyId: f.companyId, status: "in_review" }])).get(f.issueId)?.state).toBe("none");
});
it("settles and resumes Speko-owned tasks without adopting existing task lifecycles", async () => {
const f = await fixture(true);
await db.update(chatEndpoints).set({ provider: "speko" }).where(eq(chatEndpoints.id, f.endpointId));
await db.update(heartbeatRuns).set({ contextSnapshot: { issueId: f.issueId, source: "chat:speko", wakeCommentId: f.wakeId, wakeCommentIds: [f.wakeId] } }).where(eq(heartbeatRuns.id, f.runId));
expect(await settle(f)).toBe(false);
await db.update(issues).set({ originId: f.endpointId }).where(eq(issues.id, f.issueId));
expect(await settle(f)).toBe(true);
expect(await state(f)).toMatchObject({ status: "in_review", externalConversationState: "waiting" });
await db.transaction(tx => resumeExternalConversation(tx as unknown as typeof db, f.companyId, f.issueId));
expect(await state(f)).toMatchObject({ status: "todo", externalConversationState: "active" });
});
it("preserves waiting state in compact list responses used by the Inbox", async () => {
const f = await fixture();
await settle(f);
const app = express();
app.use((req, _res, next) => {
(req as any).actor = { type: "board", userId: "local-board", source: "local_implicit", isInstanceAdmin: true };
next();
});
app.use("/api", issueRoutes(db, {} as any));
const response = await request(app).get(`/api/companies/${f.companyId}/issues`)
.query({ view: "compact", q: "you there" });
expect(response.status, JSON.stringify(response.body)).toBe(200);
expect(response.body).toEqual([expect.objectContaining({ id: f.issueId, status: "in_review", externalConversationState: "waiting" })]);
});
it("reconciles an already-answered conversation without another model run", async () => {
const f = await fixture();
const enqueueWakeup = vi.fn(async () => null);
const recovery = recoveryService(db, { enqueueWakeup });
// Restrict to this newly created fixture, like the rollout reconciler's candidate scan.
const [issue] = await db.select().from(issues).where(eq(issues.id, f.issueId));
await recovery.reconcileStrandedAssignedIssues({ issueCreatedAtGte: issue.createdAt });
expect((await state(f))?.externalConversationState).toBe("waiting");
await recovery.reconcileStrandedAssignedIssues({ issueCreatedAtGte: issue.createdAt });
expect(enqueueWakeup).not.toHaveBeenCalled();
});
it("waits for both finalization and delivery, regardless of their order", async () => {
for (const first of ["run", "publication"]) {
const f = await fixture();
if (first === "run") await db.update(chatPublications).set({ state: "pending" }).where(eq(chatPublications.id, f.publicationId));
else await db.update(heartbeatRuns).set({ status: "running" }).where(eq(heartbeatRuns.id, f.runId));
expect(await settle(f)).toBe(false);
await db.update(chatPublications).set({ state: "published" }).where(eq(chatPublications.id, f.publicationId));
await db.update(heartbeatRuns).set({ status: "succeeded" }).where(eq(heartbeatRuns.id, f.runId));
expect(await settle(f)).toBe(true);
}
});
it("a new board message reactivates the same conversation and prevents stale settlement", async () => {
const f = await fixture();
await settle(f);
const followup = await issueService(db).addComment(f.issueId, "what's your name?", { userId: "local-board" });
expect((await state(f))?.externalConversationState).toBe("active");
expect(await settle(f)).toBe(false);
expect((await state(f))?.status).toBe("todo");
await issueService(db).checkout(f.issueId, f.agentId, ["todo", "backlog", "blocked"], null);
expect((await state(f))?.status).toBe("in_progress");
const nextRun = randomUUID();
await db.insert(chatMessageLinks).values({ companyId: f.companyId, endpointId: f.endpointId, conversationId: f.conversationId, commentId: followup.id, providerMessageId: followup.id, direction: "inbound" });
await db.insert(heartbeatRuns).values({ id: nextRun, companyId: f.companyId, agentId: f.agentId, runtimeMode: "legacy", status: "succeeded", finishedAt: new Date(),
contextSnapshot: { issueId: f.issueId, source: "chat:slack", wakeCommentId: followup.id } });
const [response] = await db.insert(issueComments).values({ companyId: f.companyId, issueId: f.issueId, authorAgentId: f.agentId, createdByRunId: nextRun, body: "Carl", metadata: { version: 1, authorizationReason: "allow_chat_run_presentation", sections: [] } }).returning();
await db.insert(chatPublications).values({ companyId: f.companyId, endpointId: f.endpointId, conversationId: f.conversationId, issueId: f.issueId, commentId: response.id,
idempotencyKey: nextRun, payload: { text: "Carl" }, state: "published", providerMessageId: nextRun });
expect(await settle(f)).toBe(true);
expect((await state(f))?.externalConversationState).toBe("waiting");
});
it("serializes simultaneous finalizers and a new message without losing the message", async () => {
const f = await fixture();
await Promise.all([settle(f), settle(f), issueService(db).addComment(f.issueId, "another request", { userId: "local-board" })]);
expect((await state(f))?.externalConversationState).toBe("active");
expect(await settle(f)).toBe(false);
});
it("lets an operator explicitly request review instead of keeping Idle", async () => {
const f = await fixture();
await settle(f);
await issueService(db).update(f.issueId, { status: "in_review", actorUserId: "local-board" });
expect(await state(f)).toEqual({ status: "in_review", externalConversationState: "active" });
expect(await settle(f)).toBe(false);
});
it("duplicate board delivery does not reactivate an answered turn", async () => {
const f = await fixture();
const clientRequestId = randomUUID();
await db.update(issueComments).set({ clientRequestId }).where(eq(issueComments.id, f.wakeId));
await settle(f);
const duplicate = await issueService(db).addComment(f.issueId, "you there?", { userId: "local-board" }, { clientRequestId });
expect(duplicate.id).toBe(f.wakeId);
expect((await state(f))?.externalConversationState).toBe("waiting");
});
it("keeps delivery errors visible after a conversation has settled", async () => {
const f = await fixture();
await settle(f);
await db.update(chatPublications).set({ state: "failed" }).where(eq(chatPublications.id, f.publicationId));
expect((await state(f))?.externalConversationState).toBe("active");
expect(await db.select().from(issues).where(and(eq(issues.id, f.issueId), executionIssueCondition()))).toHaveLength(1);
});
it("does not settle past an unlinked Slack guest's newer message", async () => {
const f = await fixture();
const [message] = await db.insert(issueComments).values({ companyId: f.companyId, issueId: f.issueId, authorType: "system", body: "one more thing" }).returning();
await db.insert(chatMessageLinks).values({ companyId: f.companyId, endpointId: f.endpointId, conversationId: f.conversationId, commentId: message.id, providerMessageId: message.id, direction: "inbound" });
expect(await settle(f)).toBe(false);
});
it("requires committed native response-wait evidence", async () => {
const f = await fixture(true);
await db.update(nativeRunFinalizations).set({ phase: "arbitrating" }).where(eq(nativeRunFinalizations.runId, f.runId));
expect(await settle(f)).toBe(false);
await db.update(nativeRunFinalizations).set({ phase: "committed" }).where(eq(nativeRunFinalizations.runId, f.runId));
await db.update(heartbeatRuns).set({ resultJson: { finalizationReasonCode: "governed_response_waiting" } }).where(eq(heartbeatRuns.id, f.runId));
expect(await settle(f)).toBe(false);
});
it("settles an authorized published reply after an interaction continuation", async () => {
const f = await fixture(true);
const [answer] = await db.insert(issueComments).values({ companyId: f.companyId, issueId: f.issueId,
authorType: "user", authorUserId: "local-board", body: "Here is the requested answer", createdAt: new Date(f.start.getTime() + 2) }).returning();
await db.update(heartbeatRuns).set({ contextSnapshot: {
issueId: f.issueId, source: "issue.interaction.respond", externalChatContinuation: true, wakeCommentId: answer.id,
} }).where(eq(heartbeatRuns.id, f.runId));
expect(await settle(f)).toBe(true);
});
it("waits for execution release before moving the issue to review", async () => {
const f = await fixture();
await db.update(issues).set({ executionRunId: f.runId }).where(eq(issues.id, f.issueId));
expect(await settle(f)).toBe(false);
expect((await state(f))?.status).toBe("in_progress");
await db.update(issues).set({ executionRunId: null }).where(eq(issues.id, f.issueId));
expect(await settle(f)).toBe(true);
});
it.each([
{ status: "pending" },
{ status: "changes_requested" },
{ status: "idle", monitor: { status: "scheduled" } },
{ status: "idle", monitor: { status: "triggered" } },
])("preserves governed or monitored unfinished work: %j", async (executionState) => {
const f = await fixture();
await db.update(issues).set({ executionState }).where(eq(issues.id, f.issueId));
expect(await settle(f)).toBe(false);
});
it("preserves pending interactions and removes Idle if a real review is requested later", async () => {
const f = await fixture();
await settle(f);
await db.insert(issueThreadInteractions).values({ companyId: f.companyId, issueId: f.issueId, kind: "request_confirmation", payload: { version: 1, prompt: "Approve?" } });
expect((await state(f))?.externalConversationState).toBe("active");
await db.update(issues).set({ status: "in_progress" }).where(eq(issues.id, f.issueId));
await db.update(chatConversations).set({ state: "active" }).where(eq(chatConversations.id, f.conversationId));
expect(await settle(f)).toBe(false);
});
it.each(["failed", "cancelled", "timed_out", "queued", "running", "scheduled_retry"])("does not park a %s run", async (status) => {
const f = await fixture();
await db.update(heartbeatRuns).set({ status }).where(eq(heartbeatRuns.id, f.runId));
expect(await settle(f)).toBe(false);
});
it.each(["pending", "failed", "delivery_unknown", "retry", "streaming", "awaiting_consent"] as const)("does not park %s delivery", async (state) => {
const f = await fixture();
await db.update(chatPublications).set({ state }).where(eq(chatPublications.id, f.publicationId));
expect(await settle(f)).toBe(false);
});
it.each(["done", "blocked", "cancelled", "in_review"])("preserves explicit %s disposition", async (status) => {
const f = await fixture();
await db.update(issues).set({ status }).where(eq(issues.id, f.issueId));
expect(await settle(f)).toBe(false);
});
it.each(["completed", "unavailable", "endpoint_removed"])("does not reactivate a %s conversation", async (state) => {
const f = await fixture();
await db.update(chatConversations).set({ state }).where(eq(chatConversations.id, f.conversationId));
expect(await settle(f)).toBe(false);
});
it("respects company, provider, and connection boundaries", async () => {
const f = await fixture();
expect(await settleSlackConversation(db, randomUUID(), f.issueId)).toBe(false);
await db.update(chatEndpoints).set({ status: "revoked" }).where(eq(chatEndpoints.id, f.endpointId));
expect(await settle(f)).toBe(false);
await db.update(chatEndpoints).set({ status: "active", provider: "telegram" }).where(eq(chatEndpoints.id, f.endpointId));
expect(await settle(f)).toBe(false);
});
it("preserves scheduled work, newer runs, queued wakes, monitors, blockers, and approvals", async () => {
const monitor = await fixture();
await db.update(issues).set({ monitorNextCheckAt: new Date(Date.now() + 60000) }).where(eq(issues.id, monitor.issueId));
expect(await settle(monitor)).toBe(false);
const queued = await fixture();
await db.insert(agentWakeupRequests).values({ companyId: queued.companyId, agentId: queued.agentId, source: "automation", status: "deferred_issue_execution", payload: { issueId: queued.issueId } });
expect(await settle(queued)).toBe(false);
const newer = await fixture();
await db.insert(heartbeatRuns).values({ companyId: newer.companyId, agentId: newer.agentId, status: "scheduled_retry", contextSnapshot: { issueId: newer.issueId } });
expect(await settle(newer)).toBe(false);
const blocked = await fixture();
const [blocker] = await db.insert(issues).values({ companyId: blocked.companyId, title: "dependency", status: "todo" }).returning();
await db.insert(issueRelations).values({ companyId: blocked.companyId, issueId: blocker.id, relatedIssueId: blocked.issueId, type: "blocks" });
expect(await settle(blocked)).toBe(false);
const review = await fixture();
const [approval] = await db.insert(approvals).values({ companyId: review.companyId, type: "hire_agent", status: "pending", payload: {} }).returning();
await db.insert(issueApprovals).values({ companyId: review.companyId, issueId: review.issueId, approvalId: approval.id });
expect(await settle(review)).toBe(false);
});
});