mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-31 06:04:11 +01:00
Merge origin/master and integrate environment selection
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
commit
fed4802b72
159 files changed
+13953
-2027
No files matched your search
@@ -93,11 +93,12 @@ describe("createChatReconciliationCoordinator", () => {
|
||||
it("wires bounded publication refill to commit workers and isolates periodic maintenance", () => {
|
||||
const source = readFileSync(new URL("../app.ts", import.meta.url), "utf8");
|
||||
expect(source).toContain("registerChatDeliveryWork(deliveryWork, chatChannels,");
|
||||
expect(source).toContain("registerChatActionWork(deliveryWork, chatChannels,");
|
||||
const maintenance = source.slice(
|
||||
source.indexOf("const reconcileChatPublicationMaintenance ="),
|
||||
source.indexOf("const chatReconciliation ="),
|
||||
);
|
||||
expect(maintenance).toContain("await chatChannels.processPublicationMaintenance()");
|
||||
expect(maintenance).toContain("chatChannels.processPublicationMaintenance()");
|
||||
expect(maintenance).not.toContain("chatChannels.schedulePendingPublications()");
|
||||
expect(maintenance).not.toContain("chatChannels.processPendingPublications()");
|
||||
const workers = readFileSync(new URL("../services/chat-delivery-work.ts", import.meta.url), "utf8");
|
||||
@@ -105,6 +106,12 @@ describe("createChatReconciliationCoordinator", () => {
|
||||
// The service integration tests hold real publication workers while this
|
||||
// scheduled method returns; app shutdown must also join those workers.
|
||||
expect(source).toContain("await chatChannels.shutdown()");
|
||||
expect(source).not.toContain("chatChannels.processPendingSlackSessionSyncs()");
|
||||
const service = readFileSync(new URL("../services/chat-channels.ts", import.meta.url), "utf8");
|
||||
const actionMaintenance = service.slice(service.indexOf("async function processPendingChatMaintenance("),
|
||||
service.indexOf("async function processPendingDeliveries("));
|
||||
expect(actionMaintenance).not.toContain("processPendingProviderEffects(");
|
||||
expect(actionMaintenance).not.toContain("processPendingGitHubWebhookIngress(");
|
||||
});
|
||||
|
||||
it.each([false, true])(
|
||||
|
||||
@@ -19,6 +19,11 @@ vi.mock("../services/index.js", () => ({
|
||||
|
||||
vi.mock("node:child_process", () => ({ spawn: mockSpawn }));
|
||||
|
||||
vi.mock("../services/fast-responses.js", () => ({
|
||||
enqueueFastResponse: vi.fn(async () => undefined),
|
||||
supersedeFastResponse: vi.fn(async () => undefined),
|
||||
}));
|
||||
|
||||
vi.mock("../routes/authz.js", () => ({
|
||||
getActorInfo: () => ({ actorId: "user-1", agentId: null, runId: null }),
|
||||
assertCompanyAccess: () => {},
|
||||
@@ -28,7 +33,7 @@ async function createApp(deploymentMode: "local_trusted" | "authenticated" = "lo
|
||||
const { boardChatRoutes } = await import("../routes/board-chat.js");
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use("/api", boardChatRoutes({} as any, { deploymentMode }));
|
||||
app.use("/api", boardChatRoutes({ transaction: async (effect: (tx: unknown) => unknown) => effect({}) } as any, { deploymentMode }));
|
||||
return app;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { installDatabaseWorkSignals } from "../../../packages/db/src/work-signals.js";
|
||||
import { createDeliveryWorkCoordinator } from "../services/delivery-work-coordinator.js";
|
||||
import { registerChatActionWork } from "../services/chat-action-work.js";
|
||||
import { CHAT_ACTION_QUEUES, notifyChatActionWork, notifyChatEndpointWork, notifyChatVerificationWork } from "../services/chat-work-notifications.js";
|
||||
|
||||
const coordinators: ReturnType<typeof createDeliveryWorkCoordinator>[] = [];
|
||||
afterEach(async () => {
|
||||
for (const coordinator of coordinators.splice(0)) await coordinator.stop();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
function setup() {
|
||||
vi.useFakeTimers();
|
||||
const execute = vi.fn(async () => [{ xid: "42", status: "committed" }]);
|
||||
const owner = installDatabaseWorkSignals({ execute, async transaction(callback: (tx: any) => Promise<any>) {
|
||||
return callback({ execute });
|
||||
} });
|
||||
const enabled = vi.fn(() => true), onError = vi.fn();
|
||||
const coordinator = createDeliveryWorkCoordinator({ owner, canRun: enabled, canReconcile: enabled, onError });
|
||||
coordinators.push(coordinator);
|
||||
const service = {
|
||||
processPendingProviderEffects: vi.fn(async () => {}),
|
||||
processPendingGitHubWebhookIngress: vi.fn(async () => {}),
|
||||
processPendingSlackSessionSyncs: vi.fn(async () => {}),
|
||||
processPendingSlackBoardMessages: vi.fn(async () => {}),
|
||||
processPendingSlackTaskStarts: vi.fn(async () => {}),
|
||||
processPendingReceiptReactions: vi.fn(async () => {}),
|
||||
processPendingSlackSessionStops: vi.fn(async () => {}),
|
||||
processPendingVerificationMessages: vi.fn(async () => {}),
|
||||
nextChatActionAt: vi.fn(async (): Promise<number | null> => null),
|
||||
nextVerificationMessageAt: vi.fn(async (): Promise<number | null> => null),
|
||||
};
|
||||
const runs = [service.processPendingProviderEffects, service.processPendingGitHubWebhookIngress, service.processPendingSlackSessionSyncs, service.processPendingSlackBoardMessages, service.processPendingSlackTaskStarts,
|
||||
service.processPendingReceiptReactions, service.processPendingSlackSessionStops, service.processPendingVerificationMessages];
|
||||
return { owner, service, runs, coordinator, enabled, onError, start: () => registerChatActionWork(coordinator, service, enabled) };
|
||||
}
|
||||
describe("chat action scheduling", () => {
|
||||
it("does one startup scan per lane and no more SQL or timers during an idle hour", async () => {
|
||||
const s = setup();
|
||||
await s.start().ready;
|
||||
await vi.advanceTimersByTimeAsync(3_600_000);
|
||||
for (const run of s.runs) expect(run).toHaveBeenCalledTimes(1);
|
||||
expect(s.service.nextChatActionAt).toHaveBeenCalledTimes(7);
|
||||
expect(s.service.nextVerificationMessageAt).toHaveBeenCalledTimes(1);
|
||||
expect(s.coordinator.nextWakeAt()).toBeNull();
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
});
|
||||
it("wakes each lane after its write settles and wakes parked work when an endpoint changes", async () => {
|
||||
const s = setup();
|
||||
await s.start().ready;
|
||||
for (const kind of Object.keys(CHAT_ACTION_QUEUES) as (keyof typeof CHAT_ACTION_QUEUES)[]) {
|
||||
await s.owner.transaction(async tx => {
|
||||
await notifyChatActionWork(tx, kind);
|
||||
expect(s.runs.every(run => run.mock.calls.length === 1)).toBe(true);
|
||||
});
|
||||
}
|
||||
await s.owner.transaction(tx => notifyChatVerificationWork(tx));
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
for (const run of s.runs) expect(run).toHaveBeenCalledTimes(2);
|
||||
await s.owner.transaction(tx => notifyChatEndpointWork(tx));
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
for (const run of s.runs) expect(run).toHaveBeenCalledTimes(3);
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
});
|
||||
it("restores durable deadlines and reschedules when a foreground send moves a retry earlier", async () => {
|
||||
const s = setup();
|
||||
let due: number | null = Date.now() + 120_000;
|
||||
s.service.nextChatActionAt.mockImplementation(async () => due);
|
||||
await s.start().ready;
|
||||
expect(s.coordinator.nextWakeAt()).toBe(due);
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
due = Date.now() + 30_000;
|
||||
await s.owner.transaction(tx => notifyChatActionWork(tx, "receipt_reaction"));
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
expect(s.coordinator.nextWakeAt()).toBe(due);
|
||||
expect(s.service.processPendingReceiptReactions).toHaveBeenCalledTimes(2);
|
||||
due = null;
|
||||
await vi.advanceTimersByTimeAsync(30_000);
|
||||
expect(s.service.processPendingReceiptReactions).toHaveBeenCalledTimes(3);
|
||||
await vi.advanceTimersByTimeAsync(120_000);
|
||||
expect(s.coordinator.nextWakeAt()).toBeNull();
|
||||
});
|
||||
it("keeps lanes independent and retains a commit received during a drain", async () => {
|
||||
const s = setup();
|
||||
let release!: () => void;
|
||||
s.service.processPendingSlackBoardMessages.mockImplementationOnce(() => new Promise(resolve => { release = resolve; }));
|
||||
const work = s.start();
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
await s.owner.transaction(tx => notifyChatActionWork(tx, "slack_board_message"));
|
||||
await s.owner.transaction(tx => notifyChatActionWork(tx, "slack_session_stop"));
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
expect(s.service.processPendingSlackSessionStops).toHaveBeenCalledTimes(2);
|
||||
release(); await work.ready;
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
expect(s.service.processPendingSlackBoardMessages).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
it("retries failed recovery and does no dispatch or deadline queries during standby", async () => {
|
||||
const s = setup();
|
||||
s.enabled.mockReturnValue(false);
|
||||
await s.start().ready;
|
||||
await vi.advanceTimersByTimeAsync(2000);
|
||||
expect(s.service.nextChatActionAt).not.toHaveBeenCalled();
|
||||
for (const run of s.runs) expect(run).not.toHaveBeenCalled();
|
||||
s.enabled.mockReturnValue(true);
|
||||
s.service.processPendingSlackTaskStarts.mockRejectedValueOnce(new Error("DB unavailable"));
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
expect(s.onError).toHaveBeenCalledTimes(1);
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
expect(s.service.processPendingSlackTaskStarts).toHaveBeenCalledTimes(2);
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,12 @@
|
||||
import { registerChatActionWork, dueChatAction } from "../services/chat-action-work.js";
|
||||
import { createDeliveryWorkCoordinator } from "../services/delivery-work-coordinator.js";
|
||||
import { registerChatDeliveryWork } from "../services/chat-delivery-work.js";
|
||||
import { notifyChatPublicationWork } from "../services/chat-work-notifications.js";
|
||||
import { notifyChatPublicationWork, notifyChatActionWork, CHAT_ACTION_QUEUES } from "../services/chat-work-notifications.js";
|
||||
import { DELIVERY_QUEUES, subscribeDeliveryWork } from "../services/delivery-work-notifications.js";
|
||||
import { aiConnectionService } from "../services/ai-connections.js";
|
||||
import { fastResponseService } from "../services/fast-responses.js";
|
||||
import { fastResponseReceipt } from "../services/fast-response-provider.js";
|
||||
import { fastResponseRequests } from "@paperclipai/db";
|
||||
import { chatSlackRegistrations, toolOauthStates } from "@paperclipai/db";
|
||||
import { buildSlackAppManifest } from "@paperclipai/shared";
|
||||
import { toolAccessService } from "../services/tool-access.js";
|
||||
@@ -2471,6 +2476,40 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => {
|
||||
expect(f.responseWrites).toEqual([]);
|
||||
expect(f.responseComments.size).toBe(0);
|
||||
});
|
||||
it.each(["published", "failed", "delivery_unknown"] as const)("coordinates GitHub working feedback with a %s fast response", async outcome => {
|
||||
await instanceSettingsService(db).updateExperimental({ enableFastResponses: true });
|
||||
const f = await reviewBotFixture();
|
||||
const binding = await aiConnectionService(db).save(f.companyId, "owner-user", { provider: "openrouter", method: "api_key", name: "Fast GitHub", ownership: "shared", apiKey: "fixture-key", agentIds: [], allAgents: true }, "fixture-key");
|
||||
const fast = fastResponseService(db, { authorizeExternal: f.service.authorizeFastResponse,
|
||||
provider: async () => ({ ...(outcome === "failed" ? { errorCode: "invalid_output", noProviderWork: true } : { text: "I’ll check the border styling." }), receipt: fastResponseReceipt({ usage: { inputTokens: 80, outputTokens: 8 } }) }) });
|
||||
await fast.configure(f.companyId, "owner-user", { enabled: true, ...binding, model: "openai/gpt-oss-120b", allowSponsored: true });
|
||||
const now = vi.spyOn(Date, "now").mockReturnValue(Date.now()); onTestFinished(() => now.mockRestore());
|
||||
const thread = makeThread({ channelId: "paperclipai/paperclip", id: "github:paperclipai/paperclip:issue:418" }).thread;
|
||||
await deliverMessage({ callbacks: f.callbacks, endpointId: f.endpoint.id, provider: "github", thread,
|
||||
trigger: "mention", message: makeMessage({ id: "41801", text: "Fix the border styling", userId: "42", userName: "octocat", mentioned: true }) });
|
||||
const [job] = await db.select().from(fastResponseRequests).where(eq(fastResponseRequests.companyId, f.companyId));
|
||||
expect(job).toBeDefined();
|
||||
expect(f.responseWrites).toEqual([]);
|
||||
await fast.process(job);
|
||||
if (outcome === "delivery_unknown") {
|
||||
await db.update(chatPublications).set({ state: "delivery_unknown" }).where(eq(chatPublications.idempotencyKey, `fast-response:${job.id}`));
|
||||
} else await f.service.processPendingPublications();
|
||||
await githubResponseCommentService(db, f.providerFetch).processPending();
|
||||
if (outcome === "failed") expect(f.responseWrites).toEqual([expect.objectContaining({ body: expect.stringContaining("Working on this") })]);
|
||||
else expect(f.responseWrites).toEqual([]);
|
||||
if (outcome === "published") {
|
||||
expect(f.runtime.endpoints.get(f.endpoint.id)!.posts).toContainEqual({ threadId: thread.id, text: "I’ll check the border styling." });
|
||||
const [run] = await db.insert(heartbeatRuns).values({ companyId: f.companyId, agentId: f.assignedAgentId,
|
||||
status: "running", runtimeMode: "native", contextSnapshot: await chatWakeContext({
|
||||
endpointId: f.endpoint.id, issueId: job.issueId, provider: "github", providerMessageId: "41801",
|
||||
}) }).returning();
|
||||
await githubChatReviewService(db, f.providerFetch).execute({ companyId: f.companyId, agentId: f.assignedAgentId,
|
||||
issueId: job.issueId!, runId: run.id }, "comment", { body: "The border styling is fixed.", idempotencyKey: "fast-response-final" });
|
||||
expect(f.responseWrites).toEqual([expect.objectContaining({ body: expect.stringContaining("The border styling is fixed.") })]);
|
||||
expect((await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, run.id)))[0].status).toBe("running");
|
||||
}
|
||||
expect((await db.select().from(issues).where(eq(issues.id, job.issueId!)))[0].status).not.toBe("done");
|
||||
});
|
||||
it("acknowledges a GitHub request after a durable scheduler retry succeeds", async () => {
|
||||
const f = await reviewBotFixture();
|
||||
f.wakeup.mockRejectedValueOnce(new Error("Temporary scheduler outage"));
|
||||
@@ -4456,7 +4495,8 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => {
|
||||
const delivery = randomUUID();
|
||||
const send = () => f.service.handleWebhook(f.endpoint.publicId, "github", signedGitHubWebhookRequest({ event, delivery, payload, webhookSecret: f.webhookSecret }));
|
||||
expect((await send()).status).toBeLessThan(300);
|
||||
await expect.poll(async () => (await db.select().from(chatConversations).where(eq(chatConversations.endpointId, f.endpoint.id))).length).toBe(1);
|
||||
// Webhook acknowledgement precedes the asynchronous admission transaction.
|
||||
await expect.poll(async () => (await db.select().from(chatConversations).where(eq(chatConversations.endpointId, f.endpoint.id))).length, { timeout: 10_000 }).toBe(1);
|
||||
await send();
|
||||
const [conversation] = await db.select().from(chatConversations).where(eq(chatConversations.endpointId, f.endpoint.id));
|
||||
expect(conversation.externalThreadId).toBe(`github:PaperclipAI/Paperclip:${event === "issues" || event === "issue_comment" ? "issue:" : ""}83${event === "pull_request_review_comment" ? ":rc:83000" : ""}`);
|
||||
@@ -6517,6 +6557,27 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => {
|
||||
await expect(restarted.slackRegistration.install(f.endpoint.id, actor)).resolves.toHaveProperty("authorizationUrl");
|
||||
expect((await restarted.get(f.endpoint.id)).setup.webhookVerifiedAt).toBeFalsy();
|
||||
});
|
||||
it("wakes verification from a signed callback with no maintenance polling", async () => {
|
||||
const f = await fixture({ scheduleDeferredWork: () => undefined });
|
||||
await f.create(); await f.service.slackRegistration.complete(await f.install(), "code", null, actor);
|
||||
const onError = vi.fn();
|
||||
const coordinator = createDeliveryWorkCoordinator({ owner: db, canRun: () => true, onError });
|
||||
try {
|
||||
await registerChatActionWork(coordinator, f.service, () => true).ready;
|
||||
await vi.waitFor(() => expect(coordinator.nextWakeAt()).toBeNull(), { timeout: 10_000 });
|
||||
expect(await f.service.nextVerificationMessageAt()).toBeNull();
|
||||
await f.service.handleWebhook(f.endpoint.publicId, "slack", signedSlackWebhookRequest({
|
||||
url: f.endpoint.setup.webhookUrl!, contentType: "application/json", signingSecret,
|
||||
body: JSON.stringify({ type: "url_verification", challenge: "scheduler" }),
|
||||
}));
|
||||
await vi.waitFor(async () => {
|
||||
expect((await f.service.get(f.endpoint.id)).setup.slackAccount?.verificationStatus).toBe("sent");
|
||||
expect(coordinator.nextWakeAt()).toBeNull();
|
||||
}, { timeout: 10_000 });
|
||||
expect(f.provider.mock.calls.filter(([url]) => String(url).endsWith("chat.postMessage"))).toHaveLength(2);
|
||||
expect(onError).not.toHaveBeenCalled();
|
||||
} finally { await coordinator.stop(); }
|
||||
});
|
||||
it("acknowledges verification before sending one DM across concurrent callbacks and restart recovery", async () => {
|
||||
const f = await fixture({ scheduleDeferredWork: () => undefined });
|
||||
await f.create(); await f.service.slackRegistration.complete(await f.install(), "code", null, actor);
|
||||
@@ -6904,10 +6965,48 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => {
|
||||
await request(app).patch(`/api/chat-endpoints/${github.id}`).send({ communicationInstructions: "Not enabled" }).expect(422);
|
||||
});
|
||||
|
||||
it.each(["slack", "github"] as const)("publishes fast responses to the accepted %s thread without completing its work", async channelProvider => {
|
||||
await instanceSettingsService(db).updateExperimental({ enableChatConnectors: true, enableFastResponses: true });
|
||||
const fixture = await seedCompany();
|
||||
const { callbacks, endpoint, runtime, service, wakeup } = channelProvider === "github" ? await configuredGitHubEndpoint(fixture) : await configuredSlackEndpoint(fixture);
|
||||
await db.update(chatEndpoints).set({ status: "active" }).where(eq(chatEndpoints.id, endpoint.id));
|
||||
if (channelProvider === "github") {
|
||||
const current = await service.get(endpoint.id);
|
||||
const [principal] = await db.insert(chatExternalPrincipals).values({ companyId: fixture.companyId, provider: "github", providerAccountId: current.providerAccountId!, externalId: "42", kind: "user", displayName: "Octocat", handle: "octocat", isBot: false }).returning();
|
||||
await db.insert(chatIdentityLinks).values({ companyId: fixture.companyId, endpointId: endpoint.id, principalId: principal.id, paperclipUserId: "owner-user", status: "linked", confirmedAt: new Date() });
|
||||
}
|
||||
const binding = await aiConnectionService(db).save(fixture.companyId, "owner-user", { provider: "openrouter", method: "api_key", name: "Fast test", ownership: "shared", apiKey: "fixture-key", agentIds: [], allAgents: true }, "fixture-key");
|
||||
const provider = vi.fn(async () => ({ text: "I’ll check the border styling.", receipt: fastResponseReceipt({ usage: { inputTokens: 80, outputTokens: 8 }, response: { body: { usage: { cost: 0.00001 } } } }) }));
|
||||
const fast = fastResponseService(db, { provider, authorizeExternal: service.authorizeFastResponse });
|
||||
await fast.configure(fixture.companyId, "owner-user", { enabled: true, ...binding, model: "openai/gpt-oss-120b", allowSponsored: true });
|
||||
const now = vi.spyOn(Date, "now").mockReturnValue(Date.now()); onTestFinished(() => now.mockRestore());
|
||||
const current = await service.get(endpoint.id);
|
||||
const channel = makeThread(channelProvider === "github" ? { channelId: "paperclipai/paperclip", id: "github:paperclipai/paperclip:903" } : { channelId: "CFAST", id: "slack:CFAST:9000.1", name: "fast response test" });
|
||||
await deliverMessage({ callbacks, provider: channelProvider, endpointId: endpoint.id, thread: channel.thread,
|
||||
message: makeMessage({ id: "9000.1", text: `@${current.botUsername ?? "maya"} fix the border styling`, mentioned: true, ...(channelProvider === "github" ? { userId: "42", userName: "octocat" } : {}) }), trigger: "mention" });
|
||||
const [job] = await db.select().from(fastResponseRequests).where(eq(fastResponseRequests.companyId, fixture.companyId));
|
||||
expect(job).toBeDefined(); expect(wakeup).toHaveBeenCalled();
|
||||
const [queuedRun] = await db.insert(heartbeatRuns).values({ companyId: fixture.companyId, agentId: fixture.assignedAgentId,
|
||||
status: "queued", contextSnapshot: { issueId: job.issueId, wakeCommentId: job.sourceCommentId } }).returning();
|
||||
await db.update(agentWakeupRequests).set({ runId: queuedRun.id }).where(and(eq(agentWakeupRequests.companyId, fixture.companyId), eq(agentWakeupRequests.agentId, fixture.assignedAgentId)));
|
||||
await fast.process(job);
|
||||
expect(provider).toHaveBeenCalledWith(expect.objectContaining({ prompt: expect.stringContaining('"state":"queued"') }));
|
||||
await service.processPendingPublications();
|
||||
const posts = runtime.endpoints.get(endpoint.id)!.posts;
|
||||
expect(posts).toContainEqual({ threadId: channel.thread.id, text: "I’ll check the border styling." });
|
||||
const [receipt] = await db.select().from(issueComments).where(and(eq(issueComments.issueId, job.issueId!), eq(issueComments.origin, "fast_response")));
|
||||
expect(receipt).toMatchObject({ authorAgentId: fixture.assignedAgentId, createdByRunId: null });
|
||||
expect((await db.select().from(issues).where(eq(issues.id, job.issueId!)))[0].status).not.toBe("done");
|
||||
expect(provider).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("mirrors Paperclip messages with user attribution and returns only the selected Slack reply", async () => {
|
||||
await instanceSettingsService(db).updateExperimental({ enableChatConnectors: true });
|
||||
const fixture = await seedCompany();
|
||||
const { callbacks, endpoint, runtime, service, wakeup } = await configuredSlackEndpoint(fixture);
|
||||
const workWake = vi.fn();
|
||||
const stopWorkObserver = subscribeDeliveryWork(db, DELIVERY_QUEUES.chatBoardMessages, workWake);
|
||||
onTestFinished(() => { stopWorkObserver(); expect(workWake).toHaveBeenCalled(); });
|
||||
const channel = makeThread({ channelId: "CBOARD", id: "slack:CBOARD:8000.1", name: "board" });
|
||||
await deliverMessage({ callbacks, endpointId: endpoint.id, thread: channel.thread,
|
||||
message: makeMessage({ id: "8000.1", text: "@maya start here", mentioned: true }), trigger: "mention" });
|
||||
@@ -6942,6 +7041,7 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => {
|
||||
"**Owner User (via Paperclip)**\n\nPlease make the plan", "The plan is ready.",
|
||||
]);
|
||||
// The explicit Board composer uses the same mirror and a durable wakeup.
|
||||
workWake.mockClear();
|
||||
const key = randomUUID();
|
||||
await service.publishBoardMessage(endpoint.id, conversation.id, "Now assign the follow-ups", key, "owner-user");
|
||||
await service.publishBoardMessage(endpoint.id, conversation.id, "Now assign the follow-ups", key, "owner-user");
|
||||
@@ -6954,9 +7054,14 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => {
|
||||
wakeup.mockRejectedValueOnce(new Error("temporary scheduler failure"));
|
||||
await service.publishBoardMessage(endpoint.id, conversation.id, "Retry this work", randomUUID(), "owner-user");
|
||||
expect(await db.select().from(chatActions).where(and(eq(chatActions.endpointId, endpoint.id), eq(chatActions.kind, "slack_board_message"), eq(chatActions.status, "received")))).toHaveLength(1);
|
||||
await service.processPendingDeliveries();
|
||||
const recovery = createDeliveryWorkCoordinator({ owner: db, canRun: () => true, onError: error => { throw error; } });
|
||||
try {
|
||||
await registerChatActionWork(recovery, service, () => true).ready;
|
||||
await vi.waitFor(async () => {
|
||||
expect(await db.select().from(chatActions).where(and(eq(chatActions.endpointId, endpoint.id), eq(chatActions.kind, "slack_board_message"), eq(chatActions.status, "received")))).toHaveLength(0);
|
||||
});
|
||||
} finally { await recovery.stop(); }
|
||||
await service.processPendingPublications();
|
||||
expect(await db.select().from(chatActions).where(and(eq(chatActions.endpointId, endpoint.id), eq(chatActions.kind, "slack_board_message"), eq(chatActions.status, "received")))).toHaveLength(0);
|
||||
expect(runtime.endpoints.get(endpoint.id)!.posts.filter(post => post.text.includes("Retry this work"))).toHaveLength(1);
|
||||
});
|
||||
|
||||
@@ -13279,6 +13384,49 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => {
|
||||
await service.shutdown();
|
||||
});
|
||||
|
||||
it("drains accepted GitHub webhooks from commit wakes and durable retry deadlines", async () => {
|
||||
const fixture = await seedCompany();
|
||||
const { endpoint, runtime, service, webhookSecret } = await configuredGitHubEndpoint(fixture, {
|
||||
deferWebhookProcessing: true, scheduleDeferredWork: () => undefined,
|
||||
});
|
||||
const providerRuntime = runtime.endpoints.get(endpoint.id)!;
|
||||
let attempts = 0;
|
||||
providerRuntime.webhookHook = async () => {
|
||||
providerRuntime.webhookResponse = new Response("result", { status: ++attempts === 1 ? 503 : 202 });
|
||||
};
|
||||
const delivery = randomUUID();
|
||||
const send = () => service.handleWebhook(endpoint.publicId, "github", signedGitHubWebhookRequest({
|
||||
delivery, event: "installation_repositories", payload: { action: "added", installation: { id: 2468 } }, webhookSecret,
|
||||
}));
|
||||
const onError = vi.fn();
|
||||
let coordinator = createDeliveryWorkCoordinator({ owner: db, canRun: () => true, onError });
|
||||
const receipt = () => db.select().from(chatActions).where(and(eq(chatActions.endpointId, endpoint.id),
|
||||
eq(chatActions.providerActionId, `github_webhook_ingress:${delivery}`))).then(rows => rows[0]);
|
||||
try {
|
||||
await registerChatActionWork(coordinator, service, () => true).ready;
|
||||
expect((await send()).status).toBe(202);
|
||||
await expect.poll(async () => (await receipt())?.status, { timeout: 10_000 }).toBe("processed");
|
||||
expect(attempts).toBe(2);
|
||||
expect((await receipt())?.result).toMatchObject({ attempts: 2, httpStatus: 202 });
|
||||
// A duplicate callback wakes the lane but cannot repeat a settled effect.
|
||||
await send();
|
||||
await vi.waitFor(() => expect(coordinator.nextWakeAt()).toBeNull(), { timeout: 10_000 });
|
||||
expect(attempts).toBe(2);
|
||||
await coordinator.stop();
|
||||
// Work committed while no worker exists is recovered by the next startup.
|
||||
const restartDelivery = randomUUID();
|
||||
await service.handleWebhook(endpoint.publicId, "github", signedGitHubWebhookRequest({
|
||||
delivery: restartDelivery, event: "installation_repositories",
|
||||
payload: { action: "added", installation: { id: 2468 } }, webhookSecret,
|
||||
}));
|
||||
expect(attempts).toBe(2);
|
||||
coordinator = createDeliveryWorkCoordinator({ owner: db, canRun: () => true, onError });
|
||||
await registerChatActionWork(coordinator, service, () => true).ready;
|
||||
await expect.poll(() => attempts, { timeout: 10_000 }).toBe(3);
|
||||
expect(onError).not.toHaveBeenCalled();
|
||||
} finally { await coordinator.stop(); }
|
||||
});
|
||||
|
||||
it("safely re-arms an exact terminal GitHub delivery after an operator redelivery", async () => {
|
||||
const fixture = await seedCompany();
|
||||
const { endpoint, runtime, service, webhookSecret } =
|
||||
@@ -18437,6 +18585,9 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => {
|
||||
});
|
||||
const { callbacks, endpoint, runtime, service } =
|
||||
await configuredSlackEndpoint(fixture, { cancelRun });
|
||||
const workWake = vi.fn();
|
||||
const stopWorkObserver = subscribeDeliveryWork(db, DELIVERY_QUEUES.chatSessionStops, workWake);
|
||||
onTestFinished(() => { stopWorkObserver(); expect(workWake).toHaveBeenCalled(); });
|
||||
const externalUserId = "USTOPPER1";
|
||||
const channelId = "CSTOPSESSION1";
|
||||
const threadTs = `${Math.floor(Date.now() / 1_000) - 5}.100000`;
|
||||
@@ -18543,6 +18694,7 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => {
|
||||
},
|
||||
});
|
||||
const webhookUrl = `https://paperclip.example/api/chat-webhooks/${endpoint.publicId}/slack`;
|
||||
workWake.mockClear();
|
||||
const first = await service.handleWebhook(
|
||||
endpoint.publicId,
|
||||
"slack",
|
||||
@@ -27725,6 +27877,9 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => {
|
||||
const fixture = await seedCompany();
|
||||
const { callbacks, endpoint, runtime, service } =
|
||||
await configuredSlackEndpoint(fixture);
|
||||
const workWake = vi.fn();
|
||||
const stopWorkObserver = subscribeDeliveryWork(db, DELIVERY_QUEUES.chatReceiptReactions, workWake);
|
||||
onTestFinished(() => { stopWorkObserver(); expect(workWake).toHaveBeenCalled(); });
|
||||
const endpointRuntime = runtime.endpoints.get(endpoint.id);
|
||||
if (!endpointRuntime) throw new Error("Expected Slack runtime");
|
||||
endpointRuntime.reactionErrors.push(
|
||||
@@ -32841,7 +32996,7 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => {
|
||||
.update(chatEndpoints)
|
||||
.set({ status: "active" })
|
||||
.where(eq(chatEndpoints.id, endpoint.id));
|
||||
const channelId = `C-LANE-${endpoint.id}`;
|
||||
const channelId = `C${endpoint.id.replaceAll("-", "").toUpperCase()}`;
|
||||
await db.insert(chatEndpointResources).values({
|
||||
companyId: fixture.companyId,
|
||||
endpointId: endpoint.id,
|
||||
@@ -32914,6 +33069,155 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => {
|
||||
return { ...fixture, storage, runtime, service, lanes, enqueue, cleanup };
|
||||
}
|
||||
|
||||
it("wakes Slack session sync from publication commit and leaves settled action queues quiet", async () => {
|
||||
const f = await publicationLaneFixture(1);
|
||||
const lane = f.lanes[0]!;
|
||||
const onError = vi.fn();
|
||||
const coordinator = createDeliveryWorkCoordinator({ owner: db, canRun: () => true, onError });
|
||||
try {
|
||||
await registerChatActionWork(coordinator, f.service, () => true).ready;
|
||||
await f.enqueue(0, "A completed answer");
|
||||
await f.service.processPendingPublications();
|
||||
await expect.poll(async () => db.select({ status: chatActions.status }).from(chatActions)
|
||||
.where(and(eq(chatActions.endpointId, lane.endpoint.id), eq(chatActions.kind, "slack_session_sync"))),
|
||||
{ timeout: 10_000 }).toEqual([{ status: "processed" }]);
|
||||
await vi.waitFor(() => expect(coordinator.nextWakeAt()).toBeNull(), { timeout: 10_000 });
|
||||
const scans = vi.spyOn(db, "select");
|
||||
try {
|
||||
await new Promise(resolve => setTimeout(resolve, 1200));
|
||||
expect(scans).not.toHaveBeenCalled();
|
||||
} finally { scans.mockRestore(); }
|
||||
expect(onError).not.toHaveBeenCalled();
|
||||
} finally { await coordinator.stop(); await f.cleanup(); }
|
||||
});
|
||||
|
||||
it("recovers stale provider sends on startup without replaying uncertain effects", async () => {
|
||||
const f = await publicationLaneFixture(1);
|
||||
const lane = f.lanes[0]!;
|
||||
const [action] = await db.insert(chatActions).values({ companyId: f.companyId, endpointId: lane.endpoint.id,
|
||||
kind: "provider_effect", providerActionId: randomUUID(), status: "processing", result: { attempts: 1 },
|
||||
updatedAt: new Date(Date.now() - 120_000), payload: {} }).returning();
|
||||
const before = lane.providerRuntime.posts.length;
|
||||
const coordinator = createDeliveryWorkCoordinator({ owner: db, canRun: () => true });
|
||||
try {
|
||||
await registerChatActionWork(coordinator, f.service, () => true).ready;
|
||||
await expect.poll(async () => db.select({ status: chatActions.status }).from(chatActions)
|
||||
.where(eq(chatActions.id, action!.id)), { timeout: 10_000 }).toEqual([{ status: "delivery_unknown" }]);
|
||||
expect(await f.service.nextChatActionAt("provider_effect")).toBeNull();
|
||||
expect(lane.providerRuntime.posts).toHaveLength(before);
|
||||
} finally { await coordinator.stop(); await f.cleanup(); }
|
||||
});
|
||||
|
||||
it("restores action retry and stale-claim deadlines and parks paused or uncertain work", async () => {
|
||||
const f = await publicationLaneFixture(1);
|
||||
const lane = f.lanes[0]!;
|
||||
const at = new Date();
|
||||
const later = new Date(at.getTime() + 120_000);
|
||||
try {
|
||||
for (const kind of Object.keys(CHAT_ACTION_QUEUES) as (keyof typeof CHAT_ACTION_QUEUES)[]) {
|
||||
const [action] = await db.insert(chatActions).values({ companyId: f.companyId, endpointId: lane.endpoint.id,
|
||||
kind, providerActionId: randomUUID(), status: "received", updatedAt: at }).returning();
|
||||
const set = (status: string, result: Record<string, unknown> = {}) => db.update(chatActions)
|
||||
.set({ status, result, updatedAt: at }).where(eq(chatActions.id, action!.id));
|
||||
const due = async () => db.select({ id: chatActions.id }).from(chatActions)
|
||||
.leftJoin(chatEndpoints, eq(chatEndpoints.id, chatActions.endpointId))
|
||||
.where(and(eq(chatActions.id, action!.id), dueChatAction(kind))).then(rows => rows.length > 0);
|
||||
if (kind === "slack_board_message") {
|
||||
expect(await due()).toBe(true);
|
||||
await db.update(chatEndpoints).set({ status: "paused" }).where(eq(chatEndpoints.id, lane.endpoint.id));
|
||||
expect(await f.service.nextChatActionAt(kind)).toBeNull();
|
||||
expect(await due()).toBe(false);
|
||||
await db.update(chatEndpoints).set({ status: "active" }).where(eq(chatEndpoints.id, lane.endpoint.id));
|
||||
} else if (kind === "slash_task_start") {
|
||||
for (const [status, delay] of [["validating", 60_000], ["resolving", 300_000], ["admitting", 60_000]] as const) {
|
||||
await set(status);
|
||||
expect(await f.service.nextChatActionAt(kind)).toBe(at.getTime() + delay);
|
||||
expect(await due()).toBe(false);
|
||||
}
|
||||
for (const status of ["queued", "provider_confirmed"]) {
|
||||
await set(status, { retryAt: later.toISOString() });
|
||||
expect(await f.service.nextChatActionAt(kind)).toBe(later.getTime());
|
||||
expect(await due()).toBe(false);
|
||||
await db.update(chatEndpoints).set({ status: "attention" }).where(eq(chatEndpoints.id, lane.endpoint.id));
|
||||
expect(await f.service.nextChatActionAt(kind)).toBeNull();
|
||||
await db.update(chatEndpoints).set({ status: "active" }).where(eq(chatEndpoints.id, lane.endpoint.id));
|
||||
}
|
||||
await set("delivery_unknown");
|
||||
expect(await f.service.nextChatActionAt(kind)).toBeNull();
|
||||
expect(await due()).toBe(false);
|
||||
} else if (kind === "slack_session_sync") {
|
||||
expect(await due()).toBe(true);
|
||||
await set("received", { retryAt: later.toISOString() });
|
||||
expect(await f.service.nextChatActionAt(kind)).toBe(later.getTime());
|
||||
expect(await due()).toBe(false);
|
||||
await set("processing");
|
||||
expect(await f.service.nextChatActionAt(kind)).toBe(at.getTime() + 60_000);
|
||||
for (const status of ["paused", "attention"]) {
|
||||
await db.update(chatEndpoints).set({ status }).where(eq(chatEndpoints.id, lane.endpoint.id));
|
||||
expect(await f.service.nextChatActionAt(kind)).toBeNull();
|
||||
expect(await due()).toBe(false);
|
||||
}
|
||||
await db.update(chatEndpoints).set({ status: "active" }).where(eq(chatEndpoints.id, lane.endpoint.id));
|
||||
await set("failed", { retryable: true });
|
||||
expect(await f.service.nextChatActionAt(kind)).toBeNull();
|
||||
} else {
|
||||
expect(await due()).toBe(true);
|
||||
await set("processing");
|
||||
expect(await f.service.nextChatActionAt(kind)).toBe(at.getTime() + 60_000);
|
||||
expect(await due()).toBe(false);
|
||||
await set("failed", { retryable: true, retryAt: later.toISOString() });
|
||||
expect(await f.service.nextChatActionAt(kind)).toBe(later.getTime());
|
||||
expect(await due()).toBe(false);
|
||||
await set("failed", { retryable: false });
|
||||
expect(await f.service.nextChatActionAt(kind)).toBeNull();
|
||||
}
|
||||
await set("processed");
|
||||
expect(await f.service.nextChatActionAt(kind)).toBeNull();
|
||||
}
|
||||
} finally { await f.cleanup(); }
|
||||
});
|
||||
|
||||
it("recovers action rows on startup, wakes after outer commit, and leaves empty queues quiet", async () => {
|
||||
const f = await publicationLaneFixture(1);
|
||||
const lane = f.lanes[0]!;
|
||||
const onError = vi.fn();
|
||||
const coordinator = createDeliveryWorkCoordinator({ owner: db, canRun: () => true, onError });
|
||||
const action = (kind: "receipt_reaction" | "slack_session_stop") => ({ companyId: f.companyId,
|
||||
endpointId: lane.endpoint.id, kind, providerActionId: randomUUID(), status: "processing",
|
||||
updatedAt: new Date(Date.now() - 120_000), payload: {} });
|
||||
try {
|
||||
const [stale] = await db.insert(chatActions).values(action("receipt_reaction")).returning();
|
||||
await registerChatActionWork(coordinator, f.service, () => true).ready;
|
||||
await vi.waitFor(() => expect(coordinator.nextWakeAt()).toBeNull(), { timeout: 10_000 });
|
||||
expect((await db.select().from(chatActions).where(eq(chatActions.id, stale!.id)))[0]?.status).toBe("failed");
|
||||
const scans = vi.spyOn(db, "select");
|
||||
try {
|
||||
await new Promise(resolve => setTimeout(resolve, 1200));
|
||||
expect(scans).not.toHaveBeenCalled();
|
||||
} finally { scans.mockRestore(); }
|
||||
let release!: () => void, inserted!: () => void;
|
||||
const gate = new Promise<void>(resolve => { release = resolve; });
|
||||
const staged = new Promise<void>(resolve => { inserted = resolve; });
|
||||
const writing = db.transaction(async tx => {
|
||||
await tx.transaction(async nested => {
|
||||
await notifyChatActionWork(nested, "slack_session_stop");
|
||||
await nested.insert(chatActions).values(action("slack_session_stop"));
|
||||
});
|
||||
inserted(); await gate;
|
||||
});
|
||||
await staged;
|
||||
expect(coordinator.nextWakeAt()).not.toBeNull();
|
||||
release(); await writing;
|
||||
await vi.waitFor(async () => {
|
||||
const rows = await db.select().from(chatActions).where(and(eq(chatActions.companyId, f.companyId), eq(chatActions.kind, "slack_session_stop")));
|
||||
expect(rows[0]?.status).toBe("failed");
|
||||
expect(coordinator.nextWakeAt()).toBeNull();
|
||||
}, { timeout: 10_000 });
|
||||
expect(lane.providerRuntime.posts).toHaveLength(0);
|
||||
expect(onError).not.toHaveBeenCalled();
|
||||
} finally { await coordinator.stop(); await f.cleanup(); }
|
||||
});
|
||||
|
||||
it("restores chat queue deadlines without polling blocked followers or receipt owners", async () => {
|
||||
const f = await publicationLaneFixture(1);
|
||||
const { service, lanes, enqueue } = f;
|
||||
@@ -53115,6 +53419,9 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => {
|
||||
const fixture = await seedCompany();
|
||||
const { callbacks, endpoint, runtime, service } =
|
||||
await configuredSlackEndpoint(fixture);
|
||||
const workWake = vi.fn();
|
||||
const stopWorkObserver = subscribeDeliveryWork(db, DELIVERY_QUEUES.chatTaskStarts, workWake);
|
||||
onTestFinished(() => { stopWorkObserver(); expect(workWake).toHaveBeenCalled(); });
|
||||
const command = endpoint.setup.command;
|
||||
if (!command) throw new Error("Slack endpoint did not expose its command");
|
||||
if (!callbacks.onSlashCommand) {
|
||||
@@ -53923,6 +54230,75 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("delivers queued provider notices from commit wakes without the deferred fast path", async () => {
|
||||
const fixture = await seedCompany();
|
||||
const { callbacks, endpoint, service, runtime } =
|
||||
await configuredSlackEndpoint(fixture, { scheduleDeferredWork: () => undefined });
|
||||
if (!callbacks.onSlashCommand) {
|
||||
throw new Error("Slack slash command callback was not registered");
|
||||
}
|
||||
await db.insert(chatEndpointResources).values({
|
||||
companyId: fixture.companyId,
|
||||
endpointId: endpoint.id,
|
||||
type: "channel",
|
||||
providerResourceId: "C-CONTROL-GUIDANCE",
|
||||
label: "control-guidance",
|
||||
availability: "available",
|
||||
enabled: true,
|
||||
});
|
||||
const command = endpoint.setup.command;
|
||||
if (!command) throw new Error("Slack endpoint did not expose its command");
|
||||
const post = vi.fn(async () => ({
|
||||
id: "unexpected-channel-control-post",
|
||||
threadId: "slack:C-CONTROL-GUIDANCE:root",
|
||||
}));
|
||||
const postEphemeral = vi.fn(async () => ({
|
||||
id: "channel-control-guidance",
|
||||
threadId: "slack:C-CONTROL-GUIDANCE:root",
|
||||
}));
|
||||
const providerRuntime = runtime.endpoints.get(endpoint.id)!;
|
||||
vi.spyOn(providerRuntime, "thread").mockReturnValue({ post, postEphemeral } as never);
|
||||
const coordinator = createDeliveryWorkCoordinator({ owner: db, canRun: () => true });
|
||||
onTestFinished(async () => { await coordinator.stop(); });
|
||||
await registerChatActionWork(coordinator, service, () => true).ready;
|
||||
for (const control of ["status"] as const) {
|
||||
await callbacks.onSlashCommand({
|
||||
endpointId: endpoint.id,
|
||||
provider: "slack",
|
||||
event: {
|
||||
channel: {
|
||||
id: "slack:C-CONTROL-GUIDANCE",
|
||||
name: "control-guidance",
|
||||
isDM: false,
|
||||
post,
|
||||
postEphemeral,
|
||||
} as never,
|
||||
command,
|
||||
text: control,
|
||||
triggerId: `channel-control-${control}`,
|
||||
user: {
|
||||
userId: "U-CHANNEL-CONTROLLER",
|
||||
userName: "channel-controller",
|
||||
fullName: "Channel Controller",
|
||||
isBot: false,
|
||||
isMe: false,
|
||||
isSystem: false,
|
||||
},
|
||||
raw: { command, text: control },
|
||||
adapter: {} as never,
|
||||
openModal: async () => undefined,
|
||||
},
|
||||
});
|
||||
}
|
||||
await expect.poll(async () => db.select({ status: chatActions.status }).from(chatActions)
|
||||
.where(and(eq(chatActions.endpointId, endpoint.id), eq(chatActions.kind, "provider_effect"))),
|
||||
{ timeout: 10_000 }).toEqual([{ status: "processed" }]);
|
||||
expect(postEphemeral).toHaveBeenCalledTimes(1);
|
||||
expect(post).not.toHaveBeenCalled();
|
||||
expect(await service.nextChatActionAt("provider_effect")).toBeNull();
|
||||
await coordinator.stop();
|
||||
});
|
||||
|
||||
it("returns ephemeral guidance for exact Slack controls in channels without creating tasks or actions", async () => {
|
||||
const fixture = await seedCompany();
|
||||
const { callbacks, endpoint, service } =
|
||||
@@ -54362,6 +54738,9 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => {
|
||||
const fixture = await seedCompany();
|
||||
const { callbacks, endpoint, runtime, service } =
|
||||
await configuredTelegramEndpoint(fixture);
|
||||
const workWake = vi.fn();
|
||||
const stopWorkObserver = subscribeDeliveryWork(db, DELIVERY_QUEUES.chatProviderEffects, workWake);
|
||||
onTestFinished(() => { stopWorkObserver(); expect(workWake).toHaveBeenCalled(); });
|
||||
if (!callbacks.onSlashCommand) {
|
||||
throw new Error("Telegram slash command callback was not registered");
|
||||
}
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import { aiConnectionService } from "../services/ai-connections.js";
|
||||
import { fastResponseService } from "../services/fast-responses.js";
|
||||
import { fastResponseReceipt } from "../services/fast-response-provider.js";
|
||||
import { connectionIntentService } from "../services/connection-intents.js";
|
||||
import { applyConnectorSkills, prepareConnectorSkillDelivery, resolveConnectorAssignments, annotateConnectorSkills } from "../services/connector-runtime.js";
|
||||
import { PaperclipRunnerToolAuthority } from "../services/native-runtime/paperclip-runner-tool-authority.js";
|
||||
@@ -40,6 +43,7 @@ import {
|
||||
emailEndpoints,
|
||||
emailMessages,
|
||||
emailSends,
|
||||
fastResponseRequests,
|
||||
issueComments,
|
||||
heartbeatRuns,
|
||||
issues,
|
||||
@@ -194,9 +198,15 @@ describe("AgentMail durable email pipeline", () => {
|
||||
|
||||
it("can replay the additive email migration without losing existing data", async () => {
|
||||
const migration = readFileSync(new URL("../../../packages/db/src/migrations/0272_light_kate_bishop.sql", import.meta.url), "utf8");
|
||||
await db.execute(sql.raw(migration));
|
||||
await db.execute(sql.raw(migration));
|
||||
expect(await db.select().from(authUsers).where(eq(authUsers.id, "email-board"))).toHaveLength(1);
|
||||
// Replaying this historical migration reinstates its older connection constraint.
|
||||
// Roll back the probe so later tests keep the current AI-connection schema.
|
||||
const rollback = new Error("migration replay probe complete");
|
||||
await expect(db.transaction(async tx => {
|
||||
await tx.execute(sql.raw(migration));
|
||||
await tx.execute(sql.raw(migration));
|
||||
expect(await tx.select().from(authUsers).where(eq(authUsers.id, "email-board"))).toHaveLength(1);
|
||||
throw rollback;
|
||||
})).rejects.toBe(rollback);
|
||||
});
|
||||
|
||||
async function fixture(mode: "websocket" | "webhook" = "webhook", storage?: StorageService,
|
||||
@@ -444,6 +454,33 @@ describe("AgentMail durable email pipeline", () => {
|
||||
expect((await f.service.publication(id, f.companyId)).outcome).toBe("sent");
|
||||
});
|
||||
|
||||
it("publishes one sponsored fast response only to the originating email sender", async () => {
|
||||
await instanceSettingsService(db).updateExperimental({ enableFastResponses: true });
|
||||
const f = await fixture();
|
||||
const binding = await aiConnectionService(db).save(f.companyId, "email-board", { provider: "openrouter", method: "api_key", name: "Fast email", ownership: "shared", apiKey: "fixture-key", agentIds: [], allAgents: true }, "fixture-key");
|
||||
const provider = vi.fn(async () => ({ text: "I’ll check the settings panel border.", receipt: fastResponseReceipt({ usage: { inputTokens: 80, outputTokens: 9 }, response: { body: { usage: { cost: 0.00001 } } } }) }));
|
||||
const fast = fastResponseService(db, { provider, authorizeExternal: f.service.authorizeFastResponse,
|
||||
publishExternal: async (...args) => { await f.service.publishFastResponse(...args); return true; } });
|
||||
await fast.configure(f.companyId, "email-board", { enabled: true, ...binding, model: "openai/gpt-oss-120b", allowSponsored: true });
|
||||
vi.spyOn(Date, "now").mockReturnValue(Date.now());
|
||||
const incoming = f.message("fast-email", "fast-thread", { text: "Please check the settings panel border.", cc: ["bystander@example.test"] });
|
||||
await f.receive(incoming);
|
||||
const [job] = await db.select().from(fastResponseRequests).where(eq(fastResponseRequests.companyId, f.companyId));
|
||||
expect(job).toMatchObject({ sponsored: true, responsibleUserId: null });
|
||||
expect(f.wakeup).toHaveBeenCalled();
|
||||
await f.service.start();
|
||||
await fast.process(job);
|
||||
await vi.waitFor(() => expect(f.sends).toHaveLength(1), { timeout: 5000 });
|
||||
await f.service.flushPublications();
|
||||
expect(f.sends).toHaveLength(1);
|
||||
expect(f.sends[0].body.text).toBe("I’ll check the settings panel border.");
|
||||
expect(f.sends[0].body.cc ?? []).not.toContain("bystander@example.test");
|
||||
expect(f.sends[0].path).toContain("fast-email/reply");
|
||||
const [receipt] = await db.select().from(issueComments).where(and(eq(issueComments.issueId, job.issueId!), eq(issueComments.origin, "fast_response")));
|
||||
expect(receipt).toMatchObject({ authorAgentId: f.agentId, createdByRunId: null });
|
||||
expect((await issueService(db).getById(job.issueId!))?.status).not.toBe("done");
|
||||
});
|
||||
|
||||
it("admits signed webhooks through the durable queue and rejects a valid signature for another inbox", async () => {
|
||||
const f = await fixture();
|
||||
const endpoint = await f.service.getEndpoint(f.endpointId);
|
||||
|
||||
@@ -125,6 +125,10 @@ vi.mock("../services/issue-assignment-wakeup.js", () => ({
|
||||
queueIssueAssignmentWakeup: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../services/fast-responses.js", () => ({
|
||||
enqueueFastResponse: vi.fn(async () => undefined),
|
||||
}));
|
||||
|
||||
function buildApp(routerFactory: (app: express.Express) => void) {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
@@ -153,7 +157,7 @@ function createProjectApp() {
|
||||
|
||||
function createIssueApp() {
|
||||
issueServer ??= buildApp((expressApp) => {
|
||||
expressApp.use("/api", issueRoutes({} as any, {} as any));
|
||||
expressApp.use("/api", issueRoutes({ transaction: async (effect: (tx: unknown) => unknown) => effect({}) } as any, {} as any));
|
||||
}).listen(0);
|
||||
return issueServer;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { afterEach, expect, it, vi } from "vitest";
|
||||
import { fastResponseCatalogModels } from "../services/fast-response-models.js";
|
||||
|
||||
afterEach(() => vi.unstubAllEnvs());
|
||||
|
||||
it("keeps direct Anthropic models independent of host Bedrock and adapter overrides", () => {
|
||||
vi.stubEnv("CLAUDE_CODE_USE_BEDROCK", "1");
|
||||
vi.stubEnv("PAPERCLIP_ADAPTER_MODELS", JSON.stringify({ claude_local: [{ id: "host-private-model" }] }));
|
||||
const models = fastResponseCatalogModels({ provider: "anthropic" });
|
||||
expect(models.length).toBeGreaterThan(0);
|
||||
expect(models.every(model => model.id.startsWith("claude-"))).toBe(true);
|
||||
});
|
||||
|
||||
it("uses only a routed connection's configured model IDs", () => {
|
||||
const routing = { kind: "bedrock" as const, protocol: "bedrock" as const, auth: "bearer" as const, region: "us-east-1", models: [] };
|
||||
expect(fastResponseCatalogModels({ provider: "anthropic", routing })).toEqual([]);
|
||||
const models = [{ id: "us.anthropic.custom", label: "Configured model" }];
|
||||
expect(fastResponseCatalogModels({ provider: "anthropic", routing: { ...routing, models } })).toEqual(models);
|
||||
});
|
||||
@@ -0,0 +1,463 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { AiConnectionMetadata } from "@paperclipai/shared";
|
||||
import {
|
||||
fastResponsePrompt,
|
||||
fastResponseReceipt,
|
||||
runFastResponseProvider,
|
||||
} from "../services/fast-response-provider.js";
|
||||
const ack =
|
||||
"I’ll check the settings panel’s border styling and work on the fix.";
|
||||
const chatResponse = () => ({
|
||||
id: "receipt-1",
|
||||
model: "openai/gpt-oss-120b",
|
||||
created: 1,
|
||||
choices: [
|
||||
{
|
||||
index: 0,
|
||||
finish_reason: "stop",
|
||||
message: { role: "assistant", content: ack },
|
||||
},
|
||||
],
|
||||
usage: {
|
||||
prompt_tokens: 100,
|
||||
completion_tokens: 15,
|
||||
total_tokens: 115,
|
||||
cost: 0.00001,
|
||||
},
|
||||
});
|
||||
const response = (body: unknown) =>
|
||||
new Response(JSON.stringify(body), {
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
const input = (metadata: AiConnectionMetadata) => ({
|
||||
metadata,
|
||||
model: "test-model",
|
||||
credential: "fixture-key",
|
||||
prompt: fastResponsePrompt({ agentName: "Alex", message: "Fix the border" }),
|
||||
signal: AbortSignal.timeout(3000),
|
||||
});
|
||||
const connections: AiConnectionMetadata[] = [
|
||||
...(["openrouter", "openai", "anthropic", "google", "xai"] as const).map(
|
||||
(provider) => ({ provider, method: "api_key" as const }),
|
||||
),
|
||||
{
|
||||
provider: "anthropic",
|
||||
method: "api_key",
|
||||
routing: {
|
||||
kind: "bedrock",
|
||||
protocol: "bedrock",
|
||||
region: "us-west-2",
|
||||
auth: "bearer",
|
||||
models: [],
|
||||
},
|
||||
},
|
||||
...(["chat", "responses", "messages"] as const).map((protocol) => ({
|
||||
provider: "openai" as const,
|
||||
method: "api_key" as const,
|
||||
routing: {
|
||||
kind: "gateway" as const,
|
||||
protocol,
|
||||
baseUrl: "https://gateway.example/v1",
|
||||
auth: "bearer" as const,
|
||||
models: [],
|
||||
},
|
||||
})),
|
||||
{
|
||||
provider: "openai",
|
||||
method: "api_key",
|
||||
routing: {
|
||||
kind: "local",
|
||||
protocol: "chat",
|
||||
baseUrl: "http://localhost:8080/v1",
|
||||
auth: "none",
|
||||
models: [],
|
||||
},
|
||||
},
|
||||
...(["responses", "messages"] as const).map((protocol) => ({
|
||||
provider: "openrouter" as const,
|
||||
method: "api_key" as const,
|
||||
routing: {
|
||||
kind: "openrouter" as const,
|
||||
protocol,
|
||||
auth: "bearer" as const,
|
||||
models: [],
|
||||
},
|
||||
})),
|
||||
];
|
||||
describe("fast response provider", () => {
|
||||
it("rejects copied JSON output while retaining the provider charge", async () => {
|
||||
const body = chatResponse();
|
||||
body.choices[0].message.content = '{"message":"I’ll check the border."}';
|
||||
const result = await runFastResponseProvider(input({ provider: "openrouter", method: "api_key" }), {
|
||||
fetch: vi.fn(async () => response(body)),
|
||||
});
|
||||
expect(result.errorCode).toBe("invalid_output");
|
||||
expect(result.text).toBeUndefined();
|
||||
expect(result.receipt.costStatus).toBe("reported");
|
||||
});
|
||||
it("bounds unicode input and drops old context first", () => {
|
||||
const prompt = fastResponsePrompt({
|
||||
agentName: "Alex",
|
||||
message: "🦄".repeat(10000),
|
||||
recent: ["OLD-CONTEXT".repeat(1000)],
|
||||
});
|
||||
expect(Buffer.byteLength(prompt)).toBeLessThanOrEqual(4096);
|
||||
expect(prompt).not.toContain("OLD-CONTEXT");
|
||||
expect(prompt).toContain("[truncated]");
|
||||
});
|
||||
it("bounds escaped metadata and multibyte attachment names without losing the current request", () => {
|
||||
const prompt = fastResponsePrompt({
|
||||
agentName: "\u0001".repeat(100),
|
||||
title: "🦄".repeat(200),
|
||||
attachments: Array(5).fill("\u0001".repeat(80)),
|
||||
message: "Fix the border",
|
||||
recent: ["old".repeat(2000)],
|
||||
});
|
||||
expect(Buffer.byteLength(prompt)).toBeLessThanOrEqual(4096);
|
||||
expect(prompt).toContain("Fix the border");
|
||||
});
|
||||
it("calls OpenRouter through the SDK with latency routing and records the charge", async () => {
|
||||
const fetch = vi.fn(async () => response(chatResponse()));
|
||||
const result = await runFastResponseProvider(
|
||||
input({ provider: "openrouter", method: "api_key" }),
|
||||
{ fetch },
|
||||
);
|
||||
expect(result.text).toBe(ack);
|
||||
expect(result.receipt).toMatchObject({
|
||||
inputTokens: 100,
|
||||
outputTokens: 15,
|
||||
costStatus: "reported",
|
||||
costCents: "0.0010000",
|
||||
});
|
||||
const [url, init] = fetch.mock.calls[0] as unknown as [string, RequestInit];
|
||||
expect(url).toBe("https://openrouter.ai/api/v1/chat/completions");
|
||||
const body = JSON.parse(String(init.body));
|
||||
expect(body).toMatchObject({
|
||||
model: "test-model",
|
||||
max_tokens: 256,
|
||||
provider: { sort: "latency" },
|
||||
});
|
||||
expect(body.tools).toBeUndefined();
|
||||
expect(fetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
it.each([
|
||||
[
|
||||
{
|
||||
provider: "openai",
|
||||
method: "api_key",
|
||||
routing: {
|
||||
kind: "gateway",
|
||||
protocol: "chat",
|
||||
baseUrl: "https://gateway.example/v1",
|
||||
auth: "bearer",
|
||||
models: [],
|
||||
},
|
||||
},
|
||||
"https://gateway.example/v1/chat/completions",
|
||||
"Bearer fixture-key",
|
||||
],
|
||||
[
|
||||
{
|
||||
provider: "openai",
|
||||
method: "api_key",
|
||||
routing: {
|
||||
kind: "local",
|
||||
protocol: "chat",
|
||||
baseUrl: "http://localhost:8080/v1",
|
||||
auth: "none",
|
||||
models: [],
|
||||
},
|
||||
},
|
||||
"http://localhost:8080/v1/chat/completions",
|
||||
null,
|
||||
],
|
||||
] as const)(
|
||||
"honors saved chat endpoint/auth %j",
|
||||
async (metadata, expectedUrl, auth) => {
|
||||
const fetch = vi.fn(async () => response(chatResponse()));
|
||||
const result = await runFastResponseProvider(
|
||||
input(metadata as AiConnectionMetadata),
|
||||
{ fetch },
|
||||
);
|
||||
expect(result.text).toBe(ack);
|
||||
const [url, init] = fetch.mock.calls[0] as unknown as [
|
||||
string,
|
||||
RequestInit,
|
||||
];
|
||||
expect(url).toBe(expectedUrl);
|
||||
expect(new Headers(init.headers).get("authorization")).toBe(auth);
|
||||
},
|
||||
);
|
||||
it.each([
|
||||
[
|
||||
{ provider: "openai", method: "api_key" },
|
||||
"https://api.openai.com/v1/responses",
|
||||
],
|
||||
[{ provider: "xai", method: "api_key" }, "https://api.x.ai/v1/responses"],
|
||||
[
|
||||
{
|
||||
provider: "openai",
|
||||
method: "api_key",
|
||||
routing: {
|
||||
kind: "openrouter",
|
||||
protocol: "responses",
|
||||
auth: "bearer",
|
||||
models: [],
|
||||
},
|
||||
},
|
||||
"https://openrouter.ai/api/v1/responses",
|
||||
],
|
||||
[
|
||||
{
|
||||
provider: "openai",
|
||||
method: "api_key",
|
||||
routing: {
|
||||
kind: "gateway",
|
||||
protocol: "responses",
|
||||
baseUrl: "https://gateway.example/v1",
|
||||
auth: "bearer",
|
||||
models: [],
|
||||
},
|
||||
},
|
||||
"https://gateway.example/v1/responses",
|
||||
],
|
||||
] as const)("supports Responses %j", async (metadata, url) => {
|
||||
const fetch = vi.fn(async () =>
|
||||
response({
|
||||
id: "resp_1",
|
||||
object: "response",
|
||||
created_at: 1,
|
||||
model: "test-model",
|
||||
status: "completed",
|
||||
output: [
|
||||
{
|
||||
type: "message",
|
||||
id: "msg_1",
|
||||
role: "assistant",
|
||||
status: "completed",
|
||||
content: [{ type: "output_text", text: ack, annotations: [] }],
|
||||
},
|
||||
],
|
||||
usage: { input_tokens: 100, output_tokens: 15, total_tokens: 115 },
|
||||
}),
|
||||
);
|
||||
const result = await runFastResponseProvider(
|
||||
input(metadata as AiConnectionMetadata),
|
||||
{ fetch },
|
||||
);
|
||||
expect(result.text).toBe(ack);
|
||||
expect(result.receipt).toMatchObject({
|
||||
inputTokens: 100,
|
||||
outputTokens: 15,
|
||||
});
|
||||
const [actual, init] = fetch.mock.calls[0] as unknown as [
|
||||
string,
|
||||
RequestInit,
|
||||
];
|
||||
expect(actual).toBe(url);
|
||||
expect(new Headers(init.headers).get("authorization")).toBe(
|
||||
"Bearer fixture-key",
|
||||
);
|
||||
expect(JSON.parse(String(init.body))).toMatchObject({
|
||||
model: "test-model",
|
||||
max_output_tokens: 256,
|
||||
});
|
||||
});
|
||||
it.each([
|
||||
[
|
||||
{ provider: "anthropic", method: "api_key" },
|
||||
"https://api.anthropic.com/v1/messages",
|
||||
"x-api-key",
|
||||
"fixture-key",
|
||||
],
|
||||
[
|
||||
{
|
||||
provider: "anthropic",
|
||||
method: "api_key",
|
||||
routing: {
|
||||
kind: "openrouter",
|
||||
protocol: "messages",
|
||||
auth: "bearer",
|
||||
models: [],
|
||||
},
|
||||
},
|
||||
"https://openrouter.ai/api/v1/messages",
|
||||
"authorization",
|
||||
"Bearer fixture-key",
|
||||
],
|
||||
[
|
||||
{
|
||||
provider: "anthropic",
|
||||
method: "api_key",
|
||||
routing: {
|
||||
kind: "gateway",
|
||||
protocol: "messages",
|
||||
baseUrl: "https://messages.example/v1",
|
||||
auth: "bearer",
|
||||
models: [],
|
||||
},
|
||||
},
|
||||
"https://messages.example/v1/messages",
|
||||
"authorization",
|
||||
"Bearer fixture-key",
|
||||
],
|
||||
] as const)("supports Messages %j", async (metadata, url, header, key) => {
|
||||
const fetch = vi.fn(async () =>
|
||||
response({
|
||||
id: "msg_1",
|
||||
type: "message",
|
||||
role: "assistant",
|
||||
model: "test-model",
|
||||
content: [{ type: "text", text: ack }],
|
||||
stop_reason: "end_turn",
|
||||
stop_sequence: null,
|
||||
usage: { input_tokens: 100, output_tokens: 15 },
|
||||
}),
|
||||
);
|
||||
const result = await runFastResponseProvider(
|
||||
input(metadata as AiConnectionMetadata),
|
||||
{ fetch },
|
||||
);
|
||||
expect(result.text).toBe(ack);
|
||||
expect(result.receipt).toMatchObject({
|
||||
inputTokens: 100,
|
||||
outputTokens: 15,
|
||||
});
|
||||
const [actual, init] = fetch.mock.calls[0] as unknown as [
|
||||
string,
|
||||
RequestInit,
|
||||
];
|
||||
expect(actual).toBe(url);
|
||||
expect(new Headers(init.headers).get(header)).toBe(key);
|
||||
expect(JSON.parse(String(init.body))).toMatchObject({
|
||||
model: "test-model",
|
||||
max_tokens: 256,
|
||||
});
|
||||
});
|
||||
it("supports Google generation and auth", async () => {
|
||||
const fetch = vi.fn(async () =>
|
||||
response({
|
||||
candidates: [
|
||||
{
|
||||
content: { role: "model", parts: [{ text: ack }] },
|
||||
finishReason: "STOP",
|
||||
index: 0,
|
||||
},
|
||||
],
|
||||
usageMetadata: {
|
||||
promptTokenCount: 100,
|
||||
candidatesTokenCount: 15,
|
||||
totalTokenCount: 115,
|
||||
},
|
||||
}),
|
||||
);
|
||||
const result = await runFastResponseProvider(
|
||||
input({ provider: "google", method: "api_key" }),
|
||||
{ fetch },
|
||||
);
|
||||
expect(result.text).toBe(ack);
|
||||
expect(result.receipt).toMatchObject({
|
||||
inputTokens: 100,
|
||||
outputTokens: 15,
|
||||
});
|
||||
const [url, init] = fetch.mock.calls[0] as unknown as [string, RequestInit];
|
||||
expect(url).toBe(
|
||||
"https://generativelanguage.googleapis.com/v1beta/models/test-model:generateContent",
|
||||
);
|
||||
expect(new Headers(init.headers).get("x-goog-api-key")).toBe("fixture-key");
|
||||
expect(JSON.parse(String(init.body)).generationConfig.maxOutputTokens).toBe(
|
||||
256,
|
||||
);
|
||||
});
|
||||
it("supports Bedrock region, bearer key and Converse", async () => {
|
||||
const fetch = vi.fn(async () =>
|
||||
response({
|
||||
output: { message: { role: "assistant", content: [{ text: ack }] } },
|
||||
stopReason: "end_turn",
|
||||
usage: { inputTokens: 100, outputTokens: 15, totalTokens: 115 },
|
||||
metrics: { latencyMs: 50 },
|
||||
}),
|
||||
);
|
||||
const result = await runFastResponseProvider(
|
||||
input({
|
||||
provider: "anthropic",
|
||||
method: "api_key",
|
||||
routing: {
|
||||
kind: "bedrock",
|
||||
protocol: "bedrock",
|
||||
region: "us-west-2",
|
||||
auth: "bearer",
|
||||
models: [],
|
||||
},
|
||||
}),
|
||||
{ fetch },
|
||||
);
|
||||
expect(result.text).toBe(ack);
|
||||
expect(result.receipt).toMatchObject({
|
||||
inputTokens: 100,
|
||||
outputTokens: 15,
|
||||
});
|
||||
const [url, init] = fetch.mock.calls[0] as unknown as [string, RequestInit];
|
||||
expect(url).toBe(
|
||||
"https://bedrock-runtime.us-west-2.amazonaws.com/model/test-model/converse",
|
||||
);
|
||||
expect(new Headers(init.headers).get("authorization")).toBe(
|
||||
"Bearer fixture-key",
|
||||
);
|
||||
expect(JSON.parse(String(init.body)).inferenceConfig.maxTokens).toBe(256);
|
||||
});
|
||||
it.each(connections)(
|
||||
"aborts once without inventing a billing outcome for %j",
|
||||
async (metadata) => {
|
||||
const controller = new AbortController();
|
||||
const fetch = vi.fn(async () => {
|
||||
controller.abort();
|
||||
throw new DOMException("aborted", "AbortError");
|
||||
});
|
||||
const result = await runFastResponseProvider(
|
||||
{ ...input(metadata), signal: controller.signal },
|
||||
{ fetch },
|
||||
);
|
||||
expect(result).toMatchObject({
|
||||
errorCode: "timeout",
|
||||
receipt: { costStatus: "unpriced" },
|
||||
});
|
||||
expect(fetch).toHaveBeenCalledTimes(1);
|
||||
},
|
||||
);
|
||||
it.each(connections)(
|
||||
"never retries provider rejection or retains error bodies for %j",
|
||||
async (metadata) => {
|
||||
const fetch = vi.fn(
|
||||
async () =>
|
||||
new Response(
|
||||
JSON.stringify({ error: { message: "SECRET provider input" } }),
|
||||
{ status: 401, headers: { "content-type": "application/json" } },
|
||||
),
|
||||
);
|
||||
const result = await runFastResponseProvider(input(metadata), { fetch });
|
||||
expect(fetch).toHaveBeenCalledTimes(1);
|
||||
expect(result.errorCode).toBe("provider_auth_failed");
|
||||
expect(result.noProviderWork).toBe(true);
|
||||
expect(JSON.stringify(result)).not.toContain("SECRET");
|
||||
},
|
||||
);
|
||||
it("rejects long output without discarding its charge", async () => {
|
||||
const body = chatResponse();
|
||||
body.choices[0].message.content = "x".repeat(321);
|
||||
const result = await runFastResponseProvider(
|
||||
input({ provider: "openrouter", method: "api_key" }),
|
||||
{ fetch: async () => response(body) },
|
||||
);
|
||||
expect(result.text).toBeUndefined();
|
||||
expect(result.errorCode).toBe("invalid_output");
|
||||
expect(result.receipt.costStatus).toBe("reported");
|
||||
});
|
||||
it("does not invent a price", () => {
|
||||
expect(fastResponseReceipt({ usage: { inputTokens: 5 } })).toMatchObject({
|
||||
inputTokens: 5,
|
||||
costCents: null,
|
||||
costStatus: "unpriced",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,629 @@
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { mkdtemp, realpath, rm } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import {
|
||||
createDb,
|
||||
companies,
|
||||
agents,
|
||||
companyMemberships,
|
||||
companyFastResponses,
|
||||
costEvents,
|
||||
fastResponseRequests,
|
||||
connectionGrants,
|
||||
connectionGrantMembers,
|
||||
budgetPolicies,
|
||||
budgetReservations,
|
||||
heartbeatRunEvents,
|
||||
heartbeatRuns,
|
||||
issues,
|
||||
issueComments,
|
||||
} from "@paperclipai/db";
|
||||
import { startEmbeddedPostgresTestDatabase } from "@paperclipai/db/test-embedded-postgres";
|
||||
import { aiConnectionService } from "../services/ai-connections.js";
|
||||
import {
|
||||
enqueueFastResponse,
|
||||
supersedeFastResponse,
|
||||
fastResponseTurnQueued,
|
||||
fastResponseService,
|
||||
} from "../services/fast-responses.js";
|
||||
import {
|
||||
fastResponseReceipt,
|
||||
type FastResponseOutcome,
|
||||
} from "../services/fast-response-provider.js";
|
||||
import { instanceSettingsService } from "../services/instance-settings.js";
|
||||
import { settleConversationTurn } from "../services/agent-conversations.js";
|
||||
import { buildLowTrustSourceTrust, LOW_TRUST_QUARANTINED_BODY } from "../services/source-trust.js";
|
||||
let database: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>>,
|
||||
db: ReturnType<typeof createDb>,
|
||||
home: string;
|
||||
beforeAll(async () => {
|
||||
home = await realpath(
|
||||
await mkdtemp(path.join(os.tmpdir(), "paperclip-fast-response-")),
|
||||
);
|
||||
vi.stubEnv("PAPERCLIP_HOME", home);
|
||||
vi.stubEnv("PAPERCLIP_INSTANCE_ID", "fast-response-fixture");
|
||||
database = await startEmbeddedPostgresTestDatabase(
|
||||
"paperclip-fast-response-db-",
|
||||
);
|
||||
db = createDb(database.connectionString);
|
||||
}, 90000);
|
||||
afterAll(async () => {
|
||||
await database?.cleanup();
|
||||
vi.unstubAllEnvs();
|
||||
if (home) await rm(home, { recursive: true, force: true });
|
||||
});
|
||||
const outcome: FastResponseOutcome = {
|
||||
text: "I’ll check the settings panel’s border styling and work on the fix.",
|
||||
receipt: fastResponseReceipt({
|
||||
usage: { inputTokens: 80, outputTokens: 16 },
|
||||
response: { id: "response-1", body: { usage: { cost: 0.00001 } } },
|
||||
}),
|
||||
};
|
||||
let sequence = 0;
|
||||
async function fixture() {
|
||||
await instanceSettingsService(db).updateExperimental({ enableFastResponses: true });
|
||||
const companyId = randomUUID(),
|
||||
agentId = randomUUID(),
|
||||
issueId = randomUUID();
|
||||
await db.insert(companies).values({
|
||||
id: companyId,
|
||||
name: "Fast response tests",
|
||||
issuePrefix: `FR${++sequence}`,
|
||||
});
|
||||
await db.insert(agents).values({
|
||||
id: agentId,
|
||||
companyId,
|
||||
name: "Alex",
|
||||
adapterType: "codex_local",
|
||||
});
|
||||
await db.insert(companyMemberships).values(
|
||||
["alice", "bob"].map((principalId) => ({
|
||||
companyId,
|
||||
principalId,
|
||||
principalType: "user",
|
||||
status: "active",
|
||||
membershipRole: "owner",
|
||||
})),
|
||||
);
|
||||
const binding = await aiConnectionService(db).save(
|
||||
companyId,
|
||||
"alice",
|
||||
{
|
||||
provider: "openrouter",
|
||||
method: "api_key",
|
||||
name: "Fast key",
|
||||
ownership: "shared",
|
||||
apiKey: "fixture-key",
|
||||
agentIds: [],
|
||||
allAgents: true,
|
||||
},
|
||||
"fixture-key",
|
||||
);
|
||||
const provider = vi.fn().mockResolvedValue(structuredClone(outcome)),
|
||||
service = fastResponseService(db, { provider });
|
||||
const config = {
|
||||
connectionId: binding.connectionId,
|
||||
grantId: binding.grantId,
|
||||
enabled: true,
|
||||
model: "openai/gpt-oss-120b",
|
||||
allowSponsored: true,
|
||||
};
|
||||
await service.configure(companyId, "alice", config);
|
||||
await db.insert(issues).values({
|
||||
id: issueId,
|
||||
companyId,
|
||||
title: "Settings panel",
|
||||
status: "in_progress",
|
||||
assigneeAgentId: agentId,
|
||||
});
|
||||
const [comment] = await db
|
||||
.insert(issueComments)
|
||||
.values({
|
||||
companyId,
|
||||
issueId,
|
||||
authorUserId: "alice",
|
||||
body: "Fix the border styling",
|
||||
})
|
||||
.returning();
|
||||
const source = {
|
||||
companyId,
|
||||
agentId,
|
||||
issueId,
|
||||
responsibleUserId: "alice",
|
||||
sourceCommentId: comment.id,
|
||||
sourceKey: `comment:${comment.id}`,
|
||||
acceptedAt: comment.createdAt,
|
||||
};
|
||||
const enqueue = async () => {
|
||||
await db.transaction((tx) =>
|
||||
enqueueFastResponse(tx as unknown as typeof db, source),
|
||||
);
|
||||
return (
|
||||
await db
|
||||
.select()
|
||||
.from(fastResponseRequests)
|
||||
.where(eq(fastResponseRequests.companyId, companyId))
|
||||
)[0];
|
||||
};
|
||||
return {
|
||||
companyId,
|
||||
agentId,
|
||||
issueId,
|
||||
comment,
|
||||
source,
|
||||
config,
|
||||
binding,
|
||||
provider,
|
||||
service,
|
||||
enqueue,
|
||||
};
|
||||
}
|
||||
describe("fast response accepted turns", () => {
|
||||
it("does not enqueue or infer while the experimental setting is disabled", async () => {
|
||||
const f = await fixture();
|
||||
await instanceSettingsService(db).updateExperimental({ enableFastResponses: false });
|
||||
expect(await f.enqueue()).toBeUndefined();
|
||||
expect(await f.service.availability(f.companyId, "alice")).toMatchObject({ available: false });
|
||||
expect(await f.service.test(f.companyId, "alice")).toMatchObject({ status: "unavailable", reason: "disabled" });
|
||||
expect(f.provider).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("suppresses pending work when the experimental setting is switched off", async () => {
|
||||
const f = await fixture();
|
||||
const request = await f.enqueue();
|
||||
await instanceSettingsService(db).updateExperimental({ enableFastResponses: false });
|
||||
await f.service.process(request);
|
||||
expect(f.provider).not.toHaveBeenCalled();
|
||||
const [row] = await db.select().from(fastResponseRequests).where(eq(fastResponseRequests.id, request.id));
|
||||
expect(row).toMatchObject({ status: "skipped", publicationStatus: "suppressed", commentId: null });
|
||||
});
|
||||
|
||||
it("retains charges but suppresses a response when disabled during inference", async () => {
|
||||
const f = await fixture();
|
||||
const request = await f.enqueue();
|
||||
f.provider.mockImplementationOnce(async () => {
|
||||
await instanceSettingsService(db).updateExperimental({ enableFastResponses: false });
|
||||
return structuredClone(outcome);
|
||||
});
|
||||
await f.service.process(request);
|
||||
const [row] = await db.select().from(fastResponseRequests).where(eq(fastResponseRequests.id, request.id));
|
||||
expect(row).toMatchObject({ status: "succeeded", publicationStatus: "suppressed", commentId: null });
|
||||
expect(row.costEventId).toBeTruthy();
|
||||
});
|
||||
|
||||
it.each(["recent", "source"] as const)("keeps quarantined %s text out of the acknowledgement prompt", async location => {
|
||||
const f = await fixture();
|
||||
const untrusted = "QUARANTINED_PRIVATE_FINDING_DO_NOT_PROMOTE";
|
||||
const sourceTrust = buildLowTrustSourceTrust({ issueId: f.issueId, agentId: f.agentId });
|
||||
if (location === "source") {
|
||||
await db.update(issueComments).set({ body: untrusted, sourceTrust }).where(eq(issueComments.id, f.comment.id));
|
||||
} else {
|
||||
await db.insert(issueComments).values({ companyId: f.companyId, issueId: f.issueId, authorAgentId: f.agentId,
|
||||
body: untrusted, sourceTrust, createdAt: new Date(f.source.acceptedAt.getTime() - 1) });
|
||||
}
|
||||
await f.service.process(await f.enqueue());
|
||||
expect(f.provider).toHaveBeenCalledTimes(1);
|
||||
const prompt = f.provider.mock.calls[0][0].prompt as string;
|
||||
expect(prompt).not.toContain(untrusted);
|
||||
expect(prompt).toContain(LOW_TRUST_QUARANTINED_BODY);
|
||||
const [receipt] = await db.select().from(issueComments).where(and(eq(issueComments.issueId, f.issueId), eq(issueComments.origin, "fast_response")));
|
||||
expect(receipt.body).not.toContain(untrusted);
|
||||
expect(receipt.sourceTrust).toEqual(location === "source" ? sourceTrust : null);
|
||||
});
|
||||
it("distinguishes the current turn's running agent from older or queued work", async () => {
|
||||
const f = await fixture();
|
||||
expect(await fastResponseTurnQueued(db, f.source)).toBe(false);
|
||||
const [run] = await db
|
||||
.insert(heartbeatRuns)
|
||||
.values({
|
||||
companyId: f.companyId,
|
||||
agentId: f.agentId,
|
||||
status: "running",
|
||||
contextSnapshot: { issueId: f.issueId, wakeCommentId: f.comment.id },
|
||||
createdAt: new Date(f.source.acceptedAt.getTime() - 1),
|
||||
})
|
||||
.returning();
|
||||
expect(await fastResponseTurnQueued(db, f.source)).toBe(false);
|
||||
await db
|
||||
.update(heartbeatRuns)
|
||||
.set({
|
||||
contextSnapshot: { issueId: f.issueId, wakeCommentId: randomUUID() },
|
||||
})
|
||||
.where(eq(heartbeatRuns.id, run.id));
|
||||
expect(await fastResponseTurnQueued(db, f.source)).toBe(true);
|
||||
await db
|
||||
.update(heartbeatRuns)
|
||||
.set({
|
||||
status: "queued",
|
||||
createdAt: new Date(f.source.acceptedAt.getTime() + 1),
|
||||
})
|
||||
.where(eq(heartbeatRuns.id, run.id));
|
||||
expect(await fastResponseTurnQueued(db, f.source)).toBe(true);
|
||||
});
|
||||
it("starts disabled, and disabled turns have no request or cost", async () => {
|
||||
const f = await fixture();
|
||||
await db
|
||||
.delete(companyFastResponses)
|
||||
.where(eq(companyFastResponses.companyId, f.companyId));
|
||||
expect(await f.service.settings(f.companyId)).toMatchObject({
|
||||
enabled: false,
|
||||
allowSponsored: true,
|
||||
});
|
||||
expect(await f.enqueue()).toBeUndefined();
|
||||
expect(f.provider).not.toHaveBeenCalled();
|
||||
});
|
||||
it("deduplicates concurrent workers and persists provenance without fabricating a run", async () => {
|
||||
const f = await fixture();
|
||||
const row = await f.enqueue();
|
||||
await f.enqueue();
|
||||
await Promise.all([f.service.process(row), f.service.process(row)]);
|
||||
expect(f.provider).toHaveBeenCalledTimes(1);
|
||||
const receipts = await db
|
||||
.select()
|
||||
.from(issueComments)
|
||||
.where(
|
||||
and(
|
||||
eq(issueComments.companyId, f.companyId),
|
||||
eq(issueComments.origin, "fast_response"),
|
||||
),
|
||||
);
|
||||
expect(receipts).toHaveLength(1);
|
||||
expect(receipts[0]).toMatchObject({
|
||||
authorAgentId: f.agentId,
|
||||
createdByRunId: null,
|
||||
fastResponseRequestId: row.id,
|
||||
});
|
||||
expect(
|
||||
await db
|
||||
.select()
|
||||
.from(heartbeatRuns)
|
||||
.where(eq(heartbeatRuns.companyId, f.companyId)),
|
||||
).toHaveLength(0);
|
||||
await db
|
||||
.update(issues)
|
||||
.set({
|
||||
conversationAgentId: f.agentId,
|
||||
conversationUserId: "alice",
|
||||
conversationState: "active",
|
||||
})
|
||||
.where(eq(issues.id, f.issueId));
|
||||
const [run] = await db
|
||||
.insert(heartbeatRuns)
|
||||
.values({
|
||||
companyId: f.companyId,
|
||||
agentId: f.agentId,
|
||||
status: "succeeded",
|
||||
invocationSource: "on_demand",
|
||||
contextSnapshot: { issueId: f.issueId, wakeCommentId: f.comment.id },
|
||||
})
|
||||
.returning();
|
||||
expect(await settleConversationTurn(db, run)).toBe(false);
|
||||
const [event] = await db
|
||||
.select()
|
||||
.from(costEvents)
|
||||
.where(eq(costEvents.companyId, f.companyId));
|
||||
expect(event).toMatchObject({
|
||||
usageKind: "fast_response",
|
||||
heartbeatRunId: null,
|
||||
costCents: 0.001,
|
||||
responsibleUserId: "alice",
|
||||
});
|
||||
expect(
|
||||
(await db.select().from(issues).where(eq(issues.id, f.issueId)))[0]
|
||||
.status,
|
||||
).toBe("in_progress");
|
||||
expect(
|
||||
(
|
||||
await db
|
||||
.select()
|
||||
.from(budgetReservations)
|
||||
.where(eq(budgetReservations.fastResponseRequestId, row.id))
|
||||
)[0].state,
|
||||
).toBe("settled");
|
||||
});
|
||||
it.each([
|
||||
"reply",
|
||||
"cancel",
|
||||
"delete",
|
||||
"reassign",
|
||||
"edit",
|
||||
"revoke",
|
||||
"reset",
|
||||
"stream",
|
||||
"board_stream",
|
||||
])(
|
||||
"suppresses output after %s but retains its actual cost",
|
||||
async (change) => {
|
||||
const f = await fixture(),
|
||||
row = await f.enqueue();
|
||||
if (change === "reset") {
|
||||
await db
|
||||
.update(fastResponseRequests)
|
||||
.set({ sessionGeneration: 0 })
|
||||
.where(eq(fastResponseRequests.id, row.id));
|
||||
row.sessionGeneration = 0;
|
||||
}
|
||||
f.provider.mockImplementationOnce(async () => {
|
||||
if (change === "reset")
|
||||
await db
|
||||
.update(issues)
|
||||
.set({ conversationSessionGeneration: 1 })
|
||||
.where(eq(issues.id, f.issueId));
|
||||
if (change === "board_stream")
|
||||
await supersedeFastResponse(db, f.companyId, f.comment.id);
|
||||
if (change === "stream") {
|
||||
const [run] = await db
|
||||
.insert(heartbeatRuns)
|
||||
.values({
|
||||
companyId: f.companyId,
|
||||
agentId: f.agentId,
|
||||
status: "running",
|
||||
invocationSource: "on_demand",
|
||||
contextSnapshot: {
|
||||
issueId: f.issueId,
|
||||
wakeCommentId: f.comment.id,
|
||||
},
|
||||
})
|
||||
.returning();
|
||||
await db.insert(heartbeatRunEvents).values({
|
||||
companyId: f.companyId,
|
||||
agentId: f.agentId,
|
||||
runId: run.id,
|
||||
seq: 1,
|
||||
eventType: "item.delta",
|
||||
payload: {
|
||||
prpEvent: {
|
||||
payload: {
|
||||
kind: "agentMessage",
|
||||
text: "Here is the recommendation",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
if (change === "reply")
|
||||
await db.insert(issueComments).values({
|
||||
companyId: f.companyId,
|
||||
issueId: f.issueId,
|
||||
authorAgentId: f.agentId,
|
||||
body: "I fixed the border.",
|
||||
});
|
||||
if (change === "cancel")
|
||||
await db
|
||||
.update(issues)
|
||||
.set({ status: "cancelled" })
|
||||
.where(eq(issues.id, f.issueId));
|
||||
if (change === "delete")
|
||||
await db.delete(issues).where(eq(issues.id, f.issueId));
|
||||
if (change === "reassign")
|
||||
await db
|
||||
.update(issues)
|
||||
.set({ assigneeAgentId: null })
|
||||
.where(eq(issues.id, f.issueId));
|
||||
if (change === "edit")
|
||||
await db
|
||||
.update(issueComments)
|
||||
.set({ body: "Never mind", updatedAt: new Date(Date.now() + 100) })
|
||||
.where(eq(issueComments.id, f.comment.id));
|
||||
if (change === "revoke")
|
||||
await db
|
||||
.update(connectionGrants)
|
||||
.set({ status: "revoked" })
|
||||
.where(eq(connectionGrants.id, f.binding.grantId));
|
||||
return structuredClone(outcome);
|
||||
});
|
||||
await f.service.process(row);
|
||||
const [result] = await db
|
||||
.select()
|
||||
.from(fastResponseRequests)
|
||||
.where(eq(fastResponseRequests.id, row.id));
|
||||
expect(result).toMatchObject({
|
||||
status: "succeeded",
|
||||
publicationStatus: "suppressed",
|
||||
commentId: null,
|
||||
});
|
||||
expect(
|
||||
await db
|
||||
.select()
|
||||
.from(costEvents)
|
||||
.where(eq(costEvents.companyId, f.companyId)),
|
||||
).toHaveLength(1);
|
||||
},
|
||||
);
|
||||
it("does not call a provider for expired or denied linked-user turns", async () => {
|
||||
const f = await fixture(),
|
||||
row = await f.enqueue();
|
||||
await db.insert(connectionGrantMembers).values({
|
||||
companyId: f.companyId,
|
||||
grantId: f.binding.grantId,
|
||||
subjectType: "user",
|
||||
subjectId: "bob",
|
||||
});
|
||||
expect(await f.service.process(row)).toMatchObject({
|
||||
status: "unavailable",
|
||||
});
|
||||
expect(f.provider).not.toHaveBeenCalled();
|
||||
const g = await fixture(),
|
||||
expired = await g.enqueue();
|
||||
await g.service.process({ ...expired, expiresAt: new Date(0) });
|
||||
expect(g.provider).not.toHaveBeenCalled();
|
||||
});
|
||||
it("never regenerates interrupted attempts and keeps unknown charges reserved", async () => {
|
||||
const f = await fixture(),
|
||||
row = await f.enqueue();
|
||||
f.provider.mockResolvedValueOnce({
|
||||
errorCode: "timeout",
|
||||
receipt: fastResponseReceipt(),
|
||||
});
|
||||
await f.service.process(row);
|
||||
await f.service.sweepPending();
|
||||
expect(f.provider).toHaveBeenCalledTimes(1);
|
||||
expect(
|
||||
(
|
||||
await db
|
||||
.select()
|
||||
.from(fastResponseRequests)
|
||||
.where(eq(fastResponseRequests.id, row.id))
|
||||
)[0].status,
|
||||
).toBe("unknown");
|
||||
expect(
|
||||
(
|
||||
await db
|
||||
.select()
|
||||
.from(budgetReservations)
|
||||
.where(eq(budgetReservations.fastResponseRequestId, row.id))
|
||||
)[0].state,
|
||||
).toBe("held");
|
||||
});
|
||||
it("sponsors accepted unlinked senders using only destination-authorized context", async () => {
|
||||
const f = await fixture();
|
||||
await db
|
||||
.update(issues)
|
||||
.set({ title: "PRIVATE INTERNAL TITLE" })
|
||||
.where(eq(issues.id, f.issueId));
|
||||
const external = {
|
||||
...f.source,
|
||||
responsibleUserId: null,
|
||||
sponsored: true,
|
||||
endpointId: randomUUID(),
|
||||
};
|
||||
await db.transaction((tx) =>
|
||||
enqueueFastResponse(tx as unknown as typeof db, external),
|
||||
);
|
||||
const [row] = await db
|
||||
.select()
|
||||
.from(fastResponseRequests)
|
||||
.where(eq(fastResponseRequests.companyId, f.companyId));
|
||||
const authorizeExternal = vi.fn().mockResolvedValue({
|
||||
agentName: "Alex",
|
||||
message: "Review this border",
|
||||
queued: true,
|
||||
});
|
||||
const service = fastResponseService(db, {
|
||||
provider: f.provider,
|
||||
authorizeExternal,
|
||||
});
|
||||
await service.process(row);
|
||||
expect(authorizeExternal).toHaveBeenCalled();
|
||||
expect(f.provider).toHaveBeenCalledTimes(1);
|
||||
const prompt = f.provider.mock.calls[0][0].prompt;
|
||||
expect(prompt).toContain("Review this border");
|
||||
expect(prompt).toContain('"state":"queued"');
|
||||
expect(prompt).not.toContain("PRIVATE INTERNAL TITLE");
|
||||
expect(
|
||||
(
|
||||
await db
|
||||
.select()
|
||||
.from(costEvents)
|
||||
.where(eq(costEvents.companyId, f.companyId))
|
||||
)[0],
|
||||
).toMatchObject({ responsibleUserId: null, usageKind: "fast_response" });
|
||||
});
|
||||
it("never treats a denied linked user as company sponsored", async () => {
|
||||
const f = await fixture();
|
||||
await db.transaction((tx) =>
|
||||
enqueueFastResponse(tx as unknown as typeof db, {
|
||||
...f.source,
|
||||
sponsored: true,
|
||||
endpointId: randomUUID(),
|
||||
}),
|
||||
);
|
||||
const [row] = await db
|
||||
.select()
|
||||
.from(fastResponseRequests)
|
||||
.where(eq(fastResponseRequests.companyId, f.companyId));
|
||||
await f.service.process(row);
|
||||
expect(f.provider).not.toHaveBeenCalled();
|
||||
});
|
||||
it("expires unstarted backlog without an inference attempt", async () => {
|
||||
const f = await fixture(),
|
||||
row = await f.enqueue();
|
||||
await db
|
||||
.update(fastResponseRequests)
|
||||
.set({ expiresAt: new Date(0) })
|
||||
.where(eq(fastResponseRequests.id, row.id));
|
||||
await f.service.sweepPending();
|
||||
expect(f.provider).not.toHaveBeenCalled();
|
||||
expect(
|
||||
(
|
||||
await db
|
||||
.select()
|
||||
.from(fastResponseRequests)
|
||||
.where(eq(fastResponseRequests.id, row.id))
|
||||
)[0],
|
||||
).toMatchObject({ status: "skipped", errorCode: "expired" });
|
||||
});
|
||||
it("honors the company reservation budget before dispatch", async () => {
|
||||
const f = await fixture();
|
||||
await db
|
||||
.insert(budgetPolicies)
|
||||
.values({
|
||||
companyId: f.companyId,
|
||||
scopeType: "company",
|
||||
scopeId: f.companyId,
|
||||
windowKind: "calendar_month_utc",
|
||||
amount: 1,
|
||||
reservationCents: "2",
|
||||
notifyEnabled: false,
|
||||
});
|
||||
const row = await f.enqueue();
|
||||
expect(await f.service.process(row)).toMatchObject({
|
||||
status: "unavailable",
|
||||
});
|
||||
expect(f.provider).not.toHaveBeenCalled();
|
||||
expect(
|
||||
await db
|
||||
.select()
|
||||
.from(costEvents)
|
||||
.where(eq(costEvents.companyId, f.companyId)),
|
||||
).toHaveLength(0);
|
||||
});
|
||||
it("releases the reservation after a known provider rejection", async () => {
|
||||
const f = await fixture(),
|
||||
row = await f.enqueue();
|
||||
f.provider.mockResolvedValueOnce({
|
||||
errorCode: "provider_auth_failed",
|
||||
noProviderWork: true,
|
||||
receipt: {
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
costCents: "0",
|
||||
costStatus: "estimated",
|
||||
providerRequestId: null,
|
||||
pricingProvenance: { source: "unknown" },
|
||||
},
|
||||
});
|
||||
await f.service.process(row);
|
||||
expect(
|
||||
(
|
||||
await db
|
||||
.select()
|
||||
.from(budgetReservations)
|
||||
.where(eq(budgetReservations.fastResponseRequestId, row.id))
|
||||
)[0].state,
|
||||
).toBe("released");
|
||||
expect(
|
||||
(
|
||||
await db
|
||||
.select()
|
||||
.from(costEvents)
|
||||
.where(eq(costEvents.companyId, f.companyId))
|
||||
)[0].costCents,
|
||||
).toBe(0);
|
||||
});
|
||||
it("tests the fixed sample without posting a conversation message", async () => {
|
||||
const f = await fixture();
|
||||
const result = await f.service.test(f.companyId, "alice");
|
||||
expect(result.status).toBe("succeeded");
|
||||
expect(f.provider.mock.calls[0][0].prompt).toContain("settings panel");
|
||||
expect(
|
||||
await db
|
||||
.select()
|
||||
.from(issueComments)
|
||||
.where(eq(issueComments.companyId, f.companyId)),
|
||||
).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@@ -2,6 +2,7 @@ import { randomUUID } from "node:crypto";
|
||||
import { createServer } from "node:http";
|
||||
import { and, asc, eq, sql } from "drizzle-orm";
|
||||
import { WebSocketServer } from "ws";
|
||||
import { FAST_RESPONSE_AGENT_GUIDANCE } from "@paperclipai/shared";
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
|
||||
import {
|
||||
agents,
|
||||
@@ -2308,7 +2309,7 @@ describeEmbeddedPostgres("heartbeat comment wake batching", () => {
|
||||
wakeCommentId: sourceComment.id,
|
||||
wakeCommentIds: [sourceComment.id],
|
||||
paperclipExternalChatExecutionBound: true,
|
||||
paperclipTaskCommunicationGuidance: "## Communication in Slack\nFrozen connection preference.",
|
||||
paperclipTaskCommunicationGuidance: `## Communication in Slack\nFrozen connection preference.\n\n${FAST_RESPONSE_AGENT_GUIDANCE}`,
|
||||
paperclipExternalChatQuestionResponse: expect.objectContaining({
|
||||
schema: "paperclip.external_chat_question_response.v1",
|
||||
interactionId: answered.id,
|
||||
|
||||
@@ -61,6 +61,7 @@ describeEmbeddedPostgres("heartbeat plugin environments", () => {
|
||||
let stopDb: (() => Promise<void>) | null = null;
|
||||
let db!: ReturnType<typeof createDb>;
|
||||
const tempRoots: string[] = [];
|
||||
const heartbeats: ReturnType<typeof heartbeatService>[] = [];
|
||||
|
||||
async function grantResponsibleUser(companyId: string, userId: string) {
|
||||
await db.insert(authUsers).values({
|
||||
@@ -74,11 +75,12 @@ describeEmbeddedPostgres("heartbeat plugin environments", () => {
|
||||
|
||||
beforeAll(async () => {
|
||||
const started = await startEmbeddedPostgresTestDatabase("heartbeat-plugin-environment");
|
||||
stopDb = started.stop;
|
||||
stopDb = started.cleanup;
|
||||
db = createDb(started.connectionString);
|
||||
}, 20_000);
|
||||
|
||||
afterEach(async () => {
|
||||
for (const heartbeat of heartbeats.splice(0)) await heartbeat.drainActiveRunExecutions();
|
||||
adapterExecute.mockClear();
|
||||
while (tempRoots.length > 0) {
|
||||
const root = tempRoots.pop();
|
||||
@@ -208,6 +210,7 @@ describeEmbeddedPostgres("heartbeat plugin environments", () => {
|
||||
});
|
||||
|
||||
const heartbeat = heartbeatService(db, { pluginWorkerManager: workerManager });
|
||||
heartbeats.push(heartbeat);
|
||||
const run = await heartbeat.wakeup(agentId, {
|
||||
source: "on_demand",
|
||||
triggerDetail: "manual",
|
||||
@@ -447,6 +450,7 @@ describeEmbeddedPostgres("heartbeat plugin environments", () => {
|
||||
]);
|
||||
|
||||
const heartbeat = heartbeatService(db, { pluginWorkerManager: workerManager });
|
||||
heartbeats.push(heartbeat);
|
||||
const sharedRun = await heartbeat.wakeup(agentAId, {
|
||||
source: "on_demand",
|
||||
triggerDetail: "manual",
|
||||
@@ -673,6 +677,7 @@ describeEmbeddedPostgres("heartbeat plugin environments", () => {
|
||||
});
|
||||
|
||||
const heartbeat = heartbeatService(db, { pluginWorkerManager: workerManager });
|
||||
heartbeats.push(heartbeat);
|
||||
const run = await heartbeat.wakeup(agentId, {
|
||||
source: "assignment",
|
||||
triggerDetail: "manual",
|
||||
|
||||
@@ -158,6 +158,8 @@ describeEmbeddedPostgres("shared-workspace run serialization", () => {
|
||||
failExecutionRunIds.clear();
|
||||
captureRunFailure.mockClear();
|
||||
await instanceSettingsService(db).updateGeneral({ executionMode: "any" });
|
||||
await instanceSettingsService(db).updateExperimental({ enableManagedSandboxOnly: false });
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
@@ -500,6 +502,90 @@ describeEmbeddedPostgres("shared-workspace run serialization", () => {
|
||||
expect(executedRunIds).not.toContain(run!.id);
|
||||
});
|
||||
|
||||
it.each(["any", "invalid"])("refuses local fallback when Kubernetes is required but bootstrap mode is %s", async (mode) => {
|
||||
const fixture = await seedWorkspaceFixture({ agentEnvironmentDriver: "local" });
|
||||
vi.stubEnv("PAPERCLIP_EXECUTION_MODE", mode);
|
||||
await instanceSettingsService(db).updateGeneral({ executionMode: "kubernetes" });
|
||||
|
||||
const run = await heartbeat.invoke(fixture.agentId, "assignment", { issueId: fixture.issueId }, "system");
|
||||
expect(run).not.toBeNull();
|
||||
await heartbeat.drainActiveRunExecutions();
|
||||
|
||||
const finished = await heartbeat.getRun(run!.id);
|
||||
expect(finished?.status).toBe("failed");
|
||||
expect(finished?.error).toContain("no managed Kubernetes environment is configured");
|
||||
expect(executedRunIds).not.toContain(run!.id);
|
||||
expect(await db.select().from(environmentLeases).where(eq(environmentLeases.heartbeatRunId, run!.id))).toHaveLength(0);
|
||||
expect((await heartbeat.getRun(fixture.holderRunId))?.status).toBe("running");
|
||||
});
|
||||
|
||||
it("lazily provisions the required Kubernetes environment before applying workspace contention", async () => {
|
||||
const fixture = await seedWorkspaceFixture({ agentEnvironmentDriver: "local" });
|
||||
for (const key of Object.keys(process.env).filter(key => key.startsWith("PAPERCLIP_K8S_"))) {
|
||||
vi.stubEnv(key, undefined);
|
||||
}
|
||||
vi.stubEnv("PAPERCLIP_EXECUTION_MODE", "kubernetes");
|
||||
vi.stubEnv("PAPERCLIP_K8S_IN_CLUSTER", "true");
|
||||
vi.stubEnv("PAPERCLIP_K8S_BACKEND", "job");
|
||||
await instanceSettingsService(db).updateGeneral({ executionMode: "kubernetes" });
|
||||
|
||||
const run = await heartbeat.invoke(fixture.agentId, "assignment", { issueId: fixture.issueId }, "system");
|
||||
expect(run).not.toBeNull();
|
||||
await heartbeat.drainActiveRunExecutions();
|
||||
|
||||
const configured = await db.select().from(environments).where(eq(environments.driver, "sandbox"));
|
||||
expect(configured).toMatchObject([{
|
||||
status: "active",
|
||||
config: { provider: "kubernetes", inCluster: true, backend: "job" },
|
||||
metadata: { managedKubernetesSandbox: true },
|
||||
}]);
|
||||
expect((await heartbeat.getRun(run!.id))?.errorCode).toBe(WORKSPACE_BUSY_ERROR_CODE);
|
||||
expect(executedRunIds).not.toContain(run!.id);
|
||||
expect(await db.select().from(environmentLeases).where(eq(environmentLeases.heartbeatRunId, run!.id))).toHaveLength(0);
|
||||
expect(await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.retryOfRunId, run!.id))).toHaveLength(1);
|
||||
});
|
||||
|
||||
it.each(["missing", "archived"])("refuses local execution when the managed sandbox is %s", async (state) => {
|
||||
const fixture = await seedWorkspaceFixture({ agentEnvironmentDriver: "local" });
|
||||
if (state === "archived") {
|
||||
await db.insert(environments).values({
|
||||
name: "Unavailable managed sandbox", driver: "sandbox", status: "archived",
|
||||
config: { provider: "fake" }, metadata: { managedByPaperclip: true },
|
||||
});
|
||||
}
|
||||
await instanceSettingsService(db).updateExperimental({ enableManagedSandboxOnly: true });
|
||||
|
||||
const run = await heartbeat.invoke(fixture.agentId, "assignment", { issueId: fixture.issueId }, "system");
|
||||
expect(run).not.toBeNull();
|
||||
await heartbeat.drainActiveRunExecutions();
|
||||
|
||||
const finished = await heartbeat.getRun(run!.id);
|
||||
expect(finished?.status).toBe("failed");
|
||||
expect(finished?.error).toContain("no active managed sandbox environment exists");
|
||||
expect(executedRunIds).not.toContain(run!.id);
|
||||
expect(await db.select().from(environmentLeases).where(eq(environmentLeases.heartbeatRunId, run!.id))).toHaveLength(0);
|
||||
expect((await heartbeat.getRun(fixture.holderRunId))?.status).toBe("running");
|
||||
});
|
||||
|
||||
it("redirects a local selection to the managed sandbox before applying workspace contention", async () => {
|
||||
const fixture = await seedWorkspaceFixture({ agentEnvironmentDriver: "local" });
|
||||
await db.insert(environments).values({
|
||||
name: "Managed sandbox", driver: "sandbox", status: "active",
|
||||
config: { provider: "fake" }, metadata: { managedByPaperclip: true },
|
||||
});
|
||||
await instanceSettingsService(db).updateExperimental({ enableManagedSandboxOnly: true });
|
||||
|
||||
const run = await heartbeat.invoke(fixture.agentId, "assignment", { issueId: fixture.issueId }, "system");
|
||||
expect(run).not.toBeNull();
|
||||
await heartbeat.drainActiveRunExecutions();
|
||||
|
||||
expect((await heartbeat.getRun(run!.id))?.errorCode).toBe(WORKSPACE_BUSY_ERROR_CODE);
|
||||
expect(executedRunIds).not.toContain(run!.id);
|
||||
expect(await db.select().from(environmentLeases).where(eq(environmentLeases.heartbeatRunId, run!.id))).toHaveLength(0);
|
||||
expect(await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.retryOfRunId, run!.id))).toHaveLength(1);
|
||||
expect((await heartbeat.getRun(fixture.holderRunId))?.status).toBe("running");
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ driver: "local", policySource: "issue" },
|
||||
{ driver: "local", policySource: "project" },
|
||||
|
||||
@@ -5,7 +5,8 @@ import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest
|
||||
import {
|
||||
agentInstructionWorkingCopies, agents, agentApiKeys, agentWakeupRequests, companies, companySecretProposals, createDb,
|
||||
adapterAuthSessions, environments, environmentLeases, executionWorkspaces,
|
||||
heartbeatRuns, issues, issueWatchdogs, projects, routines, type Db,
|
||||
heartbeatRuns, issues, issueWatchdogs, issueThreadInteractions, issueQuestionResponseDeliveries,
|
||||
projects, routines, type Db,
|
||||
} from "@paperclipai/db";
|
||||
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
|
||||
import { readIdleSleepSafety, type IdleSleepDrainStatus } from "../services/idle-sleep-safety.js";
|
||||
@@ -184,6 +185,152 @@ if (!support.supported) console.warn(`Skipping idle sleep Postgres tests: ${supp
|
||||
return { companyId, agentId, runId };
|
||||
}
|
||||
|
||||
async function seedQuestionDelivery(change: Partial<typeof issueQuestionResponseDeliveries.$inferInsert> = {}) {
|
||||
const seeded = await seed();
|
||||
await db.update(heartbeatRuns).set({ finishedAt: new Date(now) }).where(eq(heartbeatRuns.id, seeded.runId));
|
||||
const issueId = randomUUID();
|
||||
const interactionId = randomUUID();
|
||||
await db.insert(issues).values({ id: issueId, companyId: seeded.companyId, title: "Finished question", status: "done" });
|
||||
await db.insert(issueThreadInteractions).values({
|
||||
id: interactionId, companyId: seeded.companyId, issueId,
|
||||
kind: "ask_user_questions", status: "answered", payload: {},
|
||||
});
|
||||
await db.insert(issueQuestionResponseDeliveries).values({
|
||||
companyId: seeded.companyId, issueId, interactionId, sourceRunId: seeded.runId,
|
||||
targetRunId: seeded.runId, correlationId: randomUUID(), payloadSha256: "fixture",
|
||||
status: "fallback_queued", deliveryMode: "wake_fallback", acknowledgedAt: new Date(now), ...change,
|
||||
});
|
||||
return { ...seeded, issueId, interactionId };
|
||||
}
|
||||
|
||||
async function seedQuestionWake(
|
||||
delivery: Awaited<ReturnType<typeof seedQuestionDelivery>>,
|
||||
change: Partial<typeof agentWakeupRequests.$inferInsert> = {},
|
||||
) {
|
||||
await db.insert(agentWakeupRequests).values({
|
||||
companyId: delivery.companyId, agentId: delivery.agentId, source: "automation",
|
||||
idempotencyKey: `question-response:${delivery.interactionId}`,
|
||||
status: "completed", finishedAt: new Date(now), ...change,
|
||||
});
|
||||
}
|
||||
|
||||
it.each(["pending", "delivering"])("keeps a %s question delivery awake", async status => {
|
||||
await seedQuestionDelivery({ status, acknowledgedAt: null });
|
||||
expect(await read()).toEqual(present);
|
||||
});
|
||||
|
||||
it.each(["delivered", "fallback_queued"])("permits acknowledged %s history after its target run finishes", async status => {
|
||||
await seedQuestionDelivery({ status });
|
||||
expect(await read()).toEqual(none);
|
||||
});
|
||||
|
||||
it.each(["delivered", "fallback_queued"])("keeps an unacknowledged %s question delivery awake", async status => {
|
||||
await seedQuestionDelivery({ status, acknowledgedAt: null });
|
||||
expect(await read()).toEqual(present);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["queued", { status: "queued", finishedAt: null }],
|
||||
["running", { status: "running", finishedAt: null }],
|
||||
["unfinished", { finishedAt: null }],
|
||||
["retry", { scheduledRetryAt: new Date(now + 1000) }],
|
||||
["accounting", { costAccountingPending: true }],
|
||||
] as const)("keeps an acknowledged question delivery awake with %s target evidence", async (_name, change) => {
|
||||
const { runId } = await seedQuestionDelivery();
|
||||
await db.update(heartbeatRuns).set(change).where(eq(heartbeatRuns.id, runId));
|
||||
expect(await read()).toEqual(present);
|
||||
});
|
||||
|
||||
it("does not accept a foreign company's finished question target", async () => {
|
||||
const foreign = await seedQuestionDelivery({ status: "failed", targetRunId: null });
|
||||
await seedQuestionDelivery({ targetRunId: foreign.runId });
|
||||
expect(await read()).toEqual(present);
|
||||
});
|
||||
|
||||
it("keeps delivered history with a missing target awake", async () => {
|
||||
await seedQuestionDelivery({ status: "delivered", targetRunId: null });
|
||||
expect(await read()).toEqual(present);
|
||||
});
|
||||
|
||||
it("keeps fallback history without a durable wake receipt awake", async () => {
|
||||
await seedQuestionDelivery({ targetRunId: null });
|
||||
expect(await read()).toEqual(present);
|
||||
});
|
||||
|
||||
it.each(["completed", "failed", "cancelled", "skipped", "timed_out"])(
|
||||
"permits fallback history after its exact durable wake is %s and finished", async status => {
|
||||
const delivery = await seedQuestionDelivery({ targetRunId: null });
|
||||
await seedQuestionWake(delivery, { status });
|
||||
expect(await read()).toEqual(none);
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["queued", "deferred_issue_execution", "coalesced"])("keeps an unresolved %s question wake awake", async status => {
|
||||
const delivery = await seedQuestionDelivery({ targetRunId: null });
|
||||
await seedQuestionWake(delivery, { status, finishedAt: null });
|
||||
expect(await read()).toEqual(present);
|
||||
});
|
||||
|
||||
it("keeps a terminal question wake without completion evidence awake", async () => {
|
||||
const delivery = await seedQuestionDelivery({ targetRunId: null });
|
||||
await seedQuestionWake(delivery, { finishedAt: null });
|
||||
expect(await read()).toEqual(present);
|
||||
});
|
||||
|
||||
it("permits a deferred question fallback after its coalesced continuation finishes", async () => {
|
||||
const delivery = await seedQuestionDelivery({ targetRunId: null });
|
||||
await seedQuestionWake(delivery, { status: "deferred_issue_execution", finishedAt: null });
|
||||
expect(await read()).toEqual(present);
|
||||
await db.update(agentWakeupRequests).set({
|
||||
status: "coalesced", runId: delivery.runId, finishedAt: new Date(now),
|
||||
}).where(eq(agentWakeupRequests.companyId, delivery.companyId));
|
||||
expect(await read()).toEqual(none);
|
||||
});
|
||||
|
||||
it.each(["missing_run", "wrong_company", "wrong_agent", "unfinished", "retry", "accounting"])(
|
||||
"keeps a coalesced question fallback awake with %s linked-run evidence", async kind => {
|
||||
const delivery = await seedQuestionDelivery({ targetRunId: null });
|
||||
let runId = delivery.runId;
|
||||
if (kind === "missing_run") runId = randomUUID();
|
||||
if (kind === "wrong_company") runId = (await seedQuestionDelivery({ status: "failed", targetRunId: null })).runId;
|
||||
if (kind === "wrong_agent") {
|
||||
const otherAgent = randomUUID();
|
||||
await db.insert(agents).values({ id: otherAgent, companyId: delivery.companyId, name: "Other", role: "engineer", status: "idle", adapterType: "process" });
|
||||
await db.update(heartbeatRuns).set({ agentId: otherAgent }).where(eq(heartbeatRuns.id, runId));
|
||||
}
|
||||
if (kind === "unfinished") await db.update(heartbeatRuns).set({ finishedAt: null }).where(eq(heartbeatRuns.id, runId));
|
||||
if (kind === "retry") await db.update(heartbeatRuns).set({ scheduledRetryAt: new Date(now + 1000) }).where(eq(heartbeatRuns.id, runId));
|
||||
if (kind === "accounting") await db.update(heartbeatRuns).set({ costAccountingPending: true }).where(eq(heartbeatRuns.id, runId));
|
||||
await seedQuestionWake(delivery, { status: "coalesced", runId });
|
||||
expect(await read()).toEqual(present);
|
||||
},
|
||||
);
|
||||
|
||||
it("does not accept another interaction's or company's question wake receipt", async () => {
|
||||
const delivery = await seedQuestionDelivery({ targetRunId: null });
|
||||
await seedQuestionWake(delivery, { idempotencyKey: `question-response:${randomUUID()}` });
|
||||
const foreign = await seedQuestionDelivery({ status: "failed", targetRunId: null });
|
||||
await seedQuestionWake(delivery, { companyId: foreign.companyId, agentId: foreign.agentId });
|
||||
expect(await read()).toEqual(present);
|
||||
});
|
||||
|
||||
it("permits failed question-delivery history without a pending target", async () => {
|
||||
await seedQuestionDelivery({ status: "failed", targetRunId: null, acknowledgedAt: null });
|
||||
expect(await read()).toEqual(none);
|
||||
});
|
||||
|
||||
it("still blocks other work after a question handoff finishes", async () => {
|
||||
const { issueId } = await seedQuestionDelivery();
|
||||
await db.update(issues).set({ status: "todo" }).where(eq(issues.id, issueId));
|
||||
expect(await read()).toEqual(present);
|
||||
});
|
||||
|
||||
it("requires a current quiet owner even with completed question history", async () => {
|
||||
await seedQuestionDelivery();
|
||||
expect(await readIdleSleepSafety(db, owned, () => now, "stale-owner", emptyLocal)).toEqual(unknown);
|
||||
expect(await readIdleSleepSafety(db, owned, () => now + 60_001, ownerId, emptyLocal)).toEqual(unknown);
|
||||
});
|
||||
|
||||
it("keeps saved agent files awake until deferred cleanup completes", async () => {
|
||||
const { companyId, agentId, runId } = await seed();
|
||||
await db.insert(agentInstructionWorkingCopies).values({
|
||||
|
||||
@@ -77,6 +77,7 @@ describe("instance settings service", () => {
|
||||
enableSummaries: false,
|
||||
enableStatusCards: false,
|
||||
enableDecisions: false,
|
||||
enableFastResponses: false,
|
||||
enableGoalsSidebarLink: true,
|
||||
enableServerInfoDebugView: true,
|
||||
enablePaperclipDeveloperMode: true,
|
||||
|
||||
@@ -117,6 +117,10 @@ vi.mock("../services/index.js", () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../services/fast-responses.js", () => ({
|
||||
enqueueFastResponse: vi.fn(async () => undefined),
|
||||
}));
|
||||
|
||||
async function createApp() {
|
||||
const [{ issueRoutes }, { errorHandler }] = await Promise.all([
|
||||
vi.importActual<typeof import("../routes/issues.js")>("../routes/issues.js"),
|
||||
@@ -134,7 +138,7 @@ async function createApp() {
|
||||
};
|
||||
next();
|
||||
});
|
||||
app.use("/api", issueRoutes({} as any, {} as any));
|
||||
app.use("/api", issueRoutes({ transaction: async (effect: (tx: unknown) => unknown) => effect({}) } as any, {} as any));
|
||||
app.use(errorHandler);
|
||||
return app;
|
||||
}
|
||||
@@ -232,6 +236,7 @@ describe("assigned backlog creation contract", () => {
|
||||
assigneeAgentId,
|
||||
status: "todo",
|
||||
}),
|
||||
expect.anything(),
|
||||
);
|
||||
expect(res.body).toEqual(expect.objectContaining({
|
||||
assigneeAgentId,
|
||||
|
||||
@@ -57,6 +57,9 @@ const mockRunnerGoalService = vi.hoisted(() => ({
|
||||
const mockLogActivity = vi.hoisted(() => vi.fn(async () => undefined));
|
||||
|
||||
function registerServiceMocks() {
|
||||
vi.doMock("../services/fast-responses.js", () => ({
|
||||
enqueueFastResponse: vi.fn(async () => undefined),
|
||||
}));
|
||||
vi.doMock("../routes/authz.js", async () => vi.importActual("../routes/authz.js"));
|
||||
|
||||
vi.doMock("@paperclipai/shared/telemetry", () => ({
|
||||
@@ -233,7 +236,7 @@ describe("closed isolated workspace issue routes", () => {
|
||||
};
|
||||
next();
|
||||
});
|
||||
app.use("/api", issueRoutes({} as any, {} as any));
|
||||
app.use("/api", issueRoutes({ transaction: async (effect: (tx: unknown) => unknown) => effect({}) } as any, {} as any));
|
||||
app.use(errorHandler);
|
||||
return app;
|
||||
}
|
||||
|
||||
@@ -3,6 +3,10 @@ import request from "supertest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { HttpError } from "../errors.js";
|
||||
|
||||
vi.mock("../services/fast-responses.js", () => ({
|
||||
enqueueFastResponse: vi.fn(async () => undefined),
|
||||
}));
|
||||
|
||||
const mockIssueService = vi.hoisted(() => ({
|
||||
getById: vi.fn(),
|
||||
getByIdForUpdate: vi.fn(),
|
||||
@@ -1435,7 +1439,7 @@ describe("issue comment reopen routes", () => {
|
||||
metadata,
|
||||
sourceTrust: null,
|
||||
},
|
||||
mockDb,
|
||||
mockTx,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -3341,7 +3345,8 @@ describe("issue comment reopen routes", () => {
|
||||
.send({ body: reviewBody });
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
expect(mockDb.transaction).not.toHaveBeenCalled();
|
||||
// The human comment and optional receipt commit together; no approval update occurs.
|
||||
expect(mockDb.transaction).toHaveBeenCalledTimes(1);
|
||||
expect(mockIssueService.update).not.toHaveBeenCalled();
|
||||
expect(mockHeartbeatService.wakeup).not.toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
|
||||
@@ -2,6 +2,10 @@ import express from "express";
|
||||
import request from "supertest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("../services/fast-responses.js", () => ({
|
||||
enqueueFastResponse: vi.fn(async () => undefined),
|
||||
}));
|
||||
|
||||
const ASSIGNEE_AGENT_ID = "11111111-1111-4111-8111-111111111111";
|
||||
const PREVIOUS_AGENT_ID = "22222222-2222-4222-8222-222222222222";
|
||||
const MENTIONED_AGENT_ID = "33333333-3333-4333-8333-333333333333";
|
||||
|
||||
@@ -25,6 +25,7 @@ const apiPrefixes: Record<string, string> = {
|
||||
"announcements.ts": "/api",
|
||||
"ai-connections.ts": "/api",
|
||||
"decision-models.ts": "/api",
|
||||
"fast-responses.ts": "/api",
|
||||
"attention.ts": "/api",
|
||||
"approvals.ts": "/api",
|
||||
"assets.ts": "/api",
|
||||
|
||||
@@ -103,6 +103,36 @@ describe("server package build script", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("compiles the vendored Runner's TypeScript surface once per build", () => {
|
||||
// prepare:runner-vendor runs the Runner's full build, and `pnpm -r build`
|
||||
// has already run it once by the time the server builds. Each extra
|
||||
// build:typescript inside that chain (two tsc projects plus the eval-kernel
|
||||
// rebuild) costs about 7s on the CI Build job's critical path, twice.
|
||||
// Checks that read dist/ must call their script directly, the way
|
||||
// generate-replay-goldens.mjs already does, not through a `check:*`
|
||||
// wrapper that rebuilds first for standalone use.
|
||||
const runner = JSON.parse(readFileSync(new URL(
|
||||
"../../../packages/paperclip-runner/package.json", import.meta.url,
|
||||
), "utf8")) as { scripts: Record<string, string> };
|
||||
const steps = runner.scripts.build.split(" && ");
|
||||
const wrapped = steps
|
||||
.filter((step) => step.startsWith("pnpm run "))
|
||||
.map((step) => step.slice("pnpm run ".length));
|
||||
for (const name of wrapped) {
|
||||
expect(runner.scripts[name], `build step ${name}`).toBeDefined();
|
||||
}
|
||||
const compiles = (step: string) =>
|
||||
step === "build:typescript" || (runner.scripts[step] ?? "").includes("build:typescript");
|
||||
|
||||
expect(wrapped.filter(compiles)).toEqual(["build:typescript"]);
|
||||
expect(steps).toContain("node scripts/generate-semantic-contracts.mjs --check");
|
||||
expect(steps).toContain("node scripts/check-runner-workflow-traceability.mjs");
|
||||
expect(steps.indexOf("node scripts/generate-semantic-contracts.mjs --check"))
|
||||
.toBeGreaterThan(steps.indexOf("pnpm run build:typescript"));
|
||||
expect(steps.indexOf("node scripts/check-runner-workflow-traceability.mjs"))
|
||||
.toBeGreaterThan(steps.indexOf("pnpm run build:typescript"));
|
||||
});
|
||||
|
||||
it("ships the pinned OpenCode dependency resolved by the vendored Runner", () => {
|
||||
const server = JSON.parse(readFileSync(packageJsonPath, "utf8"));
|
||||
const runner = JSON.parse(readFileSync(new URL(
|
||||
|
||||
@@ -44,6 +44,7 @@ const {
|
||||
const completionSweepMock = vi.fn(async () => undefined);
|
||||
const createAppMock = vi.fn(async () => Object.assign((_: unknown, __: unknown) => {}, {
|
||||
locals: {
|
||||
fastResponses: { hasPending: vi.fn(async () => false), sweepPending: vi.fn(async () => undefined) },
|
||||
deliveryWork: {
|
||||
register: (_queue: string, task: { run: () => Promise<unknown> }) => ({
|
||||
ready: task.run().catch(() => undefined), wake: vi.fn(),
|
||||
|
||||
@@ -12952,6 +12952,91 @@ describeEmbeddedPostgres("tool access service", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it.each(["new", "saved"])("starts Sentry MCP sign-in for a %s connection without a manual OAuth app", async (state) => {
|
||||
const company = await createCompany(db);
|
||||
const service = createTestToolAccessService(db);
|
||||
const connected = await service.connectGalleryApp(company.id, {
|
||||
galleryKey: "sentry", connectionMethodKey: "mcp-oauth", name: "Sentry",
|
||||
});
|
||||
const redirectUri = "http://localhost:3200/api/tools/oauth/callback";
|
||||
const issuer = "https://mcp.sentry.dev";
|
||||
const resource = `${issuer}/mcp`;
|
||||
const scopes = ["org:read", "project:write", "team:write", "event:write", "alerts:write"];
|
||||
if (state === "saved") {
|
||||
const [connection] = await db.select().from(toolConnections)
|
||||
.where(eq(toolConnections.id, connected.connectionId));
|
||||
// An existing MCP registration must survive catalog changes. This is the
|
||||
// state before a reconnect, with endpoints cached by successful discovery.
|
||||
await db.update(toolConnections).set({ config: {
|
||||
...connection.config,
|
||||
oauth: {
|
||||
...connection.config.oauth as Record<string, unknown>,
|
||||
provider: "sentry", issuer, resource,
|
||||
authorizationUrl: `${issuer}/oauth/authorize`, tokenUrl: `${issuer}/oauth/token`,
|
||||
metadataUrl: `${issuer}/.well-known/oauth-authorization-server`,
|
||||
clientId: "saved-sentry-client", clientRegistrationSource: "dcr",
|
||||
clientIssuer: issuer, clientResource: resource,
|
||||
clientCompanyId: company.id, clientRedirectUri: redirectUri,
|
||||
clientTokenEndpointAuthMethod: "none",
|
||||
},
|
||||
} }).where(eq(toolConnections.id, connected.connectionId));
|
||||
}
|
||||
const calls: string[] = [];
|
||||
vi.spyOn(globalThis, "fetch").mockImplementation(async (url, init) => {
|
||||
const href = String(url);
|
||||
calls.push(href);
|
||||
// Both hosts publish valid OAuth metadata. The web/API issuer cannot
|
||||
// register an MCP client; discovery must select the resource's issuer.
|
||||
if (href === "https://sentry.io/.well-known/oauth-authorization-server") {
|
||||
return mcpHttpResponse({
|
||||
issuer: "https://sentry.io", authorization_endpoint: "https://sentry.io/oauth/authorize/",
|
||||
token_endpoint: "https://sentry.io/oauth/token/", code_challenge_methods_supported: ["S256"],
|
||||
});
|
||||
}
|
||||
if (href === `${issuer}/.well-known/oauth-protected-resource/mcp`) {
|
||||
return mcpHttpResponse({ resource, authorization_servers: [issuer], scopes_supported: scopes });
|
||||
}
|
||||
if (href === `${issuer}/.well-known/oauth-authorization-server`) {
|
||||
return mcpHttpResponse({
|
||||
issuer, authorization_endpoint: `${issuer}/oauth/authorize`, token_endpoint: `${issuer}/oauth/token`,
|
||||
registration_endpoint: `${issuer}/oauth/register`, scopes_supported: scopes,
|
||||
grant_types_supported: ["authorization_code", "refresh_token"],
|
||||
code_challenge_methods_supported: ["S256"],
|
||||
token_endpoint_auth_methods_supported: ["client_secret_basic", "client_secret_post", "none"],
|
||||
client_id_metadata_document_supported: true,
|
||||
});
|
||||
}
|
||||
if (href === `${issuer}/oauth/register`) {
|
||||
expect(JSON.parse(String(init?.body))).toMatchObject({
|
||||
redirect_uris: [redirectUri], token_endpoint_auth_method: "none",
|
||||
});
|
||||
return mcpHttpResponse({
|
||||
client_id: "new-sentry-client", redirect_uris: [redirectUri],
|
||||
grant_types: ["authorization_code", "refresh_token"], response_types: ["code"],
|
||||
token_endpoint_auth_method: "none",
|
||||
});
|
||||
}
|
||||
throw new Error(`unexpected fetch ${href}`);
|
||||
});
|
||||
const started = await service.startOAuth(company.id, connected.connectionId, {
|
||||
redirectUri, actor: { actorType: "user", actorId: "board" },
|
||||
});
|
||||
const authorization = new URL(started.authorizationUrl);
|
||||
expect(authorization.origin + authorization.pathname).toBe(`${issuer}/oauth/authorize`);
|
||||
expect(authorization.searchParams.get("resource")).toBe(resource);
|
||||
expect(authorization.searchParams.get("scope")?.split(" ")).toEqual(scopes);
|
||||
expect(authorization.searchParams.get("code_challenge_method")).toBe("S256");
|
||||
expect(authorization.searchParams.get("client_id")).toBe(`${state}-sentry-client`);
|
||||
expect(calls).not.toContain("https://sentry.io/.well-known/oauth-authorization-server");
|
||||
expect(calls.filter((url) => url === `${issuer}/oauth/register`)).toHaveLength(state === "new" ? 1 : 0);
|
||||
const [saved] = await db.select().from(toolConnections)
|
||||
.where(eq(toolConnections.id, connected.connectionId));
|
||||
expect(saved.config.oauth).toMatchObject({
|
||||
clientId: `${state}-sentry-client`, clientIssuer: issuer, clientResource: resource,
|
||||
clientCompanyId: company.id, clientRedirectUri: redirectUri,
|
||||
});
|
||||
});
|
||||
|
||||
it("uses Asana v2 metadata instead of stale v1 endpoints for a saved custom app", async () => {
|
||||
const company = await createCompany(db);
|
||||
const service = createTestToolAccessService(db);
|
||||
|
||||
Reference in new issue
Block a user