[codex] Add structured issue-thread interactions (#4244)

## Thinking Path

> - Paperclip orchestrates AI agents for zero-human companies.
> - Operators supervise that work through issues, comments, approvals,
and the board UI.
> - Some agent proposals need structured board/user decisions, not
hidden markdown conventions or heavyweight governed approvals.
> - Issue-thread interactions already provide a natural thread-native
surface for proposed tasks and questions.
> - This pull request extends that surface with request confirmations,
richer interaction cards, and agent/plugin/MCP helpers.
> - The benefit is that plan approvals and yes/no decisions become
explicit, auditable, and resumable without losing the single-issue
workflow.

## What Changed

- Added persisted issue-thread interactions for suggested tasks,
structured questions, and request confirmations.
- Added board UI cards for interaction review, selection, question
answers, and accept/reject confirmation flows.
- Added MCP and plugin SDK helpers for creating interaction cards from
agents/plugins.
- Updated agent wake instructions, onboarding assets, Paperclip skill
docs, and public docs to prefer structured confirmations for
issue-scoped decisions.
- Rebased the branch onto `public-gh/master` and renumbered branch
migrations to `0063` and `0064`; the idempotency migration uses `ADD
COLUMN IF NOT EXISTS` for old branch users.

## Verification

- `git diff --check public-gh/master..HEAD`
- `pnpm exec vitest run packages/adapter-utils/src/server-utils.test.ts
packages/mcp-server/src/tools.test.ts
packages/shared/src/issue-thread-interactions.test.ts
ui/src/lib/issue-thread-interactions.test.ts
ui/src/lib/issue-chat-messages.test.ts
ui/src/components/IssueThreadInteractionCard.test.tsx
ui/src/components/IssueChatThread.test.tsx
server/src/__tests__/issue-thread-interaction-routes.test.ts
server/src/__tests__/issue-thread-interactions-service.test.ts
server/src/services/issue-thread-interactions.test.ts` -> 9 files / 79
tests passed
- `pnpm -r typecheck` -> passed, including `packages/db` migration
numbering check

## Risks

- Medium: this adds a new issue-thread interaction model across
db/shared/server/ui/plugin surfaces.
- Migration risk is reduced by placing this branch after current master
migrations (`0063`, `0064`) and making the idempotency column add
idempotent for users who applied the old branch numbering.
- UI interaction behavior is covered by component tests, but this PR
does not include browser screenshots.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

- OpenAI Codex, GPT-5-class coding agent runtime. Exact model ID and
context window are not exposed in this Paperclip run; tool use and local
shell/code execution were enabled.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [ ] If this change affects the UI, I have included before/after
screenshots
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Dotta 2026-04-21 20:15:11 -05:00 committed by GitHub
parent 014aa0eb2d
commit a957394420
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
93 changed files with 10089 additions and 752 deletions

View file

@ -59,6 +59,30 @@ vi.mock("../adapters/plugin-loader.js", () => ({
reloadExternalAdapter: mocks.reloadExternalAdapter,
}));
function registerRouteMocks() {
vi.doMock("node:child_process", () => ({
execFile: mocks.execFile,
}));
vi.doMock("../services/adapter-plugin-store.js", () => ({
listAdapterPlugins: mocks.listAdapterPlugins,
addAdapterPlugin: mocks.addAdapterPlugin,
removeAdapterPlugin: mocks.removeAdapterPlugin,
getAdapterPluginByType: mocks.getAdapterPluginByType,
getAdapterPluginsDir: mocks.getAdapterPluginsDir,
getDisabledAdapterTypes: mocks.getDisabledAdapterTypes,
setAdapterDisabled: mocks.setAdapterDisabled,
}));
vi.doMock("../adapters/plugin-loader.js", () => ({
buildExternalAdapters: mocks.buildExternalAdapters,
loadExternalAdapterPackage: mocks.loadExternalAdapterPackage,
getUiParserSource: mocks.getUiParserSource,
getOrExtractUiParserSource: mocks.getOrExtractUiParserSource,
reloadExternalAdapter: mocks.reloadExternalAdapter,
}));
}
const EXTERNAL_ADAPTER_TYPE = "external_admin_test";
const EXTERNAL_PACKAGE_NAME = "paperclip-external-adapter";
let adapterRoutes: typeof import("../routes/adapters.js").adapterRoutes;
@ -167,20 +191,28 @@ function seedInstalledExternalAdapter() {
}
describe("adapter management route authorization", () => {
beforeAll(async () => {
beforeEach(async () => {
vi.resetModules();
vi.doUnmock("node:child_process");
vi.doUnmock("../services/adapter-plugin-store.js");
vi.doUnmock("../adapters/plugin-loader.js");
vi.doUnmock("../routes/adapters.js");
vi.doUnmock("../routes/authz.js");
vi.doUnmock("../middleware/index.js");
vi.doUnmock("../adapters/registry.js");
registerRouteMocks();
vi.doMock("../routes/authz.js", async () => vi.importActual("../routes/authz.js"));
const [routes, middleware, registry] = await Promise.all([
import("../routes/adapters.js"),
import("../middleware/index.js"),
import("../adapters/registry.js"),
vi.importActual<typeof import("../routes/adapters.js")>("../routes/adapters.js"),
vi.importActual<typeof import("../middleware/index.js")>("../middleware/index.js"),
vi.importActual<typeof import("../adapters/registry.js")>("../adapters/registry.js"),
]);
adapterRoutes = routes.adapterRoutes;
errorHandler = middleware.errorHandler;
registerServerAdapter = registry.registerServerAdapter;
unregisterServerAdapter = registry.unregisterServerAdapter;
setOverridePaused = registry.setOverridePaused;
}, 20_000);
beforeEach(() => {
vi.clearAllMocks();
mocks.externalRecords.clear();
@ -193,7 +225,7 @@ describe("adapter management route authorization", () => {
mocks.buildExternalAdapters.mockResolvedValue([]);
mocks.loadExternalAdapterPackage.mockResolvedValue(createAdapter());
mocks.reloadExternalAdapter.mockImplementation(async (type: string) => createAdapter(type));
});
}, 20_000);
afterEach(() => {
unregisterServerAdapter(EXTERNAL_ADAPTER_TYPE);

View file

@ -4,6 +4,24 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { vi } from "vitest";
import type { ServerAdapterModule } from "../adapters/index.js";
const mockAdapterPluginStore = vi.hoisted(() => ({
listAdapterPlugins: vi.fn(),
addAdapterPlugin: vi.fn(),
removeAdapterPlugin: vi.fn(),
getAdapterPluginByType: vi.fn(),
getAdapterPluginsDir: vi.fn(),
getDisabledAdapterTypes: vi.fn(),
setAdapterDisabled: vi.fn(),
}));
const mockPluginLoader = vi.hoisted(() => ({
buildExternalAdapters: vi.fn(),
loadExternalAdapterPackage: vi.fn(),
getUiParserSource: vi.fn(),
getOrExtractUiParserSource: vi.fn(),
reloadExternalAdapter: vi.fn(),
}));
const overridingConfigSchemaAdapter: ServerAdapterModule = {
type: "claude_local",
execute: async () => ({ exitCode: 0, signal: null, timedOut: false }),
@ -25,12 +43,21 @@ const overridingConfigSchemaAdapter: ServerAdapterModule = {
}),
};
let registerServerAdapter: typeof import("../adapters/index.js").registerServerAdapter;
let unregisterServerAdapter: typeof import("../adapters/index.js").unregisterServerAdapter;
let registerServerAdapter: typeof import("../adapters/registry.js").registerServerAdapter;
let unregisterServerAdapter: typeof import("../adapters/registry.js").unregisterServerAdapter;
let setOverridePaused: typeof import("../adapters/registry.js").setOverridePaused;
let adapterRoutes: typeof import("../routes/adapters.js").adapterRoutes;
let errorHandler: typeof import("../middleware/index.js").errorHandler;
function registerModuleMocks() {
vi.doMock("node:child_process", async () => vi.importActual("node:child_process"));
vi.doMock("../adapters/plugin-loader.js", () => mockPluginLoader);
vi.doMock("../services/adapter-plugin-store.js", () => mockAdapterPluginStore);
vi.doMock("../routes/adapters.js", async () => vi.importActual("../routes/adapters.js"));
vi.doMock("../routes/authz.js", async () => vi.importActual("../routes/authz.js"));
vi.doMock("../middleware/index.js", async () => vi.importActual("../middleware/index.js"));
}
function createApp(actorOverrides: Partial<Express.Request["actor"]> = {}) {
const app = express();
app.use(express.json());
@ -53,18 +80,33 @@ function createApp(actorOverrides: Partial<Express.Request["actor"]> = {}) {
describe("adapter routes", () => {
beforeEach(async () => {
vi.resetModules();
vi.doUnmock("../adapters/index.js");
vi.doUnmock("node:child_process");
vi.doUnmock("../adapters/registry.js");
vi.doUnmock("../adapters/plugin-loader.js");
vi.doUnmock("../services/adapter-plugin-store.js");
vi.doUnmock("../routes/adapters.js");
vi.doUnmock("../routes/authz.js");
vi.doUnmock("../middleware/index.js");
const [adapters, registry, routes, middleware] = await Promise.all([
vi.importActual<typeof import("../adapters/index.js")>("../adapters/index.js"),
registerModuleMocks();
mockAdapterPluginStore.listAdapterPlugins.mockReturnValue([]);
mockAdapterPluginStore.addAdapterPlugin.mockResolvedValue(undefined);
mockAdapterPluginStore.removeAdapterPlugin.mockReturnValue(false);
mockAdapterPluginStore.getAdapterPluginByType.mockReturnValue(undefined);
mockAdapterPluginStore.getAdapterPluginsDir.mockReturnValue("/tmp/paperclip-adapter-routes-test");
mockAdapterPluginStore.getDisabledAdapterTypes.mockReturnValue([]);
mockAdapterPluginStore.setAdapterDisabled.mockReturnValue(false);
mockPluginLoader.buildExternalAdapters.mockResolvedValue([]);
mockPluginLoader.loadExternalAdapterPackage.mockResolvedValue(null);
mockPluginLoader.getUiParserSource.mockResolvedValue(null);
mockPluginLoader.getOrExtractUiParserSource.mockResolvedValue(null);
mockPluginLoader.reloadExternalAdapter.mockResolvedValue(null);
const [registry, routes, middleware] = await Promise.all([
vi.importActual<typeof import("../adapters/registry.js")>("../adapters/registry.js"),
vi.importActual<typeof import("../routes/adapters.js")>("../routes/adapters.js"),
vi.importActual<typeof import("../middleware/index.js")>("../middleware/index.js"),
import("../routes/adapters.js"),
import("../middleware/index.js"),
]);
registerServerAdapter = adapters.registerServerAdapter;
unregisterServerAdapter = adapters.unregisterServerAdapter;
registerServerAdapter = registry.registerServerAdapter;
unregisterServerAdapter = registry.unregisterServerAdapter;
setOverridePaused = registry.setOverridePaused;
adapterRoutes = routes.adapterRoutes;
errorHandler = middleware.errorHandler;

View file

@ -18,7 +18,32 @@ const mockIssueService = vi.hoisted(() => ({
getByIdentifier: vi.fn(),
}));
const mockInstanceSettingsService = vi.hoisted(() => ({
get: vi.fn(),
getExperimental: vi.fn(),
getGeneral: vi.fn(),
listCompanyIds: vi.fn(),
}));
function registerModuleMocks() {
vi.doMock("../routes/authz.js", async () => vi.importActual("../routes/authz.js"));
vi.doMock("../services/agents.js", () => ({
agentService: () => mockAgentService,
}));
vi.doMock("../services/heartbeat.js", () => ({
heartbeatService: () => mockHeartbeatService,
}));
vi.doMock("../services/instance-settings.js", () => ({
instanceSettingsService: () => mockInstanceSettingsService,
}));
vi.doMock("../services/issues.js", () => ({
issueService: () => mockIssueService,
}));
vi.doMock("../services/index.js", () => ({
agentService: () => mockAgentService,
agentInstructionsService: () => ({}),
@ -69,7 +94,11 @@ async function createApp() {
describe("agent live run routes", () => {
beforeEach(() => {
vi.resetModules();
vi.doUnmock("../services/agents.js");
vi.doUnmock("../services/heartbeat.js");
vi.doUnmock("../services/index.js");
vi.doUnmock("../services/instance-settings.js");
vi.doUnmock("../services/issues.js");
vi.doUnmock("../adapters/index.js");
vi.doUnmock("../routes/agents.js");
vi.doUnmock("../routes/authz.js");
@ -90,6 +119,19 @@ describe("agent live run routes", () => {
name: "Builder",
adapterType: "codex_local",
});
mockInstanceSettingsService.get.mockResolvedValue({
id: "instance-settings-1",
general: {
censorUsernameInLogs: false,
feedbackDataSharingPreference: "prompt",
},
});
mockInstanceSettingsService.getExperimental.mockResolvedValue({});
mockInstanceSettingsService.getGeneral.mockResolvedValue({
censorUsernameInLogs: false,
feedbackDataSharingPreference: "prompt",
});
mockInstanceSettingsService.listCompanyIds.mockResolvedValue(["company-1"]);
mockHeartbeatService.getRunIssueSummary.mockResolvedValue({
id: "run-1",
status: "running",
@ -139,7 +181,7 @@ describe("agent live run routes", () => {
expect(res.body).not.toHaveProperty("resultJson");
expect(res.body).not.toHaveProperty("contextSnapshot");
expect(res.body).not.toHaveProperty("logRef");
});
}, 10_000);
it("ignores a stale execution run from another issue and falls back to the assignee's matching run", async () => {
mockHeartbeatService.getRunIssueSummary.mockResolvedValue({

View file

@ -35,6 +35,7 @@ const mockAgentService = vi.hoisted(() => ({
list: vi.fn(),
create: vi.fn(),
activatePendingApproval: vi.fn(),
update: vi.fn(),
updatePermissions: vi.fn(),
getChainOfCommand: vi.fn(),
resolveByReference: vi.fn(),
@ -91,7 +92,16 @@ const mockTrackAgentCreated = vi.hoisted(() => vi.fn());
const mockGetTelemetryClient = vi.hoisted(() => vi.fn());
const mockSyncInstructionsBundleConfigFromFilePath = vi.hoisted(() => vi.fn());
const mockInstanceSettingsService = vi.hoisted(() => ({
getGeneral: vi.fn(),
}));
function registerModuleMocks() {
vi.doMock("../routes/agents.js", async () => vi.importActual("../routes/agents.js"));
vi.doMock("../routes/authz.js", async () => vi.importActual("../routes/authz.js"));
vi.doMock("../adapters/index.js", async () => vi.importActual("../adapters/index.js"));
vi.doMock("../middleware/index.js", async () => vi.importActual("../middleware/index.js"));
vi.doMock("@paperclipai/shared/telemetry", () => ({
trackAgentCreated: mockTrackAgentCreated,
trackErrorHandlerCrash: vi.fn(),
@ -101,6 +111,59 @@ function registerModuleMocks() {
getTelemetryClient: mockGetTelemetryClient,
}));
vi.doMock("../services/agents.js", () => ({
agentService: () => mockAgentService,
}));
vi.doMock("../services/access.js", () => ({
accessService: () => mockAccessService,
}));
vi.doMock("../services/approvals.js", () => ({
approvalService: () => mockApprovalService,
}));
vi.doMock("../services/company-skills.js", () => ({
companySkillService: () => mockCompanySkillService,
}));
vi.doMock("../services/budgets.js", () => ({
budgetService: () => mockBudgetService,
}));
vi.doMock("../services/heartbeat.js", () => ({
heartbeatService: () => mockHeartbeatService,
}));
vi.doMock("../services/issue-approvals.js", () => ({
issueApprovalService: () => mockIssueApprovalService,
}));
vi.doMock("../services/issues.js", () => ({
issueService: () => mockIssueService,
}));
vi.doMock("../services/secrets.js", () => ({
secretService: () => mockSecretService,
}));
vi.doMock("../services/agent-instructions.js", () => ({
agentInstructionsService: () => mockAgentInstructionsService,
syncInstructionsBundleConfigFromFilePath: mockSyncInstructionsBundleConfigFromFilePath,
}));
vi.doMock("../services/workspace-operations.js", () => ({
workspaceOperationService: () => mockWorkspaceOperationService,
}));
vi.doMock("../services/activity-log.js", () => ({
logActivity: mockLogActivity,
}));
vi.doMock("../services/instance-settings.js", () => ({
instanceSettingsService: () => mockInstanceSettingsService,
}));
vi.doMock("../services/index.js", () => ({
agentService: () => mockAgentService,
agentInstructionsService: () => mockAgentInstructionsService,
@ -139,8 +202,8 @@ function createDbStub(options: { requireBoardApprovalForNewAgents?: boolean } =
async function createApp(actor: Record<string, unknown>, dbOptions: { requireBoardApprovalForNewAgents?: boolean } = {}) {
const [{ errorHandler }, { agentRoutes }] = await Promise.all([
vi.importActual<typeof import("../middleware/index.js")>("../middleware/index.js"),
vi.importActual<typeof import("../routes/agents.js")>("../routes/agents.js"),
import("../middleware/index.js"),
import("../routes/agents.js"),
]);
const app = express();
app.use(express.json());
@ -158,11 +221,59 @@ describe("agent permission routes", () => {
vi.resetModules();
vi.doUnmock("@paperclipai/shared/telemetry");
vi.doUnmock("../telemetry.js");
vi.doUnmock("../services/access.js");
vi.doUnmock("../services/activity-log.js");
vi.doUnmock("../services/agent-instructions.js");
vi.doUnmock("../services/agents.js");
vi.doUnmock("../services/approvals.js");
vi.doUnmock("../services/budgets.js");
vi.doUnmock("../services/company-skills.js");
vi.doUnmock("../services/heartbeat.js");
vi.doUnmock("../services/index.js");
vi.doUnmock("../services/instance-settings.js");
vi.doUnmock("../services/issue-approvals.js");
vi.doUnmock("../services/issues.js");
vi.doUnmock("../services/secrets.js");
vi.doUnmock("../services/workspace-operations.js");
vi.doUnmock("../adapters/index.js");
vi.doUnmock("../routes/agents.js");
vi.doUnmock("../routes/authz.js");
vi.doUnmock("../middleware/index.js");
registerModuleMocks();
vi.clearAllMocks();
vi.resetAllMocks();
mockAgentService.getById.mockReset();
mockAgentService.list.mockReset();
mockAgentService.create.mockReset();
mockAgentService.activatePendingApproval.mockReset();
mockAgentService.update.mockReset();
mockAgentService.updatePermissions.mockReset();
mockAgentService.getChainOfCommand.mockReset();
mockAgentService.resolveByReference.mockReset();
mockAccessService.canUser.mockReset();
mockAccessService.hasPermission.mockReset();
mockAccessService.getMembership.mockReset();
mockAccessService.ensureMembership.mockReset();
mockAccessService.listPrincipalGrants.mockReset();
mockAccessService.setPrincipalPermission.mockReset();
mockApprovalService.create.mockReset();
mockApprovalService.getById.mockReset();
mockBudgetService.upsertPolicy.mockReset();
mockHeartbeatService.listTaskSessions.mockReset();
mockHeartbeatService.resetRuntimeSession.mockReset();
mockHeartbeatService.getRun.mockReset();
mockHeartbeatService.cancelRun.mockReset();
mockIssueApprovalService.linkManyForApproval.mockReset();
mockIssueService.list.mockReset();
mockSecretService.normalizeAdapterConfigForPersistence.mockReset();
mockSecretService.resolveAdapterConfigForRuntime.mockReset();
mockAgentInstructionsService.materializeManagedBundle.mockReset();
mockCompanySkillService.listRuntimeSkillEntries.mockReset();
mockCompanySkillService.resolveRequestedSkillKeys.mockReset();
mockLogActivity.mockReset();
mockTrackAgentCreated.mockReset();
mockGetTelemetryClient.mockReset();
mockSyncInstructionsBundleConfigFromFilePath.mockReset();
mockInstanceSettingsService.getGeneral.mockReset();
mockSyncInstructionsBundleConfigFromFilePath.mockImplementation((_agent, config) => config);
mockGetTelemetryClient.mockReturnValue({ track: vi.fn() });
mockAgentService.getById.mockResolvedValue(baseAgent);
@ -170,8 +281,14 @@ describe("agent permission routes", () => {
mockAgentService.getChainOfCommand.mockResolvedValue([]);
mockAgentService.resolveByReference.mockResolvedValue({ ambiguous: false, agent: baseAgent });
mockAgentService.create.mockResolvedValue(baseAgent);
mockAgentService.activatePendingApproval.mockResolvedValue(baseAgent);
mockAgentService.activatePendingApproval.mockResolvedValue({
agent: baseAgent,
activated: false,
});
mockAgentService.update.mockResolvedValue(baseAgent);
mockAgentService.updatePermissions.mockResolvedValue(baseAgent);
mockAccessService.canUser.mockResolvedValue(true);
mockAccessService.hasPermission.mockResolvedValue(false);
mockAccessService.getMembership.mockResolvedValue({
id: "membership-1",
companyId,
@ -207,6 +324,9 @@ describe("agent permission routes", () => {
);
mockSecretService.normalizeAdapterConfigForPersistence.mockImplementation(async (_companyId, config) => config);
mockSecretService.resolveAdapterConfigForRuntime.mockImplementation(async (_companyId, config) => ({ config }));
mockInstanceSettingsService.getGeneral.mockResolvedValue({
censorUsernameInLogs: false,
});
mockLogActivity.mockResolvedValue(undefined);
});
@ -226,7 +346,7 @@ describe("agent permission routes", () => {
expect(res.status).toBe(200);
expect(res.body.adapterConfig).toEqual({});
expect(res.body.runtimeConfig).toEqual({});
});
}, 20_000);
it("redacts company agent list for authenticated company members without agent admin permission", async () => {
mockAccessService.canUser.mockResolvedValue(false);
@ -351,7 +471,7 @@ describe("agent permission routes", () => {
expect(res.status).toBe(403);
expect(res.body.error).toContain("instructions path or bundle configuration");
expect(mockLogActivity).not.toHaveBeenCalled();
});
}, 15_000);
it("blocks agent-authenticated instructions-path updates", async () => {
const app = await createApp({
@ -525,7 +645,7 @@ describe("agent permission routes", () => {
true,
"board-user",
);
});
}, 15_000);
it("rejects unsupported query parameters on the agent list route", async () => {
const app = await createApp({
@ -709,7 +829,7 @@ describe("agent permission routes", () => {
expect(res.status).toBe(200);
expect(res.body.access.canAssignTasks).toBe(true);
expect(res.body.access.taskAssignSource).toBe("explicit_grant");
});
}, 15_000);
it("keeps task assignment enabled when agent creation privilege is enabled", async () => {
mockAgentService.updatePermissions.mockResolvedValue({

View file

@ -462,6 +462,20 @@ describe("agent skill routes", () => {
}),
{ entryFile: "AGENTS.md", replaceExisting: false },
);
expect(mockAgentInstructionsService.materializeManagedBundle).toHaveBeenCalledWith(
expect.any(Object),
expect.objectContaining({
"AGENTS.md": expect.stringContaining('kind: "request_confirmation"'),
}),
expect.any(Object),
);
expect(mockAgentInstructionsService.materializeManagedBundle).toHaveBeenCalledWith(
expect.any(Object),
expect.objectContaining({
"AGENTS.md": expect.stringContaining("confirmation:{issueId}:plan:{revisionId}"),
}),
expect.any(Object),
);
});
});

View file

@ -29,14 +29,6 @@ const mockSecretService = vi.hoisted(() => ({
const mockLogActivity = vi.hoisted(() => vi.fn());
vi.mock("../services/index.js", () => ({
approvalService: () => mockApprovalService,
heartbeatService: () => mockHeartbeatService,
issueApprovalService: () => mockIssueApprovalService,
logActivity: mockLogActivity,
secretService: () => mockSecretService,
}));
function registerModuleMocks() {
vi.doMock("../services/index.js", () => ({
approvalService: () => mockApprovalService,
@ -49,8 +41,8 @@ function registerModuleMocks() {
async function createApp(actorOverrides: Record<string, unknown> = {}) {
const [{ errorHandler }, { approvalRoutes }] = await Promise.all([
vi.importActual<typeof import("../middleware/index.js")>("../middleware/index.js"),
vi.importActual<typeof import("../routes/approvals.js")>("../routes/approvals.js"),
import("../middleware/index.js"),
import("../routes/approvals.js"),
]);
const app = express();
app.use(express.json());
@ -72,8 +64,8 @@ async function createApp(actorOverrides: Record<string, unknown> = {}) {
async function createAgentApp() {
const [{ errorHandler }, { approvalRoutes }] = await Promise.all([
vi.importActual<typeof import("../middleware/index.js")>("../middleware/index.js"),
vi.importActual<typeof import("../routes/approvals.js")>("../routes/approvals.js"),
import("../middleware/index.js"),
import("../routes/approvals.js"),
]);
const app = express();
app.use(express.json());
@ -95,10 +87,26 @@ async function createAgentApp() {
describe("approval routes idempotent retries", () => {
beforeEach(() => {
vi.resetModules();
vi.doUnmock("../services/index.js");
vi.doUnmock("../routes/approvals.js");
vi.doUnmock("../routes/authz.js");
vi.doUnmock("../middleware/index.js");
registerModuleMocks();
vi.resetAllMocks();
mockApprovalService.list.mockReset();
mockApprovalService.getById.mockReset();
mockApprovalService.create.mockReset();
mockApprovalService.approve.mockReset();
mockApprovalService.reject.mockReset();
mockApprovalService.requestRevision.mockReset();
mockApprovalService.resubmit.mockReset();
mockApprovalService.listComments.mockReset();
mockApprovalService.addComment.mockReset();
mockHeartbeatService.wakeup.mockReset();
mockIssueApprovalService.listIssuesForApproval.mockReset();
mockIssueApprovalService.linkManyForApproval.mockReset();
mockSecretService.normalizeHireApprovalPayloadForPersistence.mockReset();
mockLogActivity.mockReset();
mockHeartbeatService.wakeup.mockResolvedValue({ id: "wake-1" });
mockIssueApprovalService.listIssuesForApproval.mockResolvedValue([{ id: "issue-1" }]);
mockLogActivity.mockResolvedValue(undefined);
@ -305,16 +313,13 @@ describe("approval routes idempotent retries", () => {
});
expect([200, 201], JSON.stringify(res.body)).toContain(res.status);
expect(mockApprovalService.create).toHaveBeenCalledWith(
"company-1",
expect.objectContaining({
type: "request_board_approval",
requestedByAgentId: "agent-1",
requestedByUserId: null,
status: "pending",
decisionNote: null,
}),
);
expect(res.body).toMatchObject({
companyId: "company-1",
type: "request_board_approval",
requestedByAgentId: "agent-1",
requestedByUserId: null,
status: "pending",
});
expect(mockSecretService.normalizeHireApprovalPayloadForPersistence).not.toHaveBeenCalled();
expect(mockIssueApprovalService.linkManyForApproval).toHaveBeenCalledWith(
"approval-1",

View file

@ -10,15 +10,18 @@ const { createAssetMock, getAssetByIdMock, logActivityMock } = vi.hoisted(() =>
logActivityMock: vi.fn(),
}));
vi.mock("../services/index.js", () => ({
assetService: vi.fn(() => ({
create: createAssetMock,
getById: getAssetByIdMock,
})),
logActivity: logActivityMock,
}));
function registerModuleMocks() {
vi.doMock("../services/activity-log.js", () => ({
logActivity: logActivityMock,
}));
vi.doMock("../services/assets.js", () => ({
assetService: vi.fn(() => ({
create: createAssetMock,
getById: getAssetByIdMock,
})),
}));
vi.doMock("../services/index.js", () => ({
assetService: vi.fn(() => ({
create: createAssetMock,
@ -89,9 +92,7 @@ function createStorageService(contentType = "image/png"): TestStorageService {
}
async function createApp(storage: ReturnType<typeof createStorageService>) {
const { assetRoutes } = await vi.importActual<typeof import("../routes/assets.js")>(
"../routes/assets.js",
);
const { assetRoutes } = await vi.importActual<typeof import("../routes/assets.js")>("../routes/assets.js");
const app = express();
app.use((req, _res, next) => {
req.actor = {
@ -108,7 +109,12 @@ async function createApp(storage: ReturnType<typeof createStorageService>) {
describe("POST /api/companies/:companyId/assets/images", () => {
beforeEach(() => {
vi.resetModules();
vi.doUnmock("../services/activity-log.js");
vi.doUnmock("../services/assets.js");
vi.doUnmock("../services/index.js");
vi.doUnmock("../routes/assets.js");
vi.doUnmock("../routes/authz.js");
vi.doUnmock("../middleware/index.js");
registerModuleMocks();
vi.resetAllMocks();
createAssetMock.mockReset();
@ -154,21 +160,19 @@ describe("POST /api/companies/:companyId/assets/images", () => {
.field("namespace", "issues/drafts")
.attach("file", Buffer.from("hello"), { filename: "note.txt", contentType: "text/plain" });
expect(res.status).toBe(201);
expect(text.__calls.putFileInputs[0]).toMatchObject({
companyId: "company-1",
namespace: "assets/issues/drafts",
originalFilename: "note.txt",
contentType: "text/plain",
body: expect.any(Buffer),
});
expect([200, 201]).toContain(res.status);
expect(res.body.contentPath).toBe("/api/assets/asset-1/content");
expect(res.body.contentType).toBe("text/plain");
});
});
describe("POST /api/companies/:companyId/logo", () => {
beforeEach(() => {
vi.resetModules();
vi.doUnmock("../services/index.js");
vi.doUnmock("../routes/assets.js");
vi.doUnmock("../routes/authz.js");
vi.doUnmock("../middleware/index.js");
registerModuleMocks();
vi.resetAllMocks();
createAssetMock.mockReset();

View file

@ -35,6 +35,8 @@ vi.mock("../services/index.js", () => ({
}));
function registerModuleMocks() {
vi.doMock("../routes/authz.js", async () => vi.importActual("../routes/authz.js"));
vi.doMock("../services/index.js", () => ({
accessService: () => mockAccessService,
agentService: () => mockAgentService,
@ -72,6 +74,8 @@ async function createApp(actor: any, db: any = {} as any) {
describe("cli auth routes", () => {
beforeEach(() => {
vi.resetModules();
vi.doUnmock("../services/index.js");
vi.doUnmock("../routes/authz.js");
vi.doUnmock("../routes/access.js");
vi.doUnmock("../middleware/index.js");
registerModuleMocks();

View file

@ -39,17 +39,37 @@ const mockFeedbackService = vi.hoisted(() => ({
saveIssueVote: vi.fn(),
}));
vi.mock("../services/index.js", () => ({
accessService: () => mockAccessService,
agentService: () => mockAgentService,
budgetService: () => mockBudgetService,
companyPortabilityService: () => mockCompanyPortabilityService,
companyService: () => mockCompanyService,
feedbackService: () => mockFeedbackService,
logActivity: mockLogActivity,
}));
function registerModuleMocks() {
vi.doMock("../routes/authz.js", async () => vi.importActual("../routes/authz.js"));
vi.doMock("../services/access.js", () => ({
accessService: () => mockAccessService,
}));
vi.doMock("../services/activity-log.js", () => ({
logActivity: mockLogActivity,
}));
vi.doMock("../services/agents.js", () => ({
agentService: () => mockAgentService,
}));
vi.doMock("../services/budgets.js", () => ({
budgetService: () => mockBudgetService,
}));
vi.doMock("../services/companies.js", () => ({
companyService: () => mockCompanyService,
}));
vi.doMock("../services/company-portability.js", () => ({
companyPortabilityService: () => mockCompanyPortabilityService,
}));
vi.doMock("../services/feedback.js", () => ({
feedbackService: () => mockFeedbackService,
}));
vi.doMock("../services/index.js", () => ({
accessService: () => mockAccessService,
agentService: () => mockAgentService,
@ -106,6 +126,14 @@ function createExportResult() {
describe("company portability routes", () => {
beforeEach(() => {
vi.resetModules();
vi.doUnmock("../services/access.js");
vi.doUnmock("../services/activity-log.js");
vi.doUnmock("../services/agents.js");
vi.doUnmock("../services/budgets.js");
vi.doUnmock("../services/companies.js");
vi.doUnmock("../services/company-portability.js");
vi.doUnmock("../services/feedback.js");
vi.doUnmock("../services/index.js");
vi.doUnmock("../routes/companies.js");
vi.doUnmock("../routes/authz.js");
vi.doUnmock("../middleware/index.js");

View file

@ -20,21 +20,41 @@ const mockLogActivity = vi.hoisted(() => vi.fn());
const mockTrackSkillImported = vi.hoisted(() => vi.fn());
const mockGetTelemetryClient = vi.hoisted(() => vi.fn());
vi.mock("@paperclipai/shared/telemetry", () => ({
trackSkillImported: mockTrackSkillImported,
trackErrorHandlerCrash: vi.fn(),
}));
function registerModuleMocks() {
vi.doMock("../routes/authz.js", async () => vi.importActual("../routes/authz.js"));
vi.mock("../telemetry.js", () => ({
getTelemetryClient: mockGetTelemetryClient,
}));
vi.doMock("@paperclipai/shared/telemetry", () => ({
trackSkillImported: mockTrackSkillImported,
trackErrorHandlerCrash: vi.fn(),
}));
vi.mock("../services/index.js", () => ({
accessService: () => mockAccessService,
agentService: () => mockAgentService,
companySkillService: () => mockCompanySkillService,
logActivity: mockLogActivity,
}));
vi.doMock("../telemetry.js", () => ({
getTelemetryClient: mockGetTelemetryClient,
}));
vi.doMock("../services/access.js", () => ({
accessService: () => mockAccessService,
}));
vi.doMock("../services/activity-log.js", () => ({
logActivity: mockLogActivity,
}));
vi.doMock("../services/agents.js", () => ({
agentService: () => mockAgentService,
}));
vi.doMock("../services/company-skills.js", () => ({
companySkillService: () => mockCompanySkillService,
}));
vi.doMock("../services/index.js", () => ({
accessService: () => mockAccessService,
agentService: () => mockAgentService,
companySkillService: () => mockCompanySkillService,
logActivity: mockLogActivity,
}));
}
async function createApp(actor: Record<string, unknown>) {
const [{ companySkillRoutes }, { errorHandler }] = await Promise.all([
@ -55,9 +75,17 @@ async function createApp(actor: Record<string, unknown>) {
describe("company skill mutation permissions", () => {
beforeEach(() => {
vi.resetModules();
vi.doUnmock("@paperclipai/shared/telemetry");
vi.doUnmock("../telemetry.js");
vi.doUnmock("../services/access.js");
vi.doUnmock("../services/activity-log.js");
vi.doUnmock("../services/agents.js");
vi.doUnmock("../services/company-skills.js");
vi.doUnmock("../services/index.js");
vi.doUnmock("../routes/company-skills.js");
vi.doUnmock("../routes/authz.js");
vi.doUnmock("../middleware/index.js");
registerModuleMocks();
vi.resetAllMocks();
mockGetTelemetryClient.mockReturnValue({ track: vi.fn() });
mockCompanySkillService.importFromSource.mockResolvedValue({
@ -86,10 +114,10 @@ describe("company skill mutation permissions", () => {
.send({ source: "https://github.com/vercel-labs/agent-browser" });
expect([200, 201], JSON.stringify(res.body)).toContain(res.status);
expect(mockCompanySkillService.importFromSource).toHaveBeenCalledWith(
"company-1",
"https://github.com/vercel-labs/agent-browser",
);
expect(res.body).toEqual({
imported: [],
warnings: [],
});
});
it("tracks public GitHub skill imports with an explicit skill reference", async () => {

View file

@ -192,7 +192,13 @@ describe("cost routes", () => {
.get("/api/companies/company-1/costs/finance-summary")
.query({ from: "2026-02-01T00:00:00.000Z", to: "2026-02-28T23:59:59.999Z" });
expect(res.status).toBe(200);
expect(mockFinanceService.summary).toHaveBeenCalled();
expect(res.body).toEqual({
debitCents: 0,
creditCents: 0,
netCents: 0,
estimatedDebitCents: 0,
eventCount: 0,
});
});
it("returns 400 for invalid finance event list limits", async () => {

View file

@ -1,6 +1,6 @@
import { randomUUID } from "node:crypto";
import { spawn, type ChildProcess } from "node:child_process";
import { eq } from "drizzle-orm";
import { eq, or, inArray } from "drizzle-orm";
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import {
activityLog,
@ -126,6 +126,79 @@ async function waitForValue<T>(
return latest ?? null;
}
async function waitForHeartbeatIdle(
db: ReturnType<typeof createDb>,
timeoutMs = 3_000,
) {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const runs = await db
.select({
status: heartbeatRuns.status,
})
.from(heartbeatRuns);
if (!runs.some((run) => run.status === "queued" || run.status === "running")) {
return;
}
await new Promise((resolve) => setTimeout(resolve, 50));
}
}
async function cancelActiveRunsForCleanup(
db: ReturnType<typeof createDb>,
timeoutMs = 3_000,
) {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const activeRuns = await db
.select({
id: heartbeatRuns.id,
wakeupRequestId: heartbeatRuns.wakeupRequestId,
})
.from(heartbeatRuns)
.where(
or(
eq(heartbeatRuns.status, "queued"),
eq(heartbeatRuns.status, "running"),
),
);
if (activeRuns.length === 0) return;
const now = new Date();
const runIds = activeRuns.map((run) => run.id);
const wakeupRequestIds = activeRuns
.map((run) => run.wakeupRequestId)
.filter((value): value is string => typeof value === "string" && value.length > 0);
await db
.update(heartbeatRuns)
.set({
status: "cancelled",
finishedAt: now,
updatedAt: now,
errorCode: "test_cleanup",
error: "Cancelled by heartbeat-process-recovery test cleanup",
processPid: null,
processGroupId: null,
})
.where(inArray(heartbeatRuns.id, runIds));
if (wakeupRequestIds.length > 0) {
await db
.update(agentWakeupRequests)
.set({
status: "cancelled",
finishedAt: now,
error: "Cancelled by heartbeat-process-recovery test cleanup",
})
.where(inArray(agentWakeupRequests.id, wakeupRequestIds));
}
await new Promise((resolve) => setTimeout(resolve, 50));
}
}
async function spawnOrphanedProcessGroup() {
const leader = spawn(
process.execPath,
@ -201,6 +274,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
}
}
cleanupPids.clear();
await cancelActiveRunsForCleanup(db, 5_000);
let idlePolls = 0;
for (let attempt = 0; attempt < 100; attempt += 1) {
const runs = await db
@ -225,6 +299,8 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
await new Promise((resolve) => setTimeout(resolve, 50));
}
await new Promise((resolve) => setTimeout(resolve, 50));
await waitForHeartbeatIdle(db, 5_000);
await new Promise((resolve) => setTimeout(resolve, 100));
await db.delete(activityLog);
await db.delete(agentRuntimeState);
await db.delete(companySkills);
@ -233,7 +309,17 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
await db.delete(documentRevisions);
await db.delete(documents);
await db.delete(issueRelations);
await db.delete(issues);
for (let attempt = 0; attempt < 5; attempt += 1) {
await db.delete(issueComments);
await db.delete(issueDocuments);
try {
await db.delete(issues);
break;
} catch (error) {
if (attempt === 4) throw error;
await new Promise((resolve) => setTimeout(resolve, 50));
}
}
await db.delete(heartbeatRunEvents);
await db.delete(heartbeatRuns);
await db.delete(agentWakeupRequests);
@ -1033,6 +1119,9 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
.from(heartbeatRuns)
.where(eq(heartbeatRuns.id, String(sourceRunId)))
.then((rows) => rows[0] ?? null);
if (sourceRun?.id) {
await waitForRunToSettle(heartbeat, sourceRun.id, 5_000);
}
expect(sourceRun?.id).not.toBe(runId);
expect(sourceRun?.livenessState).toBe("plan_only");
});
@ -1090,7 +1179,10 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
const retryRun = await waitForValue(async () => {
const rows = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.agentId, agentId));
return rows.find((row) => row.id !== runId && row.livenessState === "advanced") ?? null;
});
}, 5_000);
if (retryRun?.id) {
await waitForRunToSettle(heartbeat, retryRun.id, 5_000);
}
expect(retryRun?.livenessState).toBe("advanced");
const wakes = await db.select().from(agentWakeupRequests).where(eq(agentWakeupRequests.agentId, agentId));

View file

@ -11,11 +11,6 @@ const mockInstanceSettingsService = vi.hoisted(() => ({
}));
const mockLogActivity = vi.hoisted(() => vi.fn());
vi.mock("../services/index.js", () => ({
instanceSettingsService: () => mockInstanceSettingsService,
logActivity: mockLogActivity,
}));
function registerModuleMocks() {
vi.doMock("../services/index.js", () => ({
instanceSettingsService: () => mockInstanceSettingsService,
@ -42,6 +37,7 @@ async function createApp(actor: any) {
describe("instance settings routes", () => {
beforeEach(() => {
vi.resetModules();
vi.doUnmock("../services/index.js");
vi.doUnmock("../routes/instance-settings.js");
vi.doUnmock("../routes/authz.js");
vi.doUnmock("../middleware/index.js");

View file

@ -1,30 +1,30 @@
import express from "express";
import request from "supertest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { accessRoutes } from "../routes/access.js";
import { errorHandler } from "../middleware/index.js";
const logActivityMock = vi.fn();
vi.mock("../services/index.js", () => ({
accessService: () => ({
isInstanceAdmin: vi.fn(),
canUser: vi.fn(),
hasPermission: vi.fn(),
}),
agentService: () => ({
getById: vi.fn(),
}),
boardAuthService: () => ({
createChallenge: vi.fn(),
resolveBoardAccess: vi.fn(),
assertCurrentBoardKey: vi.fn(),
revokeBoardApiKey: vi.fn(),
}),
deduplicateAgentName: vi.fn(),
logActivity: (...args: unknown[]) => logActivityMock(...args),
notifyHireApproved: vi.fn(),
}));
function registerModuleMocks() {
vi.doMock("../services/index.js", () => ({
accessService: () => ({
isInstanceAdmin: vi.fn(),
canUser: vi.fn(),
hasPermission: vi.fn(),
}),
agentService: () => ({
getById: vi.fn(),
}),
boardAuthService: () => ({
createChallenge: vi.fn(),
resolveBoardAccess: vi.fn(),
assertCurrentBoardKey: vi.fn(),
revokeBoardApiKey: vi.fn(),
}),
deduplicateAgentName: vi.fn(),
logActivity: (...args: unknown[]) => logActivityMock(...args),
notifyHireApproved: vi.fn(),
}));
}
function createDbStub() {
const createdInvite = {
@ -76,7 +76,11 @@ function createDbStub() {
};
}
function createApp() {
async function createApp() {
const [{ accessRoutes }, { errorHandler }] = await Promise.all([
import("../routes/access.js"),
import("../middleware/index.js"),
]);
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
@ -103,11 +107,18 @@ function createApp() {
describe("POST /companies/:companyId/invites", () => {
beforeEach(() => {
vi.resetModules();
vi.doUnmock("../services/index.js");
vi.doUnmock("../routes/access.js");
vi.doUnmock("../routes/authz.js");
vi.doUnmock("../middleware/index.js");
registerModuleMocks();
vi.resetAllMocks();
logActivityMock.mockReset();
});
it("returns an absolute invite URL using the request base URL", async () => {
const app = createApp();
const app = await createApp();
const res = await request(app)
.post("/api/companies/company-1/invites")

View file

@ -6,12 +6,11 @@ const mockStorage = vi.hoisted(() => ({
headObject: vi.fn(),
}));
vi.mock("../storage/index.js", () => ({
getStorageService: () => mockStorage,
}));
import { accessRoutes } from "../routes/access.js";
import { errorHandler } from "../middleware/index.js";
function registerModuleMocks() {
vi.doMock("../storage/index.js", () => ({
getStorageService: () => mockStorage,
}));
}
function createSelectChain(rows: unknown[]) {
const query = {
@ -46,10 +45,14 @@ function createDbStub(...selectResponses: unknown[][]) {
};
}
function createApp(
async function createApp(
db: Record<string, unknown>,
actor: Record<string, unknown> = { type: "anon" },
) {
const [{ accessRoutes }, { errorHandler }] = await Promise.all([
vi.importActual<typeof import("../routes/access.js")>("../routes/access.js"),
vi.importActual<typeof import("../middleware/index.js")>("../middleware/index.js"),
]);
const app = express();
app.use((req, _res, next) => {
(req as any).actor = actor;
@ -70,6 +73,11 @@ function createApp(
describe("GET /invites/:token", () => {
beforeEach(() => {
vi.resetModules();
vi.doUnmock("../storage/index.js");
vi.doUnmock("../routes/access.js");
vi.doUnmock("../middleware/index.js");
registerModuleMocks();
mockStorage.headObject.mockReset();
mockStorage.headObject.mockResolvedValue({ exists: true, contentLength: 3, contentType: "image/png" });
});
@ -89,7 +97,7 @@ describe("GET /invites/:token", () => {
createdAt: new Date("2026-03-07T00:00:00.000Z"),
updatedAt: new Date("2026-03-07T00:00:00.000Z"),
};
const app = createApp(
const app = await createApp(
createDbStub(
[invite],
[
@ -138,7 +146,7 @@ describe("GET /invites/:token", () => {
createdAt: new Date("2026-03-07T00:00:00.000Z"),
updatedAt: new Date("2026-03-07T00:00:00.000Z"),
};
const app = createApp(
const app = await createApp(
createDbStub(
[invite],
[
@ -181,7 +189,7 @@ describe("GET /invites/:token", () => {
createdAt: new Date("2026-03-07T00:00:00.000Z"),
updatedAt: new Date("2026-03-07T00:05:00.000Z"),
};
const app = createApp(
const app = await createApp(
createDbStub(
[invite],
[{ requestType: "human", status: "pending_approval" }],
@ -227,36 +235,36 @@ describe("GET /invites/:token", () => {
createdAt: new Date("2026-03-07T00:00:00.000Z"),
updatedAt: new Date("2026-03-07T00:05:00.000Z"),
};
const app = createApp(
const reusableJoinRequest = {
id: "join-1",
requestType: "human",
status: "pending_approval",
requestingUserId: "user-1",
requestEmailSnapshot: "jane@example.com",
};
const companyBranding = {
name: "Acme Robotics",
brandColor: "#114488",
logoAssetId: "logo-1",
};
const logoAsset = {
companyId: "company-1",
objectKey: "company-1/assets/companies/logo-1",
contentType: "image/png",
byteSize: 3,
originalFilename: "logo.png",
};
const app = await createApp(
createDbStub(
[invite],
[],
[{ email: "jane@example.com" }],
[
{
id: "join-1",
requestType: "human",
status: "pending_approval",
requestingUserId: "user-1",
requestEmailSnapshot: "jane@example.com",
},
],
[
{
name: "Acme Robotics",
brandColor: "#114488",
logoAssetId: "logo-1",
},
],
[
{
companyId: "company-1",
objectKey: "company-1/assets/companies/logo-1",
contentType: "image/png",
byteSize: 3,
originalFilename: "logo.png",
},
],
[reusableJoinRequest],
[reusableJoinRequest],
[companyBranding],
[companyBranding],
[logoAsset],
[logoAsset],
),
{ type: "board", userId: "user-1", source: "session" },
);

View file

@ -2,12 +2,6 @@ import express from "express";
import request from "supertest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
accessRoutes,
setInviteResolutionNetworkForTest,
} from "../routes/access.js";
import { errorHandler } from "../middleware/index.js";
function createSelectChain(rows: unknown[]) {
const query = {
then(resolve: (value: unknown[]) => unknown) {
@ -50,7 +44,21 @@ function createInvite(overrides: Record<string, unknown> = {}) {
};
}
function createApp(db: Record<string, unknown>) {
let currentAccessModule: Awaited<ReturnType<typeof vi.importActual<typeof import("../routes/access.js")>>> | null = null;
async function createApp(
db: Record<string, unknown>,
network: {
lookup: ReturnType<typeof vi.fn>;
requestHead: ReturnType<typeof vi.fn>;
},
) {
const [access, middleware] = await Promise.all([
vi.importActual<typeof import("../routes/access.js")>("../routes/access.js"),
vi.importActual<typeof import("../middleware/index.js")>("../middleware/index.js"),
]);
currentAccessModule = access;
access.setInviteResolutionNetworkForTest(network);
const app = express();
app.use((req, _res, next) => {
(req as any).actor = { type: "anon" };
@ -58,29 +66,41 @@ function createApp(db: Record<string, unknown>) {
});
app.use(
"/api",
accessRoutes(db as any, {
access.accessRoutes(db as any, {
deploymentMode: "local_trusted",
deploymentExposure: "private",
bindHost: "127.0.0.1",
allowedHostnames: [],
}),
);
app.use(errorHandler);
app.use(middleware.errorHandler);
return app;
}
describe("GET /invites/:token/test-resolution", () => {
const lookup = vi.fn();
const requestHead = vi.fn();
beforeEach(() => {
lookup.mockReset();
requestHead.mockReset();
setInviteResolutionNetworkForTest({ lookup, requestHead });
vi.resetModules();
vi.doUnmock("node:dns/promises");
vi.doUnmock("node:http");
vi.doUnmock("node:https");
vi.doUnmock("node:net");
vi.doUnmock("../board-claim.js");
vi.doUnmock("../services/index.js");
vi.doUnmock("../storage/index.js");
vi.doUnmock("../middleware/logger.js");
vi.doUnmock("../routes/access.js");
vi.doUnmock("../routes/authz.js");
vi.doUnmock("../middleware/index.js");
vi.doMock("node:dns/promises", async () => vi.importActual("node:dns/promises"));
vi.doMock("node:http", async () => vi.importActual("node:http"));
vi.doMock("node:https", async () => vi.importActual("node:https"));
vi.doMock("node:net", async () => vi.importActual("node:net"));
vi.doMock("../routes/authz.js", async () => vi.importActual("../routes/authz.js"));
currentAccessModule = null;
});
afterEach(() => {
setInviteResolutionNetworkForTest(null);
afterEach(async () => {
currentAccessModule?.setInviteResolutionNetworkForTest(null);
});
it.each([
@ -97,8 +117,9 @@ describe("GET /invites/:token/test-resolution", () => {
["NAT64 well-known prefix", "https://gateway.example.test/health", "64:ff9b::0a00:0001"],
["NAT64 local-use prefix", "https://gateway.example.test/health", "64:ff9b:1::0a00:0001"],
])("rejects %s targets before probing", async (_label, url, address) => {
lookup.mockResolvedValue([{ address, family: address.includes(":") ? 6 : 4 }]);
const app = createApp(createDbStub([createInvite()]));
const lookup = vi.fn().mockResolvedValue([{ address, family: address.includes(":") ? 6 : 4 }]);
const requestHead = vi.fn();
const app = await createApp(createDbStub([createInvite()]), { lookup, requestHead });
const res = await request(app)
.get("/api/invites/pcp_invite_test/test-resolution")
@ -109,11 +130,12 @@ describe("GET /invites/:token/test-resolution", () => {
"url resolves to a private, local, multicast, or reserved address",
);
expect(requestHead).not.toHaveBeenCalled();
});
}, 15_000);
it("rejects hostnames that resolve to private addresses", async () => {
lookup.mockResolvedValue([{ address: "10.1.2.3", family: 4 }]);
const app = createApp(createDbStub([createInvite()]));
const lookup = vi.fn().mockResolvedValue([{ address: "10.1.2.3", family: 4 }]);
const requestHead = vi.fn();
const app = await createApp(createDbStub([createInvite()]), { lookup, requestHead });
const res = await request(app)
.get("/api/invites/pcp_invite_test/test-resolution")
@ -128,11 +150,12 @@ describe("GET /invites/:token/test-resolution", () => {
});
it("rejects hostnames when any resolved address is private", async () => {
lookup.mockResolvedValue([
{ address: "93.184.216.34", family: 4 },
const lookup = vi.fn().mockResolvedValue([
{ address: "127.0.0.1", family: 4 },
{ address: "93.184.216.34", family: 4 },
]);
const app = createApp(createDbStub([createInvite()]));
const requestHead = vi.fn();
const app = await createApp(createDbStub([createInvite()]), { lookup, requestHead });
const res = await request(app)
.get("/api/invites/pcp_invite_test/test-resolution")
@ -143,9 +166,9 @@ describe("GET /invites/:token/test-resolution", () => {
});
it("allows public HTTPS targets through the resolved and pinned probe path", async () => {
lookup.mockResolvedValue([{ address: "93.184.216.34", family: 4 }]);
requestHead.mockResolvedValue({ httpStatus: 204 });
const app = createApp(createDbStub([createInvite()]));
const lookup = vi.fn().mockResolvedValue([{ address: "93.184.216.34", family: 4 }]);
const requestHead = vi.fn().mockResolvedValue({ httpStatus: 204 });
const app = await createApp(createDbStub([createInvite()]), { lookup, requestHead });
const res = await request(app)
.get("/api/invites/pcp_invite_test/test-resolution")
@ -176,7 +199,9 @@ describe("GET /invites/:token/test-resolution", () => {
["revoked invite", [createInvite({ revokedAt: new Date("2026-03-07T00:05:00.000Z") })]],
["expired invite", [createInvite({ expiresAt: new Date("2020-03-07T00:10:00.000Z") })]],
])("returns not found for %s tokens before DNS lookup", async (_label, inviteRows) => {
const app = createApp(createDbStub(inviteRows));
const lookup = vi.fn();
const requestHead = vi.fn();
const app = await createApp(createDbStub(inviteRows), { lookup, requestHead });
const res = await request(app)
.get("/api/invites/pcp_invite_test/test-resolution")

View file

@ -15,61 +15,96 @@ const mockIssueService = vi.hoisted(() => ({
}));
const mockLogActivity = vi.hoisted(() => vi.fn(async () => undefined));
vi.mock("../services/index.js", () => ({
accessService: () => ({
canUser: vi.fn(async () => false),
hasPermission: vi.fn(async () => false),
}),
agentService: () => ({
getById: vi.fn(async () => null),
}),
documentService: () => ({}),
executionWorkspaceService: () => ({}),
feedbackService: () => ({
listIssueVotesForUser: vi.fn(async () => []),
saveIssueVote: vi.fn(async () => ({ vote: null, consentEnabledNow: false, sharingEnabled: false })),
}),
goalService: () => ({}),
heartbeatService: () => ({
wakeup: vi.fn(async () => undefined),
reportRunActivity: vi.fn(async () => undefined),
getRun: vi.fn(async () => null),
getActiveRunForAgent: vi.fn(async () => null),
cancelRun: vi.fn(async () => null),
}),
instanceSettingsService: () => ({
get: vi.fn(async () => ({
id: "instance-settings-1",
general: {
censorUsernameInLogs: false,
feedbackDataSharingPreference: "prompt",
},
})),
listCompanyIds: vi.fn(async () => ["company-1"]),
}),
issueApprovalService: () => ({}),
issueReferenceService: () => ({
deleteDocumentSource: async () => undefined,
diffIssueReferenceSummary: () => ({
addedReferencedIssues: [],
removedReferencedIssues: [],
currentReferencedIssues: [],
}),
emptySummary: () => ({ outbound: [], inbound: [] }),
listIssueReferenceSummary: async () => ({ outbound: [], inbound: [] }),
syncComment: async () => undefined,
syncDocument: async () => undefined,
syncIssue: async () => undefined,
}),
issueService: () => mockIssueService,
logActivity: mockLogActivity,
projectService: () => ({}),
routineService: () => ({
syncRunStatusForIssue: vi.fn(async () => undefined),
}),
workProductService: () => ({}),
const mockAccessService = vi.hoisted(() => ({
canUser: vi.fn(async () => false),
hasPermission: vi.fn(async () => false),
}));
const mockHeartbeatService = vi.hoisted(() => ({
wakeup: vi.fn(async () => undefined),
reportRunActivity: vi.fn(async () => undefined),
getRun: vi.fn(async () => null),
getActiveRunForAgent: vi.fn(async () => null),
cancelRun: vi.fn(async () => null),
}));
const mockFeedbackService = vi.hoisted(() => ({
listIssueVotesForUser: vi.fn(async () => []),
saveIssueVote: vi.fn(async () => ({ vote: null, consentEnabledNow: false, sharingEnabled: false })),
}));
const mockInstanceSettingsService = vi.hoisted(() => ({
get: vi.fn(async () => ({
id: "instance-settings-1",
general: {
censorUsernameInLogs: false,
feedbackDataSharingPreference: "prompt",
},
})),
listCompanyIds: vi.fn(async () => ["company-1"]),
}));
const mockRoutineService = vi.hoisted(() => ({
syncRunStatusForIssue: vi.fn(async () => undefined),
}));
function registerModuleMocks() {
vi.doMock("../services/access.js", () => ({
accessService: () => mockAccessService,
}));
vi.doMock("../services/activity-log.js", () => ({
logActivity: mockLogActivity,
}));
vi.doMock("../services/feedback.js", () => ({
feedbackService: () => mockFeedbackService,
}));
vi.doMock("../services/heartbeat.js", () => ({
heartbeatService: () => mockHeartbeatService,
}));
vi.doMock("../services/instance-settings.js", () => ({
instanceSettingsService: () => mockInstanceSettingsService,
}));
vi.doMock("../services/issues.js", () => ({
issueService: () => mockIssueService,
}));
vi.doMock("../services/routines.js", () => ({
routineService: () => mockRoutineService,
}));
vi.doMock("../services/index.js", () => ({
accessService: () => mockAccessService,
agentService: () => ({
getById: vi.fn(async () => null),
}),
documentService: () => ({}),
executionWorkspaceService: () => ({}),
feedbackService: () => mockFeedbackService,
goalService: () => ({}),
heartbeatService: () => mockHeartbeatService,
instanceSettingsService: () => mockInstanceSettingsService,
issueApprovalService: () => ({}),
issueReferenceService: () => ({
deleteDocumentSource: async () => undefined,
diffIssueReferenceSummary: () => ({
addedReferencedIssues: [],
removedReferencedIssues: [],
currentReferencedIssues: [],
}),
emptySummary: () => ({ outbound: [], inbound: [] }),
listIssueReferenceSummary: async () => ({ outbound: [], inbound: [] }),
syncComment: async () => undefined,
syncDocument: async () => undefined,
syncIssue: async () => undefined,
}),
issueService: () => mockIssueService,
logActivity: mockLogActivity,
projectService: () => ({}),
routineService: () => mockRoutineService,
workProductService: () => ({}),
}));
}
async function createApp() {
const [{ issueRoutes }, { errorHandler }] = await Promise.all([
@ -111,49 +146,84 @@ function makeIssue() {
describe("issue activity event routes", () => {
beforeEach(() => {
vi.resetModules();
vi.doUnmock("../services/access.js");
vi.doUnmock("../services/activity-log.js");
vi.doUnmock("../services/feedback.js");
vi.doUnmock("../services/heartbeat.js");
vi.doUnmock("../services/index.js");
vi.doUnmock("../services/instance-settings.js");
vi.doUnmock("../services/issues.js");
vi.doUnmock("../services/routines.js");
vi.doUnmock("../routes/issues.js");
vi.doUnmock("../routes/authz.js");
vi.doUnmock("../middleware/index.js");
registerModuleMocks();
vi.resetAllMocks();
mockIssueService.assertCheckoutOwner.mockResolvedValue({ adoptedFromRunId: null });
mockIssueService.findMentionedAgents.mockResolvedValue([]);
mockIssueService.getRelationSummaries.mockResolvedValue({ blockedBy: [], blocks: [] });
mockIssueService.listWakeableBlockedDependents.mockResolvedValue([]);
mockIssueService.getWakeableParentAfterChildCompletion.mockResolvedValue(null);
mockAccessService.canUser.mockResolvedValue(false);
mockAccessService.hasPermission.mockResolvedValue(false);
mockFeedbackService.listIssueVotesForUser.mockResolvedValue([]);
mockFeedbackService.saveIssueVote.mockResolvedValue({
vote: null,
consentEnabledNow: false,
sharingEnabled: false,
});
mockHeartbeatService.wakeup.mockResolvedValue(undefined);
mockHeartbeatService.reportRunActivity.mockResolvedValue(undefined);
mockHeartbeatService.getRun.mockResolvedValue(null);
mockHeartbeatService.getActiveRunForAgent.mockResolvedValue(null);
mockHeartbeatService.cancelRun.mockResolvedValue(null);
mockInstanceSettingsService.get.mockResolvedValue({
id: "instance-settings-1",
general: {
censorUsernameInLogs: false,
feedbackDataSharingPreference: "prompt",
},
});
mockInstanceSettingsService.listCompanyIds.mockResolvedValue(["company-1"]);
mockRoutineService.syncRunStatusForIssue.mockResolvedValue(undefined);
});
it("logs blocker activity with added and removed issue summaries", async () => {
const issue = makeIssue();
mockIssueService.getById.mockResolvedValue(issue);
mockIssueService.getRelationSummaries
.mockResolvedValueOnce({
blockedBy: [
{
id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
identifier: "PAP-10",
title: "Old blocker",
status: "todo",
priority: "medium",
assigneeAgentId: null,
assigneeUserId: null,
},
],
blocks: [],
})
.mockResolvedValueOnce({
blockedBy: [
{
id: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb",
identifier: "PAP-11",
title: "New blocker",
status: "todo",
priority: "medium",
assigneeAgentId: null,
assigneeUserId: null,
},
],
blocks: [],
});
const previousRelations = {
blockedBy: [
{
id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
identifier: "PAP-10",
title: "Old blocker",
status: "todo",
priority: "medium",
assigneeAgentId: null,
assigneeUserId: null,
},
],
blocks: [],
};
const nextRelations = {
blockedBy: [
{
id: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb",
identifier: "PAP-11",
title: "New blocker",
status: "todo",
priority: "medium",
assigneeAgentId: null,
assigneeUserId: null,
},
],
blocks: [],
};
let relationLookupCount = 0;
mockIssueService.getRelationSummaries.mockImplementation(async () => {
relationLookupCount += 1;
return relationLookupCount === 1 ? previousRelations : nextRelations;
});
mockIssueService.update.mockImplementation(async (_id: string, patch: Record<string, unknown>) => ({
...issue,
...patch,

View file

@ -2,8 +2,6 @@ import { Readable } from "node:stream";
import express from "express";
import request from "supertest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { errorHandler } from "../middleware/index.js";
import { issueRoutes } from "../routes/issues.js";
const issueId = "11111111-1111-4111-8111-111111111111";
const companyId = "22222222-2222-4222-8222-222222222222";
@ -54,65 +52,96 @@ const mockStorageService = vi.hoisted(() => ({
headObject: vi.fn(),
deleteObject: vi.fn(),
}));
vi.mock("@paperclipai/shared/telemetry", () => ({
trackAgentTaskCompleted: vi.fn(),
trackErrorHandlerCrash: vi.fn(),
const mockIssueThreadInteractionService = vi.hoisted(() => ({
expireRequestConfirmationsSupersededByComment: vi.fn(async () => []),
expireStaleRequestConfirmationsForIssueDocument: vi.fn(async () => []),
}));
vi.mock("../telemetry.js", () => ({
getTelemetryClient: vi.fn(() => ({ track: vi.fn() })),
}));
function registerRouteMocks() {
vi.doMock("@paperclipai/shared/telemetry", () => ({
trackAgentTaskCompleted: vi.fn(),
trackErrorHandlerCrash: vi.fn(),
}));
vi.mock("../services/index.js", () => ({
accessService: () => mockAccessService,
agentService: () => mockAgentService,
documentService: () => mockDocumentService,
executionWorkspaceService: () => ({}),
feedbackService: () => ({
listIssueVotesForUser: vi.fn(async () => []),
saveIssueVote: vi.fn(async () => ({ vote: null, consentEnabledNow: false, sharingEnabled: false })),
}),
goalService: () => ({}),
heartbeatService: () => ({
wakeup: vi.fn(async () => undefined),
reportRunActivity: vi.fn(async () => undefined),
getRun: vi.fn(async () => null),
getActiveRunForAgent: vi.fn(async () => null),
cancelRun: vi.fn(async () => null),
}),
instanceSettingsService: () => ({
get: vi.fn(async () => ({
id: "instance-settings-1",
general: {
censorUsernameInLogs: false,
feedbackDataSharingPreference: "prompt",
},
})),
listCompanyIds: vi.fn(async () => [companyId]),
}),
issueApprovalService: () => ({}),
issueReferenceService: () => ({
deleteDocumentSource: async () => undefined,
diffIssueReferenceSummary: () => ({
addedReferencedIssues: [],
removedReferencedIssues: [],
currentReferencedIssues: [],
vi.doMock("../telemetry.js", () => ({
getTelemetryClient: vi.fn(() => ({ track: vi.fn() })),
}));
vi.doMock("../services/access.js", () => ({
accessService: () => mockAccessService,
}));
vi.doMock("../services/agents.js", () => ({
agentService: () => mockAgentService,
}));
vi.doMock("../services/documents.js", () => ({
documentService: () => mockDocumentService,
}));
vi.doMock("../services/issues.js", () => ({
issueService: () => mockIssueService,
}));
vi.doMock("../services/work-products.js", () => ({
workProductService: () => mockWorkProductService,
}));
vi.doMock("../services/activity-log.js", () => ({
logActivity: vi.fn(async () => undefined),
}));
vi.doMock("../services/index.js", () => ({
accessService: () => mockAccessService,
agentService: () => mockAgentService,
documentService: () => mockDocumentService,
executionWorkspaceService: () => ({}),
feedbackService: () => ({
listIssueVotesForUser: vi.fn(async () => []),
saveIssueVote: vi.fn(async () => ({ vote: null, consentEnabledNow: false, sharingEnabled: false })),
}),
emptySummary: () => ({ outbound: [], inbound: [] }),
listIssueReferenceSummary: async () => ({ outbound: [], inbound: [] }),
syncComment: async () => undefined,
syncDocument: async () => undefined,
syncIssue: async () => undefined,
}),
issueService: () => mockIssueService,
logActivity: vi.fn(async () => undefined),
projectService: () => ({}),
routineService: () => ({
syncRunStatusForIssue: vi.fn(async () => undefined),
}),
workProductService: () => mockWorkProductService,
}));
goalService: () => ({}),
heartbeatService: () => ({
wakeup: vi.fn(async () => undefined),
reportRunActivity: vi.fn(async () => undefined),
getRun: vi.fn(async () => null),
getActiveRunForAgent: vi.fn(async () => null),
cancelRun: vi.fn(async () => null),
}),
instanceSettingsService: () => ({
get: vi.fn(async () => ({
id: "instance-settings-1",
general: {
censorUsernameInLogs: false,
feedbackDataSharingPreference: "prompt",
},
})),
listCompanyIds: vi.fn(async () => [companyId]),
}),
issueApprovalService: () => ({}),
issueReferenceService: () => ({
deleteDocumentSource: async () => undefined,
diffIssueReferenceSummary: () => ({
addedReferencedIssues: [],
removedReferencedIssues: [],
currentReferencedIssues: [],
}),
emptySummary: () => ({ outbound: [], inbound: [] }),
listIssueReferenceSummary: async () => ({ outbound: [], inbound: [] }),
syncComment: async () => undefined,
syncDocument: async () => undefined,
syncIssue: async () => undefined,
}),
issueService: () => mockIssueService,
issueThreadInteractionService: () => mockIssueThreadInteractionService,
logActivity: vi.fn(async () => undefined),
projectService: () => ({}),
routineService: () => ({
syncRunStatusForIssue: vi.fn(async () => undefined),
}),
workProductService: () => mockWorkProductService,
}));
}
function makeIssue(overrides: Record<string, unknown> = {}) {
return {
@ -146,7 +175,11 @@ function makeAgent(id: string, overrides: Record<string, unknown> = {}) {
};
}
function createApp(actor: Record<string, unknown>) {
async function createApp(actor: Record<string, unknown>) {
const [{ errorHandler }, { issueRoutes }] = await Promise.all([
vi.importActual<typeof import("../middleware/index.js")>("../middleware/index.js"),
vi.importActual<typeof import("../routes/issues.js")>("../routes/issues.js"),
]);
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
@ -191,7 +224,46 @@ function boardActor() {
describe("agent issue mutation checkout ownership", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.resetModules();
vi.doUnmock("@paperclipai/shared/telemetry");
vi.doUnmock("../telemetry.js");
vi.doUnmock("../services/access.js");
vi.doUnmock("../services/activity-log.js");
vi.doUnmock("../services/agents.js");
vi.doUnmock("../services/documents.js");
vi.doUnmock("../services/index.js");
vi.doUnmock("../services/issues.js");
vi.doUnmock("../services/work-products.js");
vi.doUnmock("../routes/issues.js");
vi.doUnmock("../routes/authz.js");
vi.doUnmock("../middleware/index.js");
registerRouteMocks();
vi.resetAllMocks();
mockAccessService.canUser.mockReset();
mockAccessService.hasPermission.mockReset();
mockAgentService.getById.mockReset();
mockAgentService.list.mockReset();
mockAgentService.resolveByReference.mockReset();
mockIssueService.addComment.mockReset();
mockIssueService.assertCheckoutOwner.mockReset();
mockIssueService.getAttachmentById.mockReset();
mockIssueService.getByIdentifier.mockReset();
mockIssueService.getById.mockReset();
mockIssueService.getRelationSummaries.mockReset();
mockIssueService.getWakeableParentAfterChildCompletion.mockReset();
mockIssueService.listAttachments.mockReset();
mockIssueService.listWakeableBlockedDependents.mockReset();
mockIssueService.remove.mockReset();
mockIssueService.removeAttachment.mockReset();
mockIssueService.update.mockReset();
mockIssueService.findMentionedAgents.mockReset();
mockDocumentService.upsertIssueDocument.mockReset();
mockWorkProductService.getById.mockReset();
mockWorkProductService.update.mockReset();
mockStorageService.putFile.mockReset();
mockStorageService.getObject.mockReset();
mockStorageService.headObject.mockReset();
mockStorageService.deleteObject.mockReset();
mockAccessService.canUser.mockResolvedValue(true);
mockAccessService.hasPermission.mockResolvedValue(false);
mockAgentService.getById.mockImplementation(async (id: string) => {
@ -295,7 +367,7 @@ describe("agent issue mutation checkout ownership", () => {
],
["attachment delete", (app: express.Express) => request(app).delete("/api/attachments/attachment-1")],
])("rejects peer agent %s on another agent's active checkout", async (_name, sendRequest) => {
const res = await sendRequest(createApp(peerActor()));
const res = await sendRequest(await createApp(peerActor()));
expect(res.status, JSON.stringify(res.body)).toBe(409);
expect(res.body.error).toBe("Issue is checked out by another agent");
@ -309,7 +381,7 @@ describe("agent issue mutation checkout ownership", () => {
});
it("allows the checked-out owner with the matching run id to patch and update documents", async () => {
const app = createApp(ownerActor());
const app = await createApp(ownerActor());
await request(app).patch(`/api/issues/${issueId}`).send({ title: "Updated" }).expect(200);
await request(app)
@ -330,7 +402,7 @@ describe("agent issue mutation checkout ownership", () => {
});
it("preserves board mutations on active checkouts", async () => {
const app = createApp(boardActor());
const app = await createApp(boardActor());
await request(app).patch(`/api/issues/${issueId}`).send({ title: "Board update" }).expect(200);
await request(app)
@ -351,7 +423,7 @@ describe("agent issue mutation checkout ownership", () => {
permissionKey: string,
) => principalId === peerAgentId && permissionKey === "tasks:manage_active_checkouts");
const res = await request(createApp(peerActor())).patch(`/api/issues/${issueId}`).send({ title: "Managed update" });
const res = await request(await createApp(peerActor())).patch(`/api/issues/${issueId}`).send({ title: "Managed update" });
expect(res.status).toBe(200);
expect(mockIssueService.assertCheckoutOwner).not.toHaveBeenCalled();
@ -365,7 +437,7 @@ describe("agent issue mutation checkout ownership", () => {
...patch,
}));
const res = await request(createApp(peerActor())).patch(`/api/issues/${issueId}`).send({ title: "Todo update" });
const res = await request(await createApp(peerActor())).patch(`/api/issues/${issueId}`).send({ title: "Todo update" });
expect(res.status).toBe(200);
expect(mockIssueService.assertCheckoutOwner).not.toHaveBeenCalled();
@ -379,10 +451,14 @@ describe("agent issue mutation checkout ownership", () => {
...patch,
}));
const res = await request(createApp(peerActor())).patch(`/api/issues/${issueId}`).send({ title: "Claimable update" });
const res = await request(await createApp(peerActor())).patch(`/api/issues/${issueId}`).send({ title: "Claimable update" });
expect(res.status).toBe(200);
expect(mockIssueService.assertCheckoutOwner).not.toHaveBeenCalled();
expect(mockIssueService.update).toHaveBeenCalled();
expect(res.body).toMatchObject({
id: issueId,
assigneeAgentId: null,
title: "Claimable update",
});
});
});

View file

@ -23,6 +23,14 @@ function registerRouteMocks() {
getTelemetryClient: vi.fn(() => ({ track: vi.fn() })),
}));
vi.doMock("../services/issues.js", () => ({
issueService: () => mockIssueService,
}));
vi.doMock("../services/activity-log.js", () => ({
logActivity: mockLogActivity,
}));
vi.doMock("../services/index.js", () => ({
accessService: () => ({
canUser: vi.fn(),
@ -118,8 +126,8 @@ function createStorageService(): TestStorageService {
async function createApp(storage: StorageService) {
const [{ errorHandler }, { issueRoutes }] = await Promise.all([
import("../middleware/index.js"),
import("../routes/issues.js"),
vi.importActual<typeof import("../middleware/index.js")>("../middleware/index.js"),
vi.importActual<typeof import("../routes/issues.js")>("../routes/issues.js"),
]);
const app = express();
app.use((req, _res, next) => {
@ -161,8 +169,16 @@ function makeAttachment(contentType: string, originalFilename: string) {
describe("issue attachment routes", () => {
beforeEach(() => {
vi.resetModules();
vi.doUnmock("@paperclipai/shared/telemetry");
vi.doUnmock("../telemetry.js");
vi.doUnmock("../services/issues.js");
vi.doUnmock("../services/index.js");
vi.doUnmock("../services/activity-log.js");
vi.doUnmock("../routes/issues.js");
vi.doUnmock("../routes/authz.js");
vi.doUnmock("../middleware/index.js");
registerRouteMocks();
vi.clearAllMocks();
vi.resetAllMocks();
mockLogActivity.mockResolvedValue(undefined);
});

View file

@ -38,6 +38,8 @@ const mockProjectService = vi.hoisted(() => ({
const mockLogActivity = vi.hoisted(() => vi.fn(async () => undefined));
function registerServiceMocks() {
vi.doMock("../routes/authz.js", async () => vi.importActual("../routes/authz.js"));
vi.doMock("@paperclipai/shared/telemetry", () => ({
trackAgentTaskCompleted: vi.fn(),
trackErrorHandlerCrash: vi.fn(),
@ -47,6 +49,30 @@ function registerServiceMocks() {
getTelemetryClient: vi.fn(() => ({ track: vi.fn() })),
}));
vi.doMock("../services/access.js", () => ({
accessService: () => mockAccessService,
}));
vi.doMock("../services/activity-log.js", () => ({
logActivity: mockLogActivity,
}));
vi.doMock("../services/execution-workspaces.js", () => ({
executionWorkspaceService: () => mockExecutionWorkspaceService,
}));
vi.doMock("../services/heartbeat.js", () => ({
heartbeatService: () => mockHeartbeatService,
}));
vi.doMock("../services/issues.js", () => ({
issueService: () => mockIssueService,
}));
vi.doMock("../services/projects.js", () => ({
projectService: () => mockProjectService,
}));
vi.doMock("../services/index.js", () => ({
accessService: () => mockAccessService,
agentService: () => ({
@ -152,7 +178,13 @@ describe("closed isolated workspace issue routes", () => {
vi.resetModules();
vi.doUnmock("@paperclipai/shared/telemetry");
vi.doUnmock("../telemetry.js");
vi.doUnmock("../services/access.js");
vi.doUnmock("../services/activity-log.js");
vi.doUnmock("../services/execution-workspaces.js");
vi.doUnmock("../services/heartbeat.js");
vi.doUnmock("../services/index.js");
vi.doUnmock("../services/issues.js");
vi.doUnmock("../services/projects.js");
vi.doUnmock("../routes/issues.js");
vi.doUnmock("../routes/authz.js");
vi.doUnmock("../middleware/index.js");

View file

@ -20,57 +20,90 @@ const mockHeartbeatService = vi.hoisted(() => ({
}));
const mockLogActivity = vi.hoisted(() => vi.fn(async () => undefined));
vi.mock("@paperclipai/shared/telemetry", () => ({
trackAgentTaskCompleted: vi.fn(),
trackErrorHandlerCrash: vi.fn(),
const mockFeedbackService = vi.hoisted(() => ({
listIssueVotesForUser: vi.fn(async () => []),
saveIssueVote: vi.fn(async () => ({ vote: null, consentEnabledNow: false, sharingEnabled: false })),
}));
const mockInstanceSettingsService = vi.hoisted(() => ({
get: vi.fn(async () => ({
id: "instance-settings-1",
general: {
censorUsernameInLogs: false,
feedbackDataSharingPreference: "prompt",
},
})),
listCompanyIds: vi.fn(async () => ["company-1"]),
}));
const mockIssueThreadInteractionService = vi.hoisted(() => ({
expireRequestConfirmationsSupersededByComment: vi.fn(async () => []),
expireStaleRequestConfirmationsForIssueDocument: vi.fn(async () => []),
}));
vi.mock("../telemetry.js", () => ({
getTelemetryClient: vi.fn(() => ({ track: vi.fn() })),
}));
function registerModuleMocks() {
vi.doMock("@paperclipai/shared/telemetry", () => ({
trackAgentTaskCompleted: vi.fn(),
trackErrorHandlerCrash: vi.fn(),
}));
vi.mock("../services/index.js", () => ({
accessService: () => mockAccessService,
agentService: () => ({ getById: vi.fn(async () => null) }),
documentService: () => ({}),
executionWorkspaceService: () => ({}),
feedbackService: () => ({
listIssueVotesForUser: vi.fn(async () => []),
saveIssueVote: vi.fn(async () => ({ vote: null, consentEnabledNow: false, sharingEnabled: false })),
}),
goalService: () => ({}),
heartbeatService: () => mockHeartbeatService,
instanceSettingsService: () => ({
get: vi.fn(async () => ({
id: "instance-settings-1",
general: {
censorUsernameInLogs: false,
feedbackDataSharingPreference: "prompt",
},
})),
listCompanyIds: vi.fn(async () => ["company-1"]),
}),
issueApprovalService: () => ({}),
issueReferenceService: () => ({
deleteDocumentSource: async () => undefined,
diffIssueReferenceSummary: () => ({
addedReferencedIssues: [],
removedReferencedIssues: [],
currentReferencedIssues: [],
vi.doMock("../telemetry.js", () => ({
getTelemetryClient: vi.fn(() => ({ track: vi.fn() })),
}));
vi.doMock("../services/access.js", () => ({
accessService: () => mockAccessService,
}));
vi.doMock("../services/activity-log.js", () => ({
logActivity: mockLogActivity,
}));
vi.doMock("../services/feedback.js", () => ({
feedbackService: () => mockFeedbackService,
}));
vi.doMock("../services/heartbeat.js", () => ({
heartbeatService: () => mockHeartbeatService,
}));
vi.doMock("../services/instance-settings.js", () => ({
instanceSettingsService: () => mockInstanceSettingsService,
}));
vi.doMock("../services/issues.js", () => ({
issueService: () => mockIssueService,
}));
vi.doMock("../services/index.js", () => ({
accessService: () => mockAccessService,
agentService: () => ({ getById: vi.fn(async () => null) }),
documentService: () => ({}),
executionWorkspaceService: () => ({}),
feedbackService: () => mockFeedbackService,
goalService: () => ({}),
heartbeatService: () => mockHeartbeatService,
instanceSettingsService: () => mockInstanceSettingsService,
issueApprovalService: () => ({}),
issueReferenceService: () => ({
deleteDocumentSource: async () => undefined,
diffIssueReferenceSummary: () => ({
addedReferencedIssues: [],
removedReferencedIssues: [],
currentReferencedIssues: [],
}),
emptySummary: () => ({ outbound: [], inbound: [] }),
listIssueReferenceSummary: async () => ({ outbound: [], inbound: [] }),
syncComment: async () => undefined,
syncDocument: async () => undefined,
syncIssue: async () => undefined,
}),
emptySummary: () => ({ outbound: [], inbound: [] }),
listIssueReferenceSummary: async () => ({ outbound: [], inbound: [] }),
syncComment: async () => undefined,
syncDocument: async () => undefined,
syncIssue: async () => undefined,
}),
issueService: () => mockIssueService,
logActivity: mockLogActivity,
projectService: () => ({}),
routineService: () => ({ syncRunStatusForIssue: vi.fn(async () => undefined) }),
workProductService: () => ({}),
}));
issueService: () => mockIssueService,
issueThreadInteractionService: () => mockIssueThreadInteractionService,
logActivity: mockLogActivity,
projectService: () => ({}),
routineService: () => ({ syncRunStatusForIssue: vi.fn(async () => undefined) }),
workProductService: () => ({}),
}));
}
function createApp() {
const app = express();
@ -80,8 +113,8 @@ function createApp() {
async function installActor(app: express.Express, actor?: Record<string, unknown>) {
const [{ issueRoutes }, { errorHandler }] = await Promise.all([
import("../routes/issues.js"),
import("../middleware/index.js"),
vi.importActual<typeof import("../routes/issues.js")>("../routes/issues.js"),
vi.importActual<typeof import("../middleware/index.js")>("../middleware/index.js"),
]);
app.use((req, _res, next) => {
@ -129,6 +162,19 @@ function makeComment(overrides: Record<string, unknown> = {}) {
describe("issue comment cancel routes", () => {
beforeEach(() => {
vi.resetModules();
vi.doUnmock("@paperclipai/shared/telemetry");
vi.doUnmock("../telemetry.js");
vi.doUnmock("../services/access.js");
vi.doUnmock("../services/activity-log.js");
vi.doUnmock("../services/feedback.js");
vi.doUnmock("../services/heartbeat.js");
vi.doUnmock("../services/index.js");
vi.doUnmock("../services/instance-settings.js");
vi.doUnmock("../services/issues.js");
vi.doUnmock("../routes/issues.js");
vi.doUnmock("../routes/authz.js");
vi.doUnmock("../middleware/index.js");
registerModuleMocks();
vi.resetAllMocks();
mockIssueService.getById.mockResolvedValue(makeIssue());
mockIssueService.assertCheckoutOwner.mockResolvedValue({ adoptedFromRunId: null });
@ -136,6 +182,12 @@ describe("issue comment cancel routes", () => {
mockIssueService.removeComment.mockResolvedValue(makeComment());
mockAccessService.canUser.mockResolvedValue(false);
mockAccessService.hasPermission.mockResolvedValue(false);
mockFeedbackService.listIssueVotesForUser.mockResolvedValue([]);
mockFeedbackService.saveIssueVote.mockResolvedValue({
vote: null,
consentEnabledNow: false,
sharingEnabled: false,
});
mockHeartbeatService.getRun.mockResolvedValue({
id: "run-1",
companyId: "company-1",
@ -145,6 +197,14 @@ describe("issue comment cancel routes", () => {
createdAt: new Date("2026-04-11T14:59:00.000Z"),
});
mockHeartbeatService.getActiveRunForAgent.mockResolvedValue(null);
mockInstanceSettingsService.get.mockResolvedValue({
id: "instance-settings-1",
general: {
censorUsernameInLogs: false,
feedbackDataSharingPreference: "prompt",
},
});
mockInstanceSettingsService.listCompanyIds.mockResolvedValue(["company-1"]);
mockLogActivity.mockResolvedValue(undefined);
});

View file

@ -57,44 +57,9 @@ const mockInstanceSettingsService = vi.hoisted(() => ({
const mockRoutineService = vi.hoisted(() => ({
syncRunStatusForIssue: vi.fn(async () => undefined),
}));
vi.mock("@paperclipai/shared/telemetry", () => ({
trackAgentTaskCompleted: vi.fn(),
trackErrorHandlerCrash: vi.fn(),
}));
vi.mock("../telemetry.js", () => ({
getTelemetryClient: vi.fn(() => ({ track: vi.fn() })),
}));
vi.mock("../services/index.js", () => ({
accessService: () => mockAccessService,
agentService: () => mockAgentService,
documentService: () => ({}),
executionWorkspaceService: () => ({}),
feedbackService: () => mockFeedbackService,
goalService: () => ({}),
heartbeatService: () => mockHeartbeatService,
instanceSettingsService: () => mockInstanceSettingsService,
issueApprovalService: () => ({}),
issueReferenceService: () => ({
deleteDocumentSource: async () => undefined,
diffIssueReferenceSummary: () => ({
addedReferencedIssues: [],
removedReferencedIssues: [],
currentReferencedIssues: [],
}),
emptySummary: () => ({ outbound: [], inbound: [] }),
listIssueReferenceSummary: async () => ({ outbound: [], inbound: [] }),
syncComment: async () => undefined,
syncDocument: async () => undefined,
syncIssue: async () => undefined,
}),
issueService: () => mockIssueService,
logActivity: mockLogActivity,
projectService: () => ({}),
routineService: () => mockRoutineService,
workProductService: () => ({}),
const mockIssueThreadInteractionService = vi.hoisted(() => ({
expireRequestConfirmationsSupersededByComment: vi.fn(async () => []),
expireStaleRequestConfirmationsForIssueDocument: vi.fn(async () => []),
}));
function registerModuleMocks() {
@ -107,6 +72,38 @@ function registerModuleMocks() {
getTelemetryClient: vi.fn(() => ({ track: vi.fn() })),
}));
vi.doMock("../services/access.js", () => ({
accessService: () => mockAccessService,
}));
vi.doMock("../services/activity-log.js", () => ({
logActivity: mockLogActivity,
}));
vi.doMock("../services/agents.js", () => ({
agentService: () => mockAgentService,
}));
vi.doMock("../services/feedback.js", () => ({
feedbackService: () => mockFeedbackService,
}));
vi.doMock("../services/heartbeat.js", () => ({
heartbeatService: () => mockHeartbeatService,
}));
vi.doMock("../services/instance-settings.js", () => ({
instanceSettingsService: () => mockInstanceSettingsService,
}));
vi.doMock("../services/issues.js", () => ({
issueService: () => mockIssueService,
}));
vi.doMock("../services/routines.js", () => ({
routineService: () => mockRoutineService,
}));
vi.doMock("../services/index.js", () => ({
accessService: () => mockAccessService,
agentService: () => mockAgentService,
@ -131,6 +128,7 @@ function registerModuleMocks() {
syncIssue: async () => undefined,
}),
issueService: () => mockIssueService,
issueThreadInteractionService: () => mockIssueThreadInteractionService,
logActivity: mockLogActivity,
projectService: () => ({}),
routineService: () => mockRoutineService,
@ -191,6 +189,17 @@ function makeIssue(status: "todo" | "done" | "blocked") {
describe("issue comment reopen routes", () => {
beforeEach(() => {
vi.resetModules();
vi.doUnmock("@paperclipai/shared/telemetry");
vi.doUnmock("../telemetry.js");
vi.doUnmock("../services/access.js");
vi.doUnmock("../services/activity-log.js");
vi.doUnmock("../services/agents.js");
vi.doUnmock("../services/feedback.js");
vi.doUnmock("../services/heartbeat.js");
vi.doUnmock("../services/index.js");
vi.doUnmock("../services/instance-settings.js");
vi.doUnmock("../services/issues.js");
vi.doUnmock("../services/routines.js");
vi.doUnmock("../routes/issues.js");
vi.doUnmock("../routes/authz.js");
vi.doUnmock("../middleware/index.js");
@ -789,26 +798,20 @@ describe("issue comment reopen routes", () => {
});
expect(res.status).toBe(200);
expect(mockIssueService.update).toHaveBeenCalledWith(
"11111111-1111-4111-8111-111111111111",
expect.objectContaining({
status: "in_review",
assigneeAgentId: "33333333-3333-4333-8333-333333333333",
assigneeUserId: null,
executionState: expect.objectContaining({
status: "pending",
currentStageType: "review",
currentParticipant: expect.objectContaining({
type: "agent",
agentId: "33333333-3333-4333-8333-333333333333",
}),
returnAssignee: expect.objectContaining({
type: "agent",
agentId: "22222222-2222-4222-8222-222222222222",
}),
}),
}),
);
expect(res.body.assigneeAgentId).toBe("33333333-3333-4333-8333-333333333333");
expect(res.body.assigneeUserId).toBeNull();
expect(res.body.executionState).toMatchObject({
status: "pending",
currentStageType: "review",
currentParticipant: {
type: "agent",
agentId: "33333333-3333-4333-8333-333333333333",
},
returnAssignee: {
type: "agent",
agentId: "22222222-2222-4222-8222-222222222222",
},
});
expect(mockHeartbeatService.wakeup).toHaveBeenCalledWith(
"33333333-3333-4333-8333-333333333333",
expect.objectContaining({

View file

@ -25,46 +25,88 @@ const mockAgentService = vi.hoisted(() => ({
}));
const mockLogActivity = vi.hoisted(() => vi.fn(async () => undefined));
vi.mock("../services/index.js", () => ({
accessService: () => mockAccessService,
agentService: () => mockAgentService,
documentService: () => mockDocumentsService,
executionWorkspaceService: () => ({}),
feedbackService: () => ({}),
goalService: () => ({}),
heartbeatService: () => ({
wakeup: vi.fn(async () => undefined),
reportRunActivity: vi.fn(async () => undefined),
}),
instanceSettingsService: () => ({
getExperimental: vi.fn(async () => ({})),
getGeneral: vi.fn(async () => ({ feedbackDataSharingPreference: "prompt" })),
}),
issueApprovalService: () => ({}),
issueReferenceService: () => ({
deleteDocumentSource: async () => undefined,
diffIssueReferenceSummary: () => ({
addedReferencedIssues: [],
removedReferencedIssues: [],
currentReferencedIssues: [],
}),
emptySummary: () => ({ outbound: [], inbound: [] }),
listIssueReferenceSummary: async () => ({ outbound: [], inbound: [] }),
syncComment: async () => undefined,
syncDocument: async () => undefined,
syncIssue: async () => undefined,
}),
issueService: () => mockIssueService,
logActivity: mockLogActivity,
projectService: () => ({}),
routineService: () => ({
syncRunStatusForIssue: vi.fn(async () => undefined),
}),
workProductService: () => ({}),
const mockHeartbeatService = vi.hoisted(() => ({
wakeup: vi.fn(async () => undefined),
reportRunActivity: vi.fn(async () => undefined),
}));
const mockInstanceSettingsService = vi.hoisted(() => ({
get: vi.fn(async () => ({
id: "instance-settings-1",
general: {
censorUsernameInLogs: false,
feedbackDataSharingPreference: "prompt",
},
})),
getExperimental: vi.fn(async () => ({})),
getGeneral: vi.fn(async () => ({ feedbackDataSharingPreference: "prompt" })),
listCompanyIds: vi.fn(async () => [companyId]),
}));
const mockRoutineService = vi.hoisted(() => ({
syncRunStatusForIssue: vi.fn(async () => undefined),
}));
const mockIssueThreadInteractionService = vi.hoisted(() => ({
expireRequestConfirmationsSupersededByComment: vi.fn(async () => []),
expireStaleRequestConfirmationsForIssueDocument: vi.fn(async () => []),
}));
const planDocument = {
id: "document-1",
companyId,
issueId,
key: "plan",
title: "Plan",
format: "markdown",
body: "# Plan",
latestRevisionId: "revision-2",
latestRevisionNumber: 2,
createdByAgentId: null,
createdByUserId: "board-user",
updatedByAgentId: null,
updatedByUserId: "board-user",
createdAt: new Date("2026-03-26T12:00:00.000Z"),
updatedAt: new Date("2026-03-26T12:10:00.000Z"),
};
const systemDocument = {
...planDocument,
id: "document-2",
key: "system-plan",
title: "System plan",
};
function registerModuleMocks() {
vi.doMock("../services/access.js", () => ({
accessService: () => mockAccessService,
}));
vi.doMock("../services/activity-log.js", () => ({
logActivity: mockLogActivity,
}));
vi.doMock("../services/agents.js", () => ({
agentService: () => mockAgentService,
}));
vi.doMock("../services/documents.js", () => ({
documentService: () => mockDocumentsService,
}));
vi.doMock("../services/heartbeat.js", () => ({
heartbeatService: () => mockHeartbeatService,
}));
vi.doMock("../services/instance-settings.js", () => ({
instanceSettingsService: () => mockInstanceSettingsService,
}));
vi.doMock("../services/issues.js", () => ({
issueService: () => mockIssueService,
}));
vi.doMock("../services/routines.js", () => ({
routineService: () => mockRoutineService,
}));
vi.doMock("../services/index.js", () => ({
accessService: () => mockAccessService,
agentService: () => mockAgentService,
@ -72,14 +114,8 @@ function registerModuleMocks() {
executionWorkspaceService: () => ({}),
feedbackService: () => ({}),
goalService: () => ({}),
heartbeatService: () => ({
wakeup: vi.fn(async () => undefined),
reportRunActivity: vi.fn(async () => undefined),
}),
instanceSettingsService: () => ({
getExperimental: vi.fn(async () => ({})),
getGeneral: vi.fn(async () => ({ feedbackDataSharingPreference: "prompt" })),
}),
heartbeatService: () => mockHeartbeatService,
instanceSettingsService: () => mockInstanceSettingsService,
issueApprovalService: () => ({}),
issueReferenceService: () => ({
deleteDocumentSource: async () => undefined,
@ -95,11 +131,10 @@ function registerModuleMocks() {
syncIssue: async () => undefined,
}),
issueService: () => mockIssueService,
issueThreadInteractionService: () => mockIssueThreadInteractionService,
logActivity: mockLogActivity,
projectService: () => ({}),
routineService: () => ({
syncRunStatusForIssue: vi.fn(async () => undefined),
}),
routineService: () => mockRoutineService,
workProductService: () => ({}),
}));
}
@ -129,8 +164,17 @@ async function createApp() {
describe("issue document revision routes", () => {
beforeEach(() => {
vi.resetModules();
vi.doUnmock("../services/access.js");
vi.doUnmock("../services/activity-log.js");
vi.doUnmock("../services/agents.js");
vi.doUnmock("../services/documents.js");
vi.doUnmock("../services/heartbeat.js");
vi.doUnmock("../services/routines.js");
vi.doUnmock("../services/index.js");
vi.doUnmock("../services/instance-settings.js");
vi.doUnmock("../services/issues.js");
vi.doUnmock("../routes/issues.js");
vi.doUnmock("../routes/authz.js");
vi.doUnmock("../middleware/index.js");
registerModuleMocks();
vi.resetAllMocks();
@ -141,25 +185,10 @@ describe("issue document revision routes", () => {
title: "Document revisions",
status: "in_progress",
});
mockDocumentsService.listIssueDocuments.mockResolvedValue([
{
id: "document-1",
companyId,
issueId,
key: "plan",
title: "Plan",
format: "markdown",
body: "# Plan",
latestRevisionId: "revision-2",
latestRevisionNumber: 2,
createdByAgentId: null,
createdByUserId: "board-user",
updatedByAgentId: null,
updatedByUserId: "board-user",
createdAt: new Date("2026-03-26T12:00:00.000Z"),
updatedAt: new Date("2026-03-26T12:10:00.000Z"),
},
]);
mockDocumentsService.listIssueDocuments.mockImplementation(
async (_issueId, options: { includeSystem?: boolean } | undefined) =>
options?.includeSystem ? [planDocument, systemDocument] : [planDocument],
);
mockDocumentsService.listIssueDocumentRevisions.mockResolvedValue([
{
id: "revision-2",
@ -198,6 +227,19 @@ describe("issue document revision routes", () => {
updatedAt: new Date("2026-03-26T12:10:00.000Z"),
},
});
mockHeartbeatService.wakeup.mockResolvedValue(undefined);
mockHeartbeatService.reportRunActivity.mockResolvedValue(undefined);
mockInstanceSettingsService.get.mockResolvedValue({
id: "instance-settings-1",
general: {
censorUsernameInLogs: false,
feedbackDataSharingPreference: "prompt",
},
});
mockInstanceSettingsService.getExperimental.mockResolvedValue({});
mockInstanceSettingsService.getGeneral.mockResolvedValue({ feedbackDataSharingPreference: "prompt" });
mockInstanceSettingsService.listCompanyIds.mockResolvedValue([companyId]);
mockRoutineService.syncRunStatusForIssue.mockResolvedValue(undefined);
mockLogActivity.mockResolvedValue(undefined);
});
@ -219,14 +261,19 @@ describe("issue document revision routes", () => {
const res = await request(await createApp()).get(`/api/issues/${issueId}/documents`);
expect(res.status).toBe(200);
expect(mockDocumentsService.listIssueDocuments).toHaveBeenCalledWith(issueId, { includeSystem: false });
expect(res.body).toEqual([expect.objectContaining({ key: "plan" })]);
});
it("passes includeSystem=true through for debug document listing", async () => {
await request(await createApp()).get(`/api/issues/${issueId}/documents?includeSystem=true`);
const res = await request(await createApp()).get(
`/api/issues/${issueId}/documents?includeSystem=true`,
);
expect(mockDocumentsService.listIssueDocuments).toHaveBeenCalledWith(issueId, { includeSystem: true });
expect(res.status).toBe(200);
expect(res.body).toEqual([
expect.objectContaining({ key: "plan" }),
expect.objectContaining({ key: "system-plan" }),
]);
});
it("restores a revision through the append-only route and logs the action", async () => {

View file

@ -49,6 +49,10 @@ const mockRoutineService = vi.hoisted(() => ({
syncRunStatusForIssue: vi.fn(async () => undefined),
}));
const mockLogActivity = vi.hoisted(() => vi.fn(async () => undefined));
const mockIssueThreadInteractionService = vi.hoisted(() => ({
expireRequestConfirmationsSupersededByComment: vi.fn(async () => []),
expireStaleRequestConfirmationsForIssueDocument: vi.fn(async () => []),
}));
function registerModuleMocks() {
vi.doMock("@paperclipai/shared/telemetry", () => ({
@ -84,6 +88,7 @@ function registerModuleMocks() {
syncIssue: async () => undefined,
}),
issueService: () => mockIssueService,
issueThreadInteractionService: () => mockIssueThreadInteractionService,
logActivity: mockLogActivity,
projectService: () => ({}),
routineService: () => mockRoutineService,

View file

@ -0,0 +1,584 @@
import express from "express";
import request from "supertest";
import { beforeEach, describe, expect, it, vi } from "vitest";
const ASSIGNEE_AGENT_ID = "11111111-1111-4111-8111-111111111111";
const CREATED_AGENT_ID = "22222222-2222-4222-8222-222222222222";
const mockIssueService = vi.hoisted(() => ({
getById: vi.fn(),
}));
const mockInteractionService = vi.hoisted(() => ({
listForIssue: vi.fn(),
create: vi.fn(),
acceptInteraction: vi.fn(),
acceptSuggestedTasks: vi.fn(),
rejectInteraction: vi.fn(),
rejectSuggestedTasks: vi.fn(),
answerQuestions: vi.fn(),
}));
const mockHeartbeatService = vi.hoisted(() => ({
wakeup: vi.fn(async () => undefined),
}));
const mockLogActivity = vi.hoisted(() => vi.fn(async () => undefined));
vi.mock("@paperclipai/shared/telemetry", () => ({
trackAgentTaskCompleted: vi.fn(),
trackErrorHandlerCrash: vi.fn(),
}));
vi.mock("../telemetry.js", () => ({
getTelemetryClient: vi.fn(() => ({ track: vi.fn() })),
}));
function registerModuleMocks() {
vi.doMock("../services/index.js", () => ({
accessService: () => ({
canUser: vi.fn(async () => true),
hasPermission: vi.fn(async () => true),
}),
agentService: () => ({
getById: vi.fn(async () => null),
resolveByReference: vi.fn(async (_companyId: string, raw: string) => ({
ambiguous: false,
agent: { id: raw },
})),
}),
clampIssueListLimit: (value: number) => value,
ISSUE_LIST_DEFAULT_LIMIT: 500,
ISSUE_LIST_MAX_LIMIT: 1000,
documentService: () => ({}),
executionWorkspaceService: () => ({}),
feedbackService: () => ({
listIssueVotesForUser: vi.fn(async () => []),
saveIssueVote: vi.fn(async () => ({ vote: null, consentEnabledNow: false, sharingEnabled: false })),
}),
goalService: () => ({}),
heartbeatService: () => mockHeartbeatService,
instanceSettingsService: () => ({
get: vi.fn(async () => ({
id: "instance-settings-1",
general: {
censorUsernameInLogs: false,
feedbackDataSharingPreference: "prompt",
},
})),
listCompanyIds: vi.fn(async () => ["company-1"]),
}),
issueApprovalService: () => ({}),
issueReferenceService: () => ({
deleteDocumentSource: async () => undefined,
diffIssueReferenceSummary: () => ({
addedReferencedIssues: [],
removedReferencedIssues: [],
currentReferencedIssues: [],
}),
emptySummary: () => ({ outbound: [], inbound: [] }),
listIssueReferenceSummary: async () => ({ outbound: [], inbound: [] }),
syncComment: async () => undefined,
syncDocument: async () => undefined,
syncIssue: async () => undefined,
}),
issueService: () => mockIssueService,
issueThreadInteractionService: () => mockInteractionService,
logActivity: mockLogActivity,
projectService: () => ({}),
routineService: () => ({
syncRunStatusForIssue: vi.fn(async () => undefined),
}),
workProductService: () => ({}),
}));
}
function createIssue(overrides: Record<string, unknown> = {}) {
return {
id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
companyId: "company-1",
status: "in_progress",
priority: "medium",
projectId: null,
goalId: null,
parentId: null,
assigneeAgentId: ASSIGNEE_AGENT_ID,
assigneeUserId: null,
createdByUserId: "local-board",
identifier: "PAP-1714",
title: "Persist interactions",
executionPolicy: null,
executionState: null,
hiddenAt: null,
...overrides,
};
}
async function createApp(actor: Record<string, unknown> = {
type: "board",
userId: "local-board",
companyIds: ["company-1"],
source: "local_implicit",
isInstanceAdmin: false,
}) {
const [{ issueRoutes }, { errorHandler }] = await Promise.all([
import("../routes/issues.js"),
import("../middleware/index.js"),
]);
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
(req as any).actor = actor;
next();
});
app.use("/api", issueRoutes({} as any, {} as any));
app.use(errorHandler);
return app;
}
describe("issue thread interaction routes", () => {
beforeEach(() => {
vi.resetModules();
vi.doUnmock("../routes/issues.js");
vi.doUnmock("../routes/authz.js");
vi.doUnmock("../middleware/index.js");
vi.doUnmock("../services/index.js");
registerModuleMocks();
vi.resetAllMocks();
mockIssueService.getById.mockResolvedValue(createIssue());
mockInteractionService.listForIssue.mockResolvedValue([]);
mockInteractionService.create.mockResolvedValue({
id: "interaction-1",
companyId: "company-1",
issueId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
kind: "suggest_tasks",
status: "pending",
continuationPolicy: "wake_assignee",
idempotencyKey: null,
sourceCommentId: null,
sourceRunId: "run-1",
payload: {
version: 1,
tasks: [{ clientKey: "task-1", title: "One" }],
},
result: null,
createdAt: "2026-04-20T12:00:00.000Z",
updatedAt: "2026-04-20T12:00:00.000Z",
});
mockInteractionService.acceptInteraction.mockResolvedValue({
interaction: {
id: "interaction-1",
companyId: "company-1",
issueId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
kind: "suggest_tasks",
status: "accepted",
continuationPolicy: "wake_assignee",
idempotencyKey: null,
sourceCommentId: "comment-1",
sourceRunId: "run-1",
payload: {
version: 1,
tasks: [{ clientKey: "task-1", title: "One" }],
},
result: {
version: 1,
createdTasks: [{ clientKey: "task-1", issueId: "child-1" }],
skippedClientKeys: ["task-2"],
},
createdAt: "2026-04-20T12:00:00.000Z",
updatedAt: "2026-04-20T12:05:00.000Z",
resolvedAt: "2026-04-20T12:05:00.000Z",
},
createdIssues: [
{
id: "child-1",
assigneeAgentId: CREATED_AGENT_ID,
status: "todo",
},
],
});
mockInteractionService.rejectInteraction.mockResolvedValue({
id: "interaction-1",
companyId: "company-1",
issueId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
kind: "suggest_tasks",
status: "rejected",
continuationPolicy: "wake_assignee",
idempotencyKey: null,
sourceCommentId: "comment-1",
sourceRunId: "run-1",
payload: {
version: 1,
tasks: [{ clientKey: "task-1", title: "One" }],
},
result: {
version: 1,
rejectionReason: "Not actionable enough",
},
createdAt: "2026-04-20T12:00:00.000Z",
updatedAt: "2026-04-20T12:05:00.000Z",
resolvedAt: "2026-04-20T12:05:00.000Z",
});
mockInteractionService.answerQuestions.mockResolvedValue({
id: "interaction-2",
companyId: "company-1",
issueId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
kind: "ask_user_questions",
status: "answered",
continuationPolicy: "wake_assignee",
idempotencyKey: null,
sourceCommentId: "comment-2",
sourceRunId: "run-2",
payload: {
version: 1,
questions: [{
id: "scope",
prompt: "Scope?",
selectionMode: "single",
options: [{ id: "phase-1", label: "Phase 1" }],
}],
},
result: {
version: 1,
answers: [{ questionId: "scope", optionIds: ["phase-1"] }],
},
createdAt: "2026-04-20T12:00:00.000Z",
updatedAt: "2026-04-20T12:06:00.000Z",
resolvedAt: "2026-04-20T12:06:00.000Z",
});
});
it("lists and creates board-authored interactions", async () => {
mockInteractionService.listForIssue.mockResolvedValue([
{ id: "interaction-1", kind: "suggest_tasks", status: "pending" },
]);
const app = await createApp();
const listRes = await request(app).get("/api/issues/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa/interactions");
expect(listRes.status).toBe(200);
expect(listRes.body).toEqual([
{ id: "interaction-1", kind: "suggest_tasks", status: "pending" },
]);
const createRes = await request(app)
.post("/api/issues/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa/interactions")
.send({
kind: "suggest_tasks",
payload: {
version: 1,
tasks: [{ clientKey: "task-1", title: "One" }],
},
});
expect(createRes.status).toBe(201);
expect(mockInteractionService.create).toHaveBeenCalled();
expect(mockLogActivity).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
action: "issue.thread_interaction_created",
details: expect.objectContaining({
interactionId: "interaction-1",
interactionKind: "suggest_tasks",
}),
}),
);
});
it("accepts suggested tasks and wakes created assignees plus the current assignee", async () => {
const app = await createApp();
const res = await request(app)
.post("/api/issues/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa/interactions/interaction-1/accept")
.send({ selectedClientKeys: ["task-1"] });
expect(res.status).toBe(200);
expect(mockInteractionService.acceptInteraction).toHaveBeenCalledWith(
expect.objectContaining({ id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa" }),
"interaction-1",
{ selectedClientKeys: ["task-1"] },
expect.objectContaining({ userId: "local-board" }),
);
expect(mockHeartbeatService.wakeup).toHaveBeenCalledTimes(2);
expect(mockHeartbeatService.wakeup).toHaveBeenNthCalledWith(
1,
CREATED_AGENT_ID,
expect.objectContaining({
source: "assignment",
reason: "issue_assigned",
payload: expect.objectContaining({
issueId: "child-1",
mutation: "interaction_accept",
}),
}),
);
expect(mockHeartbeatService.wakeup).toHaveBeenNthCalledWith(
2,
ASSIGNEE_AGENT_ID,
expect.objectContaining({
source: "automation",
reason: "issue_commented",
payload: expect.objectContaining({
issueId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
interactionId: "interaction-1",
interactionStatus: "accepted",
sourceCommentId: "comment-1",
sourceRunId: "run-1",
}),
}),
);
});
it("answers questions and emits a continuation wake", async () => {
const app = await createApp();
const res = await request(app)
.post("/api/issues/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa/interactions/interaction-2/respond")
.send({
answers: [{ questionId: "scope", optionIds: ["phase-1"] }],
});
expect(res.status).toBe(200);
expect(mockInteractionService.answerQuestions).toHaveBeenCalled();
expect(mockHeartbeatService.wakeup).toHaveBeenCalledWith(
ASSIGNEE_AGENT_ID,
expect.objectContaining({
reason: "issue_commented",
payload: expect.objectContaining({
interactionId: "interaction-2",
interactionKind: "ask_user_questions",
interactionStatus: "answered",
sourceCommentId: "comment-2",
sourceRunId: "run-2",
}),
}),
);
expect(mockLogActivity).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
action: "issue.thread_interaction_answered",
}),
);
});
it("accepts request confirmations and wakes the current assignee when configured for accept-only wakeups", async () => {
mockInteractionService.acceptInteraction.mockResolvedValueOnce({
interaction: {
id: "interaction-3",
companyId: "company-1",
issueId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
kind: "request_confirmation",
status: "accepted",
continuationPolicy: "wake_assignee_on_accept",
idempotencyKey: null,
sourceCommentId: null,
sourceRunId: "run-3",
payload: {
version: 1,
prompt: "Apply this plan?",
},
result: {
version: 1,
outcome: "accepted",
},
createdAt: "2026-04-20T12:00:00.000Z",
updatedAt: "2026-04-20T12:05:00.000Z",
resolvedAt: "2026-04-20T12:05:00.000Z",
},
createdIssues: [],
});
const app = await createApp();
const res = await request(app)
.post("/api/issues/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa/interactions/interaction-3/accept")
.send({});
expect(res.status).toBe(200);
expect(mockHeartbeatService.wakeup).toHaveBeenCalledTimes(1);
expect(mockHeartbeatService.wakeup).toHaveBeenCalledWith(
ASSIGNEE_AGENT_ID,
expect.objectContaining({
reason: "issue_commented",
payload: expect.objectContaining({
interactionId: "interaction-3",
interactionKind: "request_confirmation",
interactionStatus: "accepted",
}),
}),
);
});
it("wakes the returned agent when accepting an agent-authored confirmation from a board review assignee", async () => {
mockIssueService.getById.mockResolvedValueOnce(createIssue({
status: "in_review",
assigneeAgentId: null,
assigneeUserId: "local-board",
}));
mockInteractionService.acceptInteraction.mockResolvedValueOnce({
interaction: {
id: "interaction-4",
companyId: "company-1",
issueId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
kind: "request_confirmation",
status: "accepted",
continuationPolicy: "wake_assignee_on_accept",
idempotencyKey: null,
sourceCommentId: null,
sourceRunId: "run-4",
payload: {
version: 1,
prompt: "Approve this plan?",
},
result: {
version: 1,
outcome: "accepted",
},
createdAt: "2026-04-20T12:00:00.000Z",
updatedAt: "2026-04-20T12:05:00.000Z",
resolvedAt: "2026-04-20T12:05:00.000Z",
},
createdIssues: [],
continuationIssue: {
id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
assigneeAgentId: CREATED_AGENT_ID,
assigneeUserId: null,
status: "todo",
},
});
const app = await createApp();
const res = await request(app)
.post("/api/issues/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa/interactions/interaction-4/accept")
.send({});
expect(res.status).toBe(200);
expect(mockHeartbeatService.wakeup).toHaveBeenCalledTimes(1);
expect(mockHeartbeatService.wakeup).toHaveBeenCalledWith(
CREATED_AGENT_ID,
expect.objectContaining({
source: "automation",
reason: "issue_commented",
payload: expect.objectContaining({
issueId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
interactionId: "interaction-4",
interactionKind: "request_confirmation",
interactionStatus: "accepted",
}),
}),
);
expect(mockLogActivity).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
action: "issue.updated",
details: expect.objectContaining({
source: "request_confirmation_accept",
assigneeAgentId: CREATED_AGENT_ID,
assigneeUserId: null,
_previous: expect.objectContaining({
assigneeUserId: "local-board",
}),
}),
}),
);
});
it("does not emit a continuation wake when request confirmations are rejected", async () => {
mockInteractionService.rejectInteraction.mockResolvedValueOnce({
id: "interaction-3",
companyId: "company-1",
issueId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
kind: "request_confirmation",
status: "rejected",
continuationPolicy: "wake_assignee_on_accept",
idempotencyKey: null,
sourceCommentId: null,
sourceRunId: "run-3",
payload: {
version: 1,
prompt: "Apply this plan?",
},
result: {
version: 1,
outcome: "rejected",
reason: "Needs changes",
},
createdAt: "2026-04-20T12:00:00.000Z",
updatedAt: "2026-04-20T12:05:00.000Z",
resolvedAt: "2026-04-20T12:05:00.000Z",
});
const app = await createApp();
const res = await request(app)
.post("/api/issues/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa/interactions/interaction-3/reject")
.send({ reason: "Needs changes" });
expect(res.status).toBe(200);
expect(mockHeartbeatService.wakeup).not.toHaveBeenCalled();
});
it("does not emit an accept-only continuation wake for rejected suggested tasks", async () => {
mockInteractionService.rejectInteraction.mockResolvedValueOnce({
id: "interaction-5",
companyId: "company-1",
issueId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
kind: "suggest_tasks",
status: "rejected",
continuationPolicy: "wake_assignee_on_accept",
idempotencyKey: null,
sourceCommentId: null,
sourceRunId: "run-5",
payload: {
version: 1,
tasks: [{ clientKey: "task-1", title: "One" }],
},
result: {
version: 1,
rejectionReason: "Not now",
},
createdAt: "2026-04-20T12:00:00.000Z",
updatedAt: "2026-04-20T12:05:00.000Z",
resolvedAt: "2026-04-20T12:05:00.000Z",
});
const app = await createApp();
const res = await request(app)
.post("/api/issues/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa/interactions/interaction-5/reject")
.send({ reason: "Not now" });
expect(res.status).toBe(200);
expect(mockHeartbeatService.wakeup).not.toHaveBeenCalled();
});
it("allows agent-authored interaction creation and stamps the active run id", async () => {
const app = await createApp({
type: "agent",
agentId: CREATED_AGENT_ID,
companyId: "company-1",
runId: "run-1",
});
const res = await request(app)
.post("/api/issues/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa/interactions")
.send({
kind: "suggest_tasks",
idempotencyKey: "interaction:task-1",
payload: {
version: 1,
tasks: [{ clientKey: "task-1", title: "One" }],
},
});
expect(res.status).toBe(201);
expect(mockInteractionService.create).toHaveBeenCalledWith(
expect.objectContaining({ id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa" }),
expect.objectContaining({
kind: "suggest_tasks",
idempotencyKey: "interaction:task-1",
sourceRunId: "run-1",
}),
{
agentId: CREATED_AGENT_ID,
userId: null,
},
);
});
});

View file

@ -0,0 +1,881 @@
import { randomUUID } from "node:crypto";
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
import {
agents,
companies,
createDb,
documentRevisions,
documents,
goals,
heartbeatRuns,
issueDocuments,
instanceSettings,
issueRelations,
issueThreadInteractions,
issues,
} from "@paperclipai/db";
import {
getEmbeddedPostgresTestSupport,
startEmbeddedPostgresTestDatabase,
} from "./helpers/embedded-postgres.js";
import { instanceSettingsService } from "../services/instance-settings.js";
import { issueService } from "../services/issues.js";
import { issueThreadInteractionService } from "../services/issue-thread-interactions.js";
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
describeEmbeddedPostgres("issueThreadInteractionService", () => {
let db!: ReturnType<typeof createDb>;
let issuesSvc!: ReturnType<typeof issueService>;
let interactionsSvc!: ReturnType<typeof issueThreadInteractionService>;
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
beforeAll(async () => {
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-issue-thread-interactions-");
db = createDb(tempDb.connectionString);
issuesSvc = issueService(db);
interactionsSvc = issueThreadInteractionService(db);
}, 20_000);
afterEach(async () => {
await db.delete(issueThreadInteractions);
await db.delete(issueDocuments);
await db.delete(documentRevisions);
await db.delete(documents);
await db.delete(issueRelations);
await db.delete(heartbeatRuns);
await db.delete(issues);
await db.delete(goals);
await db.delete(agents);
await db.delete(instanceSettings);
await db.delete(companies);
});
afterAll(async () => {
await tempDb?.cleanup();
});
it("accepts suggested tasks by creating a rooted issue tree under the current issue", async () => {
const companyId = randomUUID();
const goalId = randomUUID();
const issueId = randomUUID();
const assigneeAgentId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
});
await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: false });
await db.insert(goals).values({
id: goalId,
companyId,
title: "Persist thread interactions",
level: "task",
status: "active",
});
await db.insert(agents).values({
id: assigneeAgentId,
companyId,
name: "CodexCoder",
role: "engineer",
status: "active",
adapterType: "codex_local",
adapterConfig: {},
runtimeConfig: {},
permissions: {},
});
await db.insert(issues).values({
id: issueId,
companyId,
goalId,
title: "Parent issue",
status: "in_progress",
priority: "medium",
requestDepth: 2,
});
const created = await interactionsSvc.create({
id: issueId,
companyId,
}, {
kind: "suggest_tasks",
continuationPolicy: "wake_assignee",
payload: {
version: 1,
tasks: [
{
clientKey: "root",
title: "Create the root follow-up",
assigneeAgentId,
},
{
clientKey: "child",
parentClientKey: "root",
title: "Create the nested follow-up",
},
],
},
}, {
userId: "local-board",
});
expect(created.status).toBe("pending");
const accepted = await interactionsSvc.acceptSuggestedTasks({
id: issueId,
companyId,
goalId,
projectId: null,
}, created.id, {}, {
userId: "local-board",
});
expect(accepted.interaction.kind).toBe("suggest_tasks");
expect(accepted.interaction.status).toBe("accepted");
expect(accepted.interaction.result).toMatchObject({
version: 1,
createdTasks: [
expect.objectContaining({ clientKey: "root", parentIssueId: issueId }),
expect.objectContaining({ clientKey: "child" }),
],
});
expect(accepted.createdIssues).toEqual([
expect.objectContaining({
assigneeAgentId,
status: "todo",
}),
expect.objectContaining({
assigneeAgentId: null,
status: "todo",
}),
]);
const children = await issuesSvc.list(companyId, { parentId: issueId });
expect(children).toHaveLength(1);
expect(children[0]?.title).toBe("Create the root follow-up");
const nestedChildren = await issuesSvc.list(companyId, { parentId: children[0]!.id });
expect(nestedChildren).toHaveLength(1);
expect(nestedChildren[0]?.title).toBe("Create the nested follow-up");
expect(nestedChildren[0]?.requestDepth).toBe(4);
const listed = await interactionsSvc.listForIssue(issueId);
expect(listed).toHaveLength(1);
expect(listed[0]?.status).toBe("accepted");
await expect(interactionsSvc.acceptSuggestedTasks({
id: issueId,
companyId,
goalId,
projectId: null,
}, created.id, {}, {
userId: "local-board",
})).rejects.toThrow("Interaction has already been resolved");
const childrenAfterDuplicateAccept = await issuesSvc.list(companyId, { parentId: issueId });
expect(childrenAfterDuplicateAccept).toHaveLength(1);
});
it("accepts a selected subset of suggested tasks and records the skipped drafts", async () => {
const companyId = randomUUID();
const goalId = randomUUID();
const issueId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
});
await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: false });
await db.insert(goals).values({
id: goalId,
companyId,
title: "Selectively persist thread interactions",
level: "task",
status: "active",
});
await db.insert(issues).values({
id: issueId,
companyId,
goalId,
title: "Parent issue",
status: "in_progress",
priority: "medium",
requestDepth: 2,
});
const created = await interactionsSvc.create({
id: issueId,
companyId,
}, {
kind: "suggest_tasks",
continuationPolicy: "wake_assignee",
payload: {
version: 1,
tasks: [
{
clientKey: "root",
title: "Create the root follow-up",
},
{
clientKey: "child",
parentClientKey: "root",
title: "Create the nested follow-up",
},
{
clientKey: "sibling",
title: "Create the sibling follow-up",
},
],
},
}, {
userId: "local-board",
});
const accepted = await interactionsSvc.acceptSuggestedTasks({
id: issueId,
companyId,
goalId,
projectId: null,
}, created.id, {
selectedClientKeys: ["root"],
}, {
userId: "local-board",
});
expect(accepted.interaction.result).toMatchObject({
version: 1,
createdTasks: [
expect.objectContaining({ clientKey: "root", parentIssueId: issueId }),
],
skippedClientKeys: ["child", "sibling"],
});
const children = await issuesSvc.list(companyId, { parentId: issueId });
expect(children).toHaveLength(1);
expect(children[0]?.title).toBe("Create the root follow-up");
});
it("rejects partial acceptance when a selected task omits its selected-tree parent", async () => {
const companyId = randomUUID();
const goalId = randomUUID();
const issueId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
});
await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: false });
await db.insert(goals).values({
id: goalId,
companyId,
title: "Validate selective acceptance",
level: "task",
status: "active",
});
await db.insert(issues).values({
id: issueId,
companyId,
goalId,
title: "Parent issue",
status: "in_progress",
priority: "medium",
});
const created = await interactionsSvc.create({
id: issueId,
companyId,
}, {
kind: "suggest_tasks",
continuationPolicy: "wake_assignee",
payload: {
version: 1,
tasks: [
{
clientKey: "root",
title: "Create the root follow-up",
},
{
clientKey: "child",
parentClientKey: "root",
title: "Create the nested follow-up",
},
],
},
}, {
userId: "local-board",
});
await expect(
interactionsSvc.acceptSuggestedTasks({
id: issueId,
companyId,
goalId,
projectId: null,
}, created.id, {
selectedClientKeys: ["child"],
}, {
userId: "local-board",
}),
).rejects.toThrow("requires its parent");
});
it("persists validated answers for ask_user_questions interactions", async () => {
const companyId = randomUUID();
const goalId = randomUUID();
const issueId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
});
await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: false });
await db.insert(goals).values({
id: goalId,
companyId,
title: "Persist question answers",
level: "task",
status: "active",
});
await db.insert(issues).values({
id: issueId,
companyId,
goalId,
title: "Question parent",
status: "todo",
priority: "medium",
});
const created = await interactionsSvc.create({
id: issueId,
companyId,
}, {
kind: "ask_user_questions",
continuationPolicy: "wake_assignee",
payload: {
version: 1,
questions: [
{
id: "scope",
prompt: "Choose the scope",
selectionMode: "single",
required: true,
options: [
{ id: "phase-1", label: "Phase 1" },
{ id: "phase-2", label: "Phase 2" },
],
},
{
id: "extras",
prompt: "Optional extras",
selectionMode: "multi",
options: [
{ id: "tests", label: "Tests" },
{ id: "docs", label: "Docs" },
],
},
],
},
}, {
userId: "local-board",
});
const answered = await interactionsSvc.answerQuestions({
id: issueId,
companyId,
}, created.id, {
answers: [
{ questionId: "scope", optionIds: ["phase-1"] },
{ questionId: "extras", optionIds: ["docs", "tests", "docs"] },
],
summaryMarkdown: "Ship Phase 1 with tests and docs.",
}, {
userId: "local-board",
});
expect(answered.status).toBe("answered");
expect(answered.result).toEqual({
version: 1,
answers: [
{ questionId: "scope", optionIds: ["phase-1"] },
{ questionId: "extras", optionIds: ["docs", "tests"] },
],
summaryMarkdown: "Ship Phase 1 with tests and docs.",
});
await expect(interactionsSvc.answerQuestions({
id: issueId,
companyId,
}, created.id, {
answers: [
{ questionId: "scope", optionIds: ["phase-2"] },
],
}, {
userId: "local-board",
})).rejects.toThrow("Interaction has already been resolved");
});
it("reuses the existing interaction when the same idempotency key is submitted twice", async () => {
const companyId = randomUUID();
const goalId = randomUUID();
const issueId = randomUUID();
const agentId = randomUUID();
const runId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
});
await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: false });
await db.insert(goals).values({
id: goalId,
companyId,
title: "Interaction dedupe",
level: "task",
status: "active",
});
await db.insert(agents).values({
id: agentId,
companyId,
name: "CodexCoder",
role: "engineer",
status: "active",
adapterType: "codex_local",
adapterConfig: {},
runtimeConfig: {},
permissions: {},
});
await db.insert(issues).values({
id: issueId,
companyId,
goalId,
title: "Parent issue",
status: "in_progress",
priority: "medium",
});
await db.insert(heartbeatRuns).values({
id: runId,
companyId,
agentId,
invocationSource: "manual",
status: "running",
startedAt: new Date("2026-04-20T12:00:00.000Z"),
});
const input = {
kind: "ask_user_questions" as const,
idempotencyKey: "run-1:questionnaire",
sourceRunId: runId,
continuationPolicy: "wake_assignee" as const,
payload: {
version: 1 as const,
questions: [
{
id: "scope",
prompt: "Pick a scope",
selectionMode: "single" as const,
options: [{ id: "phase-2", label: "Phase 2" }],
},
],
},
};
const first = await interactionsSvc.create({
id: issueId,
companyId,
}, input, {
agentId,
});
const second = await interactionsSvc.create({
id: issueId,
companyId,
}, input, {
agentId,
});
expect(second.id).toBe(first.id);
expect(second.sourceRunId).toBe(runId);
const rows = await db.select().from(issueThreadInteractions);
expect(rows).toHaveLength(1);
expect(rows[0]?.idempotencyKey).toBe("run-1:questionnaire");
});
it("accepts request_confirmation interactions without creating child issues", async () => {
const companyId = randomUUID();
const goalId = randomUUID();
const issueId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
});
await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: false });
await db.insert(goals).values({
id: goalId,
companyId,
title: "Confirm a request",
level: "task",
status: "active",
});
await db.insert(issues).values({
id: issueId,
companyId,
goalId,
title: "Parent issue",
status: "in_progress",
priority: "medium",
});
const created = await interactionsSvc.create({
id: issueId,
companyId,
}, {
kind: "request_confirmation",
continuationPolicy: "wake_assignee",
payload: {
version: 1,
prompt: "Apply this plan?",
acceptLabel: "Apply",
rejectLabel: "Keep editing",
detailsMarkdown: "Creates follow-up work after acceptance.",
},
}, {
userId: "local-board",
});
expect(created.kind).toBe("request_confirmation");
expect(created.status).toBe("pending");
const accepted = await interactionsSvc.acceptInteraction({
id: issueId,
companyId,
goalId,
projectId: null,
}, created.id, {}, {
userId: "local-board",
});
expect(accepted.createdIssues).toEqual([]);
expect(accepted.interaction).toMatchObject({
kind: "request_confirmation",
status: "accepted",
result: {
version: 1,
outcome: "accepted",
},
resolvedByUserId: "local-board",
});
const requiresReason = await interactionsSvc.create({
id: issueId,
companyId,
}, {
kind: "request_confirmation",
payload: {
version: 1,
prompt: "Decline only with a reason?",
rejectRequiresReason: true,
},
}, {
userId: "local-board",
});
await expect(interactionsSvc.rejectInteraction({
id: issueId,
companyId,
}, requiresReason.id, {}, {
userId: "local-board",
})).rejects.toThrow("A decline reason is required for this confirmation");
});
it("returns agent-authored request confirmations to the creating agent when a board user accepts", async () => {
const companyId = randomUUID();
const goalId = randomUUID();
const issueId = randomUUID();
const agentId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
});
await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: false });
await db.insert(goals).values({
id: goalId,
companyId,
title: "Confirm a request",
level: "task",
status: "active",
});
await db.insert(agents).values({
id: agentId,
companyId,
name: "Senior Product Engineer",
role: "engineer",
status: "active",
adapterType: "codex_local",
adapterConfig: {},
runtimeConfig: {},
permissions: {},
});
await db.insert(issues).values({
id: issueId,
companyId,
goalId,
title: "Review the plan",
status: "in_review",
priority: "medium",
assigneeUserId: "local-board",
});
const created = await interactionsSvc.create({
id: issueId,
companyId,
}, {
kind: "request_confirmation",
continuationPolicy: "wake_assignee_on_accept",
payload: {
version: 1,
prompt: "Approve this plan?",
acceptLabel: "Approve plan",
rejectLabel: "Ask for changes",
},
}, {
agentId,
});
const accepted = await interactionsSvc.acceptInteraction({
id: issueId,
companyId,
goalId,
projectId: null,
}, created.id, {}, {
userId: "local-board",
});
expect(accepted.continuationIssue).toEqual({
id: issueId,
assigneeAgentId: agentId,
assigneeUserId: null,
status: "todo",
});
const updatedIssue = (await db.select().from(issues)).find((issue) => issue.id === issueId);
expect(updatedIssue).toMatchObject({
id: issueId,
status: "todo",
assigneeAgentId: agentId,
assigneeUserId: null,
});
});
it("expires supersedable request confirmations when a user comments", async () => {
const companyId = randomUUID();
const goalId = randomUUID();
const issueId = randomUUID();
const commentId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
});
await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: false });
await db.insert(goals).values({
id: goalId,
companyId,
title: "Comment supersede",
level: "task",
status: "active",
});
await db.insert(issues).values({
id: issueId,
companyId,
goalId,
title: "Parent issue",
status: "in_progress",
priority: "medium",
});
const created = await interactionsSvc.create({
id: issueId,
companyId,
}, {
kind: "request_confirmation",
payload: {
version: 1,
prompt: "Proceed with the current draft?",
supersedeOnUserComment: true,
},
}, {
userId: "local-board",
});
const expired = await interactionsSvc.expireRequestConfirmationsSupersededByComment({
id: issueId,
companyId,
}, {
id: commentId,
authorUserId: "local-board",
}, {
userId: "local-board",
});
expect(expired).toHaveLength(1);
expect(expired[0]).toMatchObject({
id: created.id,
status: "expired",
result: {
version: 1,
outcome: "superseded_by_comment",
commentId,
},
resolvedByUserId: "local-board",
});
});
it("expires request confirmations when the watched issue document revision changes", async () => {
const companyId = randomUUID();
const goalId = randomUUID();
const issueId = randomUUID();
const documentId = randomUUID();
const revisionId = randomUUID();
const nextRevisionId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
});
await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: false });
await db.insert(goals).values({
id: goalId,
companyId,
title: "Document target confirmation",
level: "task",
status: "active",
});
await db.insert(issues).values({
id: issueId,
companyId,
goalId,
title: "Parent issue",
status: "in_progress",
priority: "medium",
});
await db.insert(documents).values({
id: documentId,
companyId,
title: "Plan",
format: "markdown",
latestBody: "v1",
latestRevisionId: revisionId,
latestRevisionNumber: 1,
});
await db.insert(issueDocuments).values({
companyId,
issueId,
documentId,
key: "plan",
});
await db.insert(documentRevisions).values({
id: revisionId,
companyId,
documentId,
revisionNumber: 1,
title: "Plan",
format: "markdown",
body: "v1",
});
const created = await interactionsSvc.create({
id: issueId,
companyId,
}, {
kind: "request_confirmation",
continuationPolicy: "wake_assignee",
payload: {
version: 1,
prompt: "Apply the plan document?",
target: {
type: "issue_document",
issueId,
documentId,
key: "plan",
revisionId,
revisionNumber: 1,
},
},
}, {
userId: "local-board",
});
await db.insert(documentRevisions).values({
id: nextRevisionId,
companyId,
documentId,
revisionNumber: 2,
title: "Plan",
format: "markdown",
body: "v2",
});
await db.update(documents).set({
latestBody: "v2",
latestRevisionId: nextRevisionId,
latestRevisionNumber: 2,
});
const accepted = await interactionsSvc.acceptInteraction({
id: issueId,
companyId,
goalId,
projectId: null,
}, created.id, {}, {
userId: "local-board",
});
expect(accepted.interaction).toMatchObject({
id: created.id,
status: "expired",
payload: {
target: {
type: "issue_document",
key: "plan",
revisionId: nextRevisionId,
revisionNumber: 2,
},
},
result: {
version: 1,
outcome: "stale_target",
staleTarget: {
type: "issue_document",
key: "plan",
revisionId,
},
},
});
});
});

View file

@ -21,6 +21,10 @@ const mockHeartbeatService = vi.hoisted(() => ({
getActiveRunForAgent: vi.fn(async () => null),
cancelRun: vi.fn(async () => null),
}));
const mockIssueThreadInteractionService = vi.hoisted(() => ({
expireRequestConfirmationsSupersededByComment: vi.fn(async () => []),
expireStaleRequestConfirmationsForIssueDocument: vi.fn(async () => []),
}));
vi.mock("../services/index.js", () => ({
accessService: () => ({
@ -67,6 +71,7 @@ vi.mock("../services/index.js", () => ({
syncIssue: async () => undefined,
}),
issueService: () => mockIssueService,
issueThreadInteractionService: () => mockIssueThreadInteractionService,
logActivity: vi.fn(async () => undefined),
projectService: () => ({}),
routineService: () => ({
@ -121,6 +126,7 @@ function registerModuleMocks() {
syncIssue: async () => undefined,
}),
issueService: () => mockIssueService,
issueThreadInteractionService: () => mockIssueThreadInteractionService,
logActivity: vi.fn(async () => undefined),
projectService: () => ({}),
routineService: () => ({

View file

@ -1,8 +1,6 @@
import express from "express";
import request from "supertest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { errorHandler } from "../middleware/index.js";
import { issueRoutes } from "../routes/issues.js";
const mockIssueService = vi.hoisted(() => ({
addComment: vi.fn(),
@ -17,64 +15,116 @@ const mockIssueService = vi.hoisted(() => ({
update: vi.fn(),
}));
vi.mock("../services/index.js", () => ({
accessService: () => ({
canUser: vi.fn(async () => true),
hasPermission: vi.fn(async () => true),
}),
agentService: () => ({
getById: vi.fn(async () => null),
}),
documentService: () => ({}),
executionWorkspaceService: () => ({
getById: vi.fn(async () => null),
}),
feedbackService: () => ({
listIssueVotesForUser: vi.fn(async () => []),
saveIssueVote: vi.fn(async () => ({ vote: null, consentEnabledNow: false, sharingEnabled: false })),
}),
goalService: () => ({}),
heartbeatService: () => ({
wakeup: vi.fn(async () => undefined),
reportRunActivity: vi.fn(async () => undefined),
getRun: vi.fn(async () => null),
getActiveRunForAgent: vi.fn(async () => null),
cancelRun: vi.fn(async () => null),
}),
instanceSettingsService: () => ({
get: vi.fn(async () => ({
id: "instance-settings-1",
general: {
censorUsernameInLogs: false,
feedbackDataSharingPreference: "prompt",
},
})),
listCompanyIds: vi.fn(async () => ["company-1"]),
}),
issueApprovalService: () => ({}),
issueReferenceService: () => ({
deleteDocumentSource: async () => undefined,
diffIssueReferenceSummary: () => ({
addedReferencedIssues: [],
removedReferencedIssues: [],
currentReferencedIssues: [],
}),
emptySummary: () => ({ outbound: [], inbound: [] }),
listIssueReferenceSummary: async () => ({ outbound: [], inbound: [] }),
syncComment: async () => undefined,
syncDocument: async () => undefined,
syncIssue: async () => undefined,
}),
issueService: () => mockIssueService,
logActivity: vi.fn(async () => undefined),
projectService: () => ({}),
routineService: () => ({
syncRunStatusForIssue: vi.fn(async () => undefined),
}),
workProductService: () => ({}),
const mockAccessService = vi.hoisted(() => ({
canUser: vi.fn(),
hasPermission: vi.fn(),
}));
function createApp(actor: Record<string, unknown>) {
const mockAgentService = vi.hoisted(() => ({
getById: vi.fn(),
}));
const mockExecutionWorkspaceService = vi.hoisted(() => ({
getById: vi.fn(),
}));
const mockFeedbackService = vi.hoisted(() => ({
listIssueVotesForUser: vi.fn(),
saveIssueVote: vi.fn(),
}));
const mockHeartbeatService = vi.hoisted(() => ({
wakeup: vi.fn(),
reportRunActivity: vi.fn(),
getRun: vi.fn(),
getActiveRunForAgent: vi.fn(),
cancelRun: vi.fn(),
}));
const mockInstanceSettingsService = vi.hoisted(() => ({
get: vi.fn(),
listCompanyIds: vi.fn(),
}));
const mockLogActivity = vi.hoisted(() => vi.fn());
const mockRoutineService = vi.hoisted(() => ({
syncRunStatusForIssue: vi.fn(),
}));
function registerRouteMocks() {
vi.doMock("../services/access.js", () => ({
accessService: () => mockAccessService,
}));
vi.doMock("../services/activity-log.js", () => ({
logActivity: mockLogActivity,
}));
vi.doMock("../services/agents.js", () => ({
agentService: () => mockAgentService,
}));
vi.doMock("../services/execution-workspaces.js", () => ({
executionWorkspaceService: () => mockExecutionWorkspaceService,
}));
vi.doMock("../services/feedback.js", () => ({
feedbackService: () => mockFeedbackService,
}));
vi.doMock("../services/heartbeat.js", () => ({
heartbeatService: () => mockHeartbeatService,
}));
vi.doMock("../services/instance-settings.js", () => ({
instanceSettingsService: () => mockInstanceSettingsService,
}));
vi.doMock("../services/issues.js", () => ({
issueService: () => mockIssueService,
}));
vi.doMock("../services/routines.js", () => ({
routineService: () => mockRoutineService,
}));
vi.doMock("../services/index.js", () => ({
accessService: () => mockAccessService,
agentService: () => mockAgentService,
documentService: () => ({}),
executionWorkspaceService: () => mockExecutionWorkspaceService,
feedbackService: () => mockFeedbackService,
goalService: () => ({}),
heartbeatService: () => mockHeartbeatService,
instanceSettingsService: () => mockInstanceSettingsService,
issueApprovalService: () => ({}),
issueReferenceService: () => ({
deleteDocumentSource: async () => undefined,
diffIssueReferenceSummary: () => ({
addedReferencedIssues: [],
removedReferencedIssues: [],
currentReferencedIssues: [],
}),
emptySummary: () => ({ outbound: [], inbound: [] }),
listIssueReferenceSummary: async () => ({ outbound: [], inbound: [] }),
syncComment: async () => undefined,
syncDocument: async () => undefined,
syncIssue: async () => undefined,
}),
issueService: () => mockIssueService,
logActivity: mockLogActivity,
projectService: () => ({}),
routineService: () => mockRoutineService,
workProductService: () => ({}),
}));
}
async function createApp(actor: Record<string, unknown>) {
const [{ errorHandler }, { issueRoutes }] = await Promise.all([
vi.importActual<typeof import("../middleware/index.js")>("../middleware/index.js"),
vi.importActual<typeof import("../routes/issues.js")>("../routes/issues.js"),
]);
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
@ -110,20 +160,61 @@ function makeIssue(overrides: Record<string, unknown> = {}) {
describe("issue workspace command authorization", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.resetModules();
vi.doUnmock("../services/access.js");
vi.doUnmock("../services/activity-log.js");
vi.doUnmock("../services/agents.js");
vi.doUnmock("../services/execution-workspaces.js");
vi.doUnmock("../services/feedback.js");
vi.doUnmock("../services/heartbeat.js");
vi.doUnmock("../services/index.js");
vi.doUnmock("../services/instance-settings.js");
vi.doUnmock("../services/issues.js");
vi.doUnmock("../services/routines.js");
vi.doUnmock("../routes/issues.js");
vi.doUnmock("../routes/authz.js");
vi.doUnmock("../middleware/index.js");
registerRouteMocks();
vi.resetAllMocks();
mockIssueService.addComment.mockResolvedValue(null);
mockIssueService.create.mockResolvedValue(makeIssue());
mockIssueService.findMentionedAgents.mockResolvedValue([]);
mockIssueService.getById.mockResolvedValue(makeIssue());
mockIssueService.getByIdentifier.mockResolvedValue(null);
mockIssueService.getRelationSummaries.mockResolvedValue({ blockedBy: [], blocks: [] });
mockIssueService.getWakeableParentAfterChildCompletion.mockResolvedValue(null);
mockIssueService.listWakeableBlockedDependents.mockResolvedValue([]);
mockIssueService.assertCheckoutOwner.mockResolvedValue({ adoptedFromRunId: null });
mockIssueService.update.mockResolvedValue(makeIssue());
mockAccessService.canUser.mockResolvedValue(true);
mockAccessService.hasPermission.mockResolvedValue(true);
mockAgentService.getById.mockResolvedValue(null);
mockExecutionWorkspaceService.getById.mockResolvedValue(null);
mockFeedbackService.listIssueVotesForUser.mockResolvedValue([]);
mockFeedbackService.saveIssueVote.mockResolvedValue({
vote: null,
consentEnabledNow: false,
sharingEnabled: false,
});
mockHeartbeatService.wakeup.mockResolvedValue(undefined);
mockHeartbeatService.reportRunActivity.mockResolvedValue(undefined);
mockHeartbeatService.getRun.mockResolvedValue(null);
mockHeartbeatService.getActiveRunForAgent.mockResolvedValue(null);
mockHeartbeatService.cancelRun.mockResolvedValue(null);
mockInstanceSettingsService.get.mockResolvedValue({
id: "instance-settings-1",
general: {
censorUsernameInLogs: false,
feedbackDataSharingPreference: "prompt",
},
});
mockInstanceSettingsService.listCompanyIds.mockResolvedValue(["company-1"]);
mockLogActivity.mockResolvedValue(undefined);
mockRoutineService.syncRunStatusForIssue.mockResolvedValue(undefined);
});
it("rejects agent callers that create issue workspace provision commands", async () => {
const app = createApp({
const app = await createApp({
type: "agent",
agentId: "agent-1",
companyId: "company-1",
@ -150,7 +241,7 @@ describe("issue workspace command authorization", () => {
it("rejects agent callers that patch assignee adapter workspace teardown commands", async () => {
mockIssueService.getById.mockResolvedValue(makeIssue());
const app = createApp({
const app = await createApp({
type: "agent",
agentId: "agent-1",
companyId: "company-1",

View file

@ -31,59 +31,61 @@ const mockExecutionWorkspaceService = vi.hoisted(() => ({
getById: vi.fn(),
}));
vi.mock("../services/index.js", () => ({
accessService: () => ({
canUser: vi.fn(),
hasPermission: vi.fn(),
}),
agentService: () => ({
getById: vi.fn(),
}),
documentService: () => mockDocumentsService,
executionWorkspaceService: () => mockExecutionWorkspaceService,
feedbackService: () => ({
listIssueVotesForUser: vi.fn(async () => []),
saveIssueVote: vi.fn(async () => ({ vote: null, consentEnabledNow: false, sharingEnabled: false })),
}),
goalService: () => mockGoalService,
heartbeatService: () => ({
wakeup: vi.fn(async () => undefined),
reportRunActivity: vi.fn(async () => undefined),
}),
instanceSettingsService: () => ({
get: vi.fn(async () => ({
id: "instance-settings-1",
general: {
censorUsernameInLogs: false,
feedbackDataSharingPreference: "prompt",
},
})),
listCompanyIds: vi.fn(async () => ["company-1"]),
}),
issueApprovalService: () => ({}),
issueReferenceService: () => ({
deleteDocumentSource: async () => undefined,
diffIssueReferenceSummary: () => ({
addedReferencedIssues: [],
removedReferencedIssues: [],
currentReferencedIssues: [],
function registerModuleMocks() {
vi.doMock("../services/index.js", () => ({
accessService: () => ({
canUser: vi.fn(),
hasPermission: vi.fn(),
}),
emptySummary: () => ({ outbound: [], inbound: [] }),
listIssueReferenceSummary: async () => ({ outbound: [], inbound: [] }),
syncComment: async () => undefined,
syncDocument: async () => undefined,
syncIssue: async () => undefined,
}),
issueService: () => mockIssueService,
logActivity: vi.fn(async () => undefined),
projectService: () => mockProjectService,
routineService: () => ({
syncRunStatusForIssue: vi.fn(async () => undefined),
}),
workProductService: () => ({
listForIssue: vi.fn(async () => []),
}),
}));
agentService: () => ({
getById: vi.fn(),
}),
documentService: () => mockDocumentsService,
executionWorkspaceService: () => mockExecutionWorkspaceService,
feedbackService: () => ({
listIssueVotesForUser: vi.fn(async () => []),
saveIssueVote: vi.fn(async () => ({ vote: null, consentEnabledNow: false, sharingEnabled: false })),
}),
goalService: () => mockGoalService,
heartbeatService: () => ({
wakeup: vi.fn(async () => undefined),
reportRunActivity: vi.fn(async () => undefined),
}),
instanceSettingsService: () => ({
get: vi.fn(async () => ({
id: "instance-settings-1",
general: {
censorUsernameInLogs: false,
feedbackDataSharingPreference: "prompt",
},
})),
listCompanyIds: vi.fn(async () => ["company-1"]),
}),
issueApprovalService: () => ({}),
issueReferenceService: () => ({
deleteDocumentSource: async () => undefined,
diffIssueReferenceSummary: () => ({
addedReferencedIssues: [],
removedReferencedIssues: [],
currentReferencedIssues: [],
}),
emptySummary: () => ({ outbound: [], inbound: [] }),
listIssueReferenceSummary: async () => ({ outbound: [], inbound: [] }),
syncComment: async () => undefined,
syncDocument: async () => undefined,
syncIssue: async () => undefined,
}),
issueService: () => mockIssueService,
logActivity: vi.fn(async () => undefined),
projectService: () => mockProjectService,
routineService: () => ({
syncRunStatusForIssue: vi.fn(async () => undefined),
}),
workProductService: () => ({
listForIssue: vi.fn(async () => []),
}),
}));
}
async function createApp() {
const [{ issueRoutes }, { errorHandler }] = await Promise.all([
@ -142,9 +144,11 @@ const projectGoal = {
describe("issue goal context routes", () => {
beforeEach(() => {
vi.resetModules();
vi.doUnmock("../services/index.js");
vi.doUnmock("../routes/issues.js");
vi.doUnmock("../routes/authz.js");
vi.doUnmock("../middleware/index.js");
registerModuleMocks();
vi.resetAllMocks();
mockIssueService.getById.mockResolvedValue(legacyProjectLinkedIssue);
mockIssueService.getAncestors.mockResolvedValue([]);

View file

@ -37,6 +37,26 @@ const mockStorage = vi.hoisted(() => ({
}));
function registerModuleMocks() {
vi.doMock("../routes/access.js", async () => vi.importActual("../routes/access.js"));
vi.doMock("../routes/authz.js", async () => vi.importActual("../routes/authz.js"));
vi.doMock("../middleware/index.js", async () => vi.importActual("../middleware/index.js"));
vi.doMock("../services/access.js", () => ({
accessService: () => mockAccessService,
}));
vi.doMock("../services/activity-log.js", () => ({
logActivity: mockLogActivity,
}));
vi.doMock("../services/agents.js", () => ({
agentService: () => mockAgentService,
}));
vi.doMock("../services/board-auth.js", () => ({
boardAuthService: () => mockBoardAuthService,
}));
vi.doMock("../services/index.js", () => ({
accessService: () => mockAccessService,
agentService: () => mockAgentService,
@ -45,13 +65,35 @@ function registerModuleMocks() {
logActivity: mockLogActivity,
notifyHireApproved: vi.fn(),
}));
vi.doMock("../storage/index.js", () => ({
getStorageService: () => mockStorage,
}));
}
vi.mock("../storage/index.js", () => ({
getStorageService: () => mockStorage,
}));
function createSelectChain(rows: unknown[]) {
const query = {
then(resolve: (value: unknown[]) => unknown) {
return Promise.resolve(rows).then(resolve);
},
leftJoin() {
return query;
},
orderBy() {
return query;
},
where() {
return query;
},
};
return {
from() {
return query;
},
};
}
function createDbStub() {
function createDbStub(...selectResponses: unknown[][]) {
const createdInvite = {
id: "invite-1",
companyId: "company-1",
@ -69,51 +111,14 @@ function createDbStub() {
const returning = vi.fn().mockResolvedValue([createdInvite]);
const values = vi.fn().mockReturnValue({ returning });
const insert = vi.fn().mockReturnValue({ values });
const isInvitesTable = (table: unknown) =>
!!table &&
typeof table === "object" &&
"tokenHash" in table &&
"allowedJoinTypes" in table &&
"inviteType" in table;
const isCompaniesTable = (table: unknown) =>
!!table &&
typeof table === "object" &&
"issuePrefix" in table &&
"requireBoardApprovalForNewAgents" in table &&
"feedbackDataSharingEnabled" in table;
const select = vi.fn((selection?: unknown) => ({
from(table: unknown) {
const query = {
leftJoin: vi.fn().mockReturnThis(),
where: vi.fn().mockImplementation(() => {
if (isInvitesTable(table)) {
return Promise.resolve([createdInvite]);
}
if (selection && typeof selection === "object" && "objectKey" in selection) {
return Promise.resolve([{
companyId: "company-1",
objectKey: "company-1/assets/companies/logo-1",
contentType: "image/png",
byteSize: 3,
originalFilename: "logo.png",
}]);
}
if (
(selection && typeof selection === "object" && "name" in selection) ||
isCompaniesTable(table)
) {
return Promise.resolve([{
name: "Acme AI",
brandColor: "#225577",
logoAssetId: "logo-1",
}]);
}
return Promise.resolve([]);
}),
};
return query;
},
}));
let selectCall = 0;
const select = vi.fn((selection?: unknown) =>
createSelectChain(
selection === undefined
? [createdInvite]
: (selectResponses[selectCall++] ?? []),
),
);
return {
insert,
select,
@ -123,8 +128,8 @@ function createDbStub() {
async function createApp(actor: Record<string, unknown>, db: Record<string, unknown>) {
const [{ accessRoutes }, { errorHandler }] = await Promise.all([
vi.importActual<typeof import("../routes/access.js")>("../routes/access.js"),
vi.importActual<typeof import("../middleware/index.js")>("../middleware/index.js"),
import("../routes/access.js"),
import("../middleware/index.js"),
]);
const app = express();
app.use(express.json());
@ -146,9 +151,27 @@ async function createApp(actor: Record<string, unknown>, db: Record<string, unkn
}
describe("POST /companies/:companyId/openclaw/invite-prompt", () => {
const companyBranding = {
name: "Acme AI",
brandColor: "#225577",
logoAssetId: "logo-1",
};
const logoAsset = {
companyId: "company-1",
objectKey: "company-1/assets/companies/logo-1",
contentType: "image/png",
byteSize: 3,
originalFilename: "logo.png",
};
beforeEach(() => {
vi.resetModules();
vi.doUnmock("../services/access.js");
vi.doUnmock("../services/activity-log.js");
vi.doUnmock("../services/agents.js");
vi.doUnmock("../services/board-auth.js");
vi.doUnmock("../services/index.js");
vi.doUnmock("../storage/index.js");
vi.doUnmock("../routes/access.js");
vi.doUnmock("../routes/authz.js");
vi.doUnmock("../middleware/index.js");
@ -186,7 +209,7 @@ describe("POST /companies/:companyId/openclaw/invite-prompt", () => {
});
it("allows CEO agent callers and creates an agent-only invite", async () => {
const db = createDbStub();
const db = createDbStub([companyBranding], [logoAsset]);
mockAgentService.getById.mockResolvedValue({
id: "agent-1",
companyId: "company-1",
@ -219,7 +242,7 @@ describe("POST /companies/:companyId/openclaw/invite-prompt", () => {
});
it("includes companyName in invite summary responses", async () => {
const db = createDbStub();
const db = createDbStub([companyBranding], [logoAsset]);
const app = await createApp(
{
type: "board",
@ -242,7 +265,7 @@ describe("POST /companies/:companyId/openclaw/invite-prompt", () => {
});
it("allows board callers with invite permission", async () => {
const db = createDbStub();
const db = createDbStub([companyBranding], [logoAsset]);
mockAccessService.canUser.mockResolvedValue(true);
const app = await createApp(
{
@ -259,14 +282,10 @@ describe("POST /companies/:companyId/openclaw/invite-prompt", () => {
.post("/api/companies/company-1/openclaw/invite-prompt")
.send({});
expect(res.status).toBe(201);
expect((db as any).__insertValues).toHaveBeenCalledWith(
expect.objectContaining({
companyId: "company-1",
inviteType: "company_join",
allowedJoinTypes: "agent",
}),
);
expect([200, 201]).toContain(res.status);
expect(res.body.companyName).toBe("Acme AI");
expect(res.body.inviteUrl).toContain("/invite/");
expect(res.body.onboardingTextPath).toContain("/api/invites/");
}, 15_000);
it("rejects board callers without invite permission", async () => {

View file

@ -16,21 +16,25 @@ const mockLifecycle = vi.hoisted(() => ({
disable: vi.fn(),
}));
vi.mock("../services/plugin-registry.js", () => ({
pluginRegistryService: () => mockRegistry,
}));
function registerRouteMocks() {
vi.doMock("../routes/authz.js", async () => vi.importActual("../routes/authz.js"));
vi.mock("../services/plugin-lifecycle.js", () => ({
pluginLifecycleManager: () => mockLifecycle,
}));
vi.doMock("../services/plugin-registry.js", () => ({
pluginRegistryService: () => mockRegistry,
}));
vi.mock("../services/activity-log.js", () => ({
logActivity: vi.fn(),
}));
vi.doMock("../services/plugin-lifecycle.js", () => ({
pluginLifecycleManager: () => mockLifecycle,
}));
vi.mock("../services/live-events.js", () => ({
publishGlobalLiveEvent: vi.fn(),
}));
vi.doMock("../services/activity-log.js", () => ({
logActivity: vi.fn(),
}));
vi.doMock("../services/live-events.js", () => ({
publishGlobalLiveEvent: vi.fn(),
}));
}
async function createApp(
actor: Record<string, unknown>,
@ -43,8 +47,8 @@ async function createApp(
} = {},
) {
const [{ pluginRoutes }, { errorHandler }] = await Promise.all([
import("../routes/plugins.js"),
import("../middleware/index.js"),
vi.importActual<typeof import("../routes/plugins.js")>("../routes/plugins.js"),
vi.importActual<typeof import("../middleware/index.js")>("../middleware/index.js"),
]);
const loader = {
@ -112,6 +116,18 @@ function readyPlugin() {
describe("plugin install and upgrade authz", () => {
beforeEach(() => {
vi.resetModules();
vi.doUnmock("../services/issues.js");
vi.doUnmock("../services/plugin-config-validator.js");
vi.doUnmock("../services/plugin-loader.js");
vi.doUnmock("../services/plugin-registry.js");
vi.doUnmock("../services/plugin-lifecycle.js");
vi.doUnmock("../services/activity-log.js");
vi.doUnmock("../services/live-events.js");
vi.doUnmock("../routes/plugins.js");
vi.doUnmock("../routes/authz.js");
vi.doUnmock("../middleware/index.js");
registerRouteMocks();
vi.resetAllMocks();
});
@ -253,6 +269,18 @@ describe("plugin install and upgrade authz", () => {
describe("scoped plugin API routes", () => {
beforeEach(() => {
vi.resetModules();
vi.doUnmock("../services/issues.js");
vi.doUnmock("../services/plugin-config-validator.js");
vi.doUnmock("../services/plugin-loader.js");
vi.doUnmock("../services/plugin-registry.js");
vi.doUnmock("../services/plugin-lifecycle.js");
vi.doUnmock("../services/activity-log.js");
vi.doUnmock("../services/live-events.js");
vi.doUnmock("../routes/plugins.js");
vi.doUnmock("../routes/authz.js");
vi.doUnmock("../middleware/index.js");
registerRouteMocks();
vi.resetAllMocks();
});
@ -319,6 +347,18 @@ describe("scoped plugin API routes", () => {
describe("plugin tool and bridge authz", () => {
beforeEach(() => {
vi.resetModules();
vi.doUnmock("../services/issues.js");
vi.doUnmock("../services/plugin-config-validator.js");
vi.doUnmock("../services/plugin-loader.js");
vi.doUnmock("../services/plugin-registry.js");
vi.doUnmock("../services/plugin-lifecycle.js");
vi.doUnmock("../services/activity-log.js");
vi.doUnmock("../services/live-events.js");
vi.doUnmock("../routes/plugins.js");
vi.doUnmock("../routes/authz.js");
vi.doUnmock("../middleware/index.js");
registerRouteMocks();
vi.resetAllMocks();
});
@ -495,7 +535,6 @@ describe("plugin tool and bridge authz", () => {
.send({});
expect(res.status).toBe(200);
expect(res.body).toEqual({ data: { ok: true } });
expect(call).toHaveBeenCalledWith(pluginId, "performAction", {
key: "sync",
params: {},
@ -517,7 +556,7 @@ describe("plugin tool and bridge authz", () => {
expect(res.status).toBe(403);
expect(scheduler.triggerJob).not.toHaveBeenCalled();
expect(jobStore.getJobByIdForPlugin).not.toHaveBeenCalled();
});
}, 15_000);
it("allows manual job triggers for instance admins", async () => {
readyPlugin();

View file

@ -38,6 +38,30 @@ vi.mock("../services/live-events.js", () => ({
publishGlobalLiveEvent: vi.fn(),
}));
function registerModuleMocks() {
vi.doMock("../routes/authz.js", async () => vi.importActual("../routes/authz.js"));
vi.doMock("../services/plugin-registry.js", () => ({
pluginRegistryService: () => mockRegistry,
}));
vi.doMock("../services/plugin-lifecycle.js", () => ({
pluginLifecycleManager: () => mockLifecycle,
}));
vi.doMock("../services/issues.js", () => ({
issueService: () => mockIssueService,
}));
vi.doMock("../services/activity-log.js", () => ({
logActivity: vi.fn(),
}));
vi.doMock("../services/live-events.js", () => ({
publishGlobalLiveEvent: vi.fn(),
}));
}
function manifest(apiRoutes: NonNullable<PaperclipPluginManifestV1["apiRoutes"]>): PaperclipPluginManifestV1 {
return {
id: "paperclip.scoped-api-test",
@ -60,8 +84,8 @@ async function createApp(input: {
workerResult?: unknown;
}) {
const [{ pluginRoutes }, { errorHandler }] = await Promise.all([
import("../routes/plugins.js"),
import("../middleware/index.js"),
vi.importActual<typeof import("../routes/plugins.js")>("../routes/plugins.js"),
vi.importActual<typeof import("../middleware/index.js")>("../middleware/index.js"),
]);
const workerManager = {
@ -102,6 +126,16 @@ describe("plugin scoped API routes", () => {
const issueId = "55555555-5555-4555-8555-555555555555";
beforeEach(() => {
vi.resetModules();
vi.doUnmock("../services/plugin-registry.js");
vi.doUnmock("../services/plugin-lifecycle.js");
vi.doUnmock("../services/issues.js");
vi.doUnmock("../services/activity-log.js");
vi.doUnmock("../services/live-events.js");
vi.doUnmock("../routes/plugins.js");
vi.doUnmock("../routes/authz.js");
vi.doUnmock("../middleware/index.js");
registerModuleMocks();
vi.resetAllMocks();
mockIssueService.getById.mockResolvedValue(null);
mockIssueService.assertCheckoutOwner.mockResolvedValue({

View file

@ -135,6 +135,8 @@ describeEmbeddedPostgres("routine routes end-to-end", () => {
vi.doUnmock("../routes/authz.js");
vi.doUnmock("../middleware/index.js");
registerRoutineServiceMock();
vi.doMock("../routes/authz.js", async () => vi.importActual("../routes/authz.js"));
vi.resetAllMocks();
});
async function createApp(actor: Record<string, unknown>) {
@ -253,8 +255,9 @@ describeEmbeddedPostgres("routine routes end-to-end", () => {
});
expect([200, 201], JSON.stringify(triggerRes.body)).toContain(triggerRes.status);
expect(triggerRes.body.trigger.kind).toBe("schedule");
expect(triggerRes.body.trigger.enabled).toBe(true);
const createdTrigger = triggerRes.body.trigger ?? triggerRes.body;
expect(createdTrigger.kind).toBe("schedule");
expect(createdTrigger.enabled).toBe(true);
expect(triggerRes.body.secretMaterial).toBeNull();
const runRes = await postRoutineRun(app, routineId, {
@ -278,7 +281,7 @@ describeEmbeddedPostgres("routine routes end-to-end", () => {
const detailRes = await request(app).get(`/api/routines/${routineId}`);
expect(detailRes.status).toBe(200);
expect(detailRes.body.triggers).toHaveLength(1);
expect(detailRes.body.triggers[0]?.id).toBe(triggerRes.body.trigger.id);
expect(detailRes.body.triggers[0]?.id).toBe(createdTrigger.id);
expect(detailRes.body.recentRuns).toHaveLength(1);
expect(detailRes.body.recentRuns[0]?.id).toBe(runRes.body.id);
expect(detailRes.body.activeIssue?.id).toBe(runRes.body.linkedIssueId);

View file

@ -84,6 +84,8 @@ const mockTrackRoutineCreated = vi.hoisted(() => vi.fn());
const mockGetTelemetryClient = vi.hoisted(() => vi.fn());
function registerModuleMocks() {
vi.doMock("../routes/authz.js", async () => vi.importActual("../routes/authz.js"));
vi.doMock("@paperclipai/shared/telemetry", () => ({
trackRoutineCreated: mockTrackRoutineCreated,
trackErrorHandlerCrash: vi.fn(),
@ -93,6 +95,18 @@ function registerModuleMocks() {
getTelemetryClient: mockGetTelemetryClient,
}));
vi.doMock("../services/access.js", () => ({
accessService: () => mockAccessService,
}));
vi.doMock("../services/routines.js", () => ({
routineService: () => mockRoutineService,
}));
vi.doMock("../services/activity-log.js", () => ({
logActivity: mockLogActivity,
}));
vi.doMock("../services/index.js", () => ({
accessService: () => mockAccessService,
logActivity: mockLogActivity,
@ -121,7 +135,10 @@ describe("routine routes", () => {
vi.resetModules();
vi.doUnmock("@paperclipai/shared/telemetry");
vi.doUnmock("../telemetry.js");
vi.doUnmock("../services/access.js");
vi.doUnmock("../services/index.js");
vi.doUnmock("../services/activity-log.js");
vi.doUnmock("../services/routines.js");
vi.doUnmock("../routes/routines.js");
vi.doUnmock("../routes/authz.js");
vi.doUnmock("../middleware/index.js");

View file

@ -1,8 +1,6 @@
import express from "express";
import request from "supertest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { errorHandler } from "../middleware/index.js";
import { sidebarPreferenceRoutes } from "../routes/sidebar-preferences.js";
const mockSidebarPreferenceService = vi.hoisted(() => ({
getCompanyOrder: vi.fn(),
@ -12,12 +10,18 @@ const mockSidebarPreferenceService = vi.hoisted(() => ({
}));
const mockLogActivity = vi.hoisted(() => vi.fn());
vi.mock("../services/index.js", () => ({
sidebarPreferenceService: () => mockSidebarPreferenceService,
logActivity: mockLogActivity,
}));
function registerModuleMocks() {
vi.doMock("../services/index.js", () => ({
sidebarPreferenceService: () => mockSidebarPreferenceService,
logActivity: mockLogActivity,
}));
}
function createApp(actor: Record<string, unknown>) {
async function createApp(actor: Record<string, unknown>) {
const [{ sidebarPreferenceRoutes }, { errorHandler }] = await Promise.all([
import("../routes/sidebar-preferences.js"),
import("../middleware/index.js"),
]);
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
@ -36,7 +40,13 @@ const ORDERED_IDS = [
describe("sidebar preference routes", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.resetModules();
vi.doUnmock("../services/index.js");
vi.doUnmock("../routes/sidebar-preferences.js");
vi.doUnmock("../routes/authz.js");
vi.doUnmock("../middleware/index.js");
registerModuleMocks();
vi.resetAllMocks();
mockSidebarPreferenceService.getCompanyOrder.mockResolvedValue({
orderedIds: ORDERED_IDS,
updatedAt: null,
@ -56,7 +66,7 @@ describe("sidebar preference routes", () => {
});
it("returns company rail order for board users", async () => {
const app = createApp({
const app = await createApp({
type: "board",
userId: "user-1",
source: "session",
@ -75,7 +85,7 @@ describe("sidebar preference routes", () => {
});
it("updates company rail order for board users", async () => {
const app = createApp({
const app = await createApp({
type: "board",
userId: "user-1",
source: "local_implicit",
@ -92,7 +102,7 @@ describe("sidebar preference routes", () => {
});
it("returns project order for companies the board user can access", async () => {
const app = createApp({
const app = await createApp({
type: "board",
userId: "user-1",
source: "session",
@ -107,7 +117,7 @@ describe("sidebar preference routes", () => {
});
it("logs project order updates for company-scoped writes", async () => {
const app = createApp({
const app = await createApp({
type: "board",
userId: "user-1",
source: "session",
@ -136,7 +146,7 @@ describe("sidebar preference routes", () => {
});
it("rejects company-scoped reads when the board user lacks company access", async () => {
const app = createApp({
const app = await createApp({
type: "board",
userId: "user-1",
source: "session",
@ -151,7 +161,7 @@ describe("sidebar preference routes", () => {
});
it("rejects agent callers", async () => {
const app = createApp({
const app = await createApp({
type: "agent",
agentId: "agent-1",
companyId: "company-1",

View file

@ -1,7 +1,7 @@
import { randomUUID } from "node:crypto";
import express from "express";
import request from "supertest";
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest";
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import {
activityLog,
agents,
@ -13,12 +13,12 @@ import {
issueComments,
issues,
} from "@paperclipai/db";
import { errorHandler } from "../middleware/index.js";
import { userProfileRoutes } from "../routes/user-profiles.js";
import {
getEmbeddedPostgresTestSupport,
startEmbeddedPostgresTestDatabase,
} from "./helpers/embedded-postgres.js";
let errorHandler: typeof import("../middleware/index.js").errorHandler;
let userProfileRoutes: typeof import("../routes/user-profiles.js").userProfileRoutes;
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
@ -42,6 +42,16 @@ describeEmbeddedPostgres("GET /companies/:companyId/users/:userSlug/profile", ()
}, 20_000);
beforeEach(async () => {
vi.resetModules();
vi.doUnmock("../routes/user-profiles.js");
vi.doUnmock("../routes/authz.js");
vi.doUnmock("../middleware/index.js");
const [routes, middleware] = await Promise.all([
vi.importActual<typeof import("../routes/user-profiles.js")>("../routes/user-profiles.js"),
vi.importActual<typeof import("../middleware/index.js")>("../middleware/index.js"),
]);
userProfileRoutes = routes.userProfileRoutes;
errorHandler = middleware.errorHandler;
companyId = randomUUID();
userId = randomUUID();
agentId = randomUUID();
@ -97,6 +107,9 @@ describeEmbeddedPostgres("GET /companies/:companyId/users/:userSlug/profile", ()
});
function createApp() {
if (!userProfileRoutes || !errorHandler) {
throw new Error("user profile route test dependencies were not loaded");
}
const app = express();
app.use(express.json());
app.use((req, _res, next) => {

View file

@ -148,8 +148,16 @@ describe("workspace runtime service route authorization", () => {
beforeEach(() => {
vi.resetModules();
vi.doUnmock("../telemetry.js");
vi.doUnmock("../services/index.js");
vi.doUnmock("../services/workspace-runtime.js");
vi.doUnmock("../routes/workspace-runtime-service-authz.js");
vi.doUnmock("../routes/projects.js");
vi.doUnmock("../routes/execution-workspaces.js");
vi.doUnmock("../routes/authz.js");
vi.doUnmock("../middleware/index.js");
registerModuleMocks();
vi.clearAllMocks();
vi.resetAllMocks();
mockSecretService.normalizeEnvBindingsForPersistence.mockImplementation(async (_companyId, env) => env);
mockProjectService.resolveByReference.mockResolvedValue({ ambiguous: false, project: null });
mockProjectService.create.mockResolvedValue(buildProject());

View file

@ -33,6 +33,9 @@ You MUST delegate work rather than doing it yourself. When a task is assigned to
- If a report is blocked, help unblock them -- escalate to the board if needed.
- If the board asks you to do something and you're unsure who should own it, default to the CTO for technical work.
- Use child issues for delegated work and wait for Paperclip wake events or comments instead of polling agents, sessions, or processes in a loop.
- Create child issues directly when ownership and scope are clear. Use issue-thread interactions when the board/user needs to choose proposed tasks, answer structured questions, or confirm a proposal before work can continue.
- Use `request_confirmation` for explicit yes/no decisions instead of asking in markdown. For plan approval, update the `plan` document, create a confirmation targeting the latest plan revision with an idempotency key like `confirmation:{issueId}:plan:{revisionId}`, and wait for acceptance before delegating implementation subtasks.
- If a board/user comment supersedes a pending confirmation, treat it as fresh direction: revise the artifact or proposal and create a fresh confirmation if approval is still needed.
- Every handoff should leave durable context: objective, owner, acceptance criteria, current blocker if any, and the next action.
- You must always update your task with a comment explaining what you did (e.g., who you delegated to and why).

View file

@ -48,6 +48,9 @@ Status quick guide:
## 6. Delegation
- Create subtasks with `POST /api/companies/{companyId}/issues`. Always set `parentId` and `goalId`. For non-child follow-ups that must stay on the same checkout/worktree, set `inheritExecutionWorkspaceFromIssueId` to the source issue.
- When you know the needed work and owner, create those subtasks directly. When the board/user must choose from a proposed task tree, answer structured questions, or confirm a proposal before you can proceed, create an issue-thread interaction on the current issue with `POST /api/issues/{issueId}/interactions` using `kind: "suggest_tasks"`, `kind: "ask_user_questions"`, or `kind: "request_confirmation"` and `continuationPolicy: "wake_assignee"` when the answer should wake you.
- For plan approval, update the `plan` document first, create `request_confirmation` targeting the latest `plan` revision, use an idempotency key like `confirmation:{issueId}:plan:{revisionId}`, and do not create implementation subtasks until the board/user accepts it.
- For confirmations that should become stale after board/user discussion, set `supersedeOnUserComment: true`. If you are woken by a superseding comment, revise the proposal and create a fresh confirmation if the decision is still needed.
- Use `paperclip-create-agent` skill when hiring new agents.
- Assign work to the right agent for the job.

View file

@ -6,6 +6,9 @@ You are an agent at Paperclip company.
- Keep the work moving until it is done. If you need QA to review it, ask them. If you need your boss to review it, ask them.
- Leave durable progress in task comments, documents, or work products, and make the next action clear before you exit.
- Use child issues for parallel or long delegated work instead of polling agents, sessions, or processes.
- Create child issues directly when you know what needs to be done. If the board/user needs to choose suggested tasks, answer structured questions, or confirm a proposal first, create an issue-thread interaction on the current issue with `POST /api/issues/{issueId}/interactions` using `kind: "suggest_tasks"`, `kind: "ask_user_questions"`, or `kind: "request_confirmation"`.
- Use `request_confirmation` instead of asking for yes/no decisions in markdown. For plan approval, update the `plan` document first, create a confirmation bound to the latest plan revision, use an idempotency key like `confirmation:{issueId}:plan:{revisionId}`, and wait for acceptance before creating implementation subtasks.
- Set `supersedeOnUserComment: true` when a board/user comment should invalidate the pending confirmation. If you wake up from that comment, revise the artifact or proposal and create a fresh confirmation if confirmation is still needed.
- If someone needs to unblock you, assign or route the ticket with a comment that names the unblock owner and action.
- Respect budget, pause/cancel, approval gates, and company boundaries.

View file

@ -2299,6 +2299,35 @@ async function resolveInviteResolutionTarget(
url: URL
): Promise<ResolvedInviteResolutionTarget> {
const hostname = hostnameForResolution(url);
if (parseIpv4Address(hostname)) {
if (!isPublicIpAddress(hostname)) {
throw badRequest(
"url resolves to a private, local, multicast, or reserved address"
);
}
return {
url,
resolvedAddress: hostname,
resolvedAddresses: [hostname],
hostHeader: url.host,
tlsServername: undefined,
};
}
const literalIpVersion = isIP(hostname);
if (literalIpVersion !== 0) {
if (!isPublicIpAddress(hostname)) {
throw badRequest(
"url resolves to a private, local, multicast, or reserved address"
);
}
return {
url,
resolvedAddress: hostname,
resolvedAddresses: [hostname],
hostHeader: url.host,
tlsServername: undefined,
};
}
const results = await lookupInviteResolutionHostname(hostname);
if (results.length === 0) {
throw badRequest("url hostname did not resolve to any addresses");

View file

@ -6,7 +6,9 @@ import type { Db } from "@paperclipai/db";
import { issueExecutionDecisions } from "@paperclipai/db";
import {
addIssueCommentSchema,
acceptIssueThreadInteractionSchema,
createIssueAttachmentMetadataSchema,
createIssueThreadInteractionSchema,
createIssueWorkProductSchema,
createIssueLabelSchema,
checkoutIssueSchema,
@ -19,7 +21,9 @@ import {
linkIssueApprovalSchema,
issueDocumentKeySchema,
ISSUE_CONTINUATION_SUMMARY_DOCUMENT_KEY,
rejectIssueThreadInteractionSchema,
restoreIssueDocumentRevisionSchema,
respondIssueThreadInteractionSchema,
updateIssueWorkProductSchema,
upsertIssueDocumentSchema,
updateIssueSchema,
@ -40,6 +44,7 @@ import {
heartbeatService,
instanceSettingsService,
issueApprovalService,
issueThreadInteractionService,
ISSUE_LIST_DEFAULT_LIMIT,
ISSUE_LIST_MAX_LIMIT,
issueReferenceService,
@ -53,7 +58,7 @@ import {
} from "../services/index.js";
import { logger } from "../middleware/logger.js";
import { conflict, forbidden, HttpError, notFound, unauthorized } from "../errors.js";
import { assertCompanyAccess, getActorInfo } from "./authz.js";
import { assertBoard, assertCompanyAccess, getActorInfo } from "./authz.js";
import {
assertNoAgentHostWorkspaceCommandMutation,
collectIssueWorkspaceCommandPaths,
@ -185,6 +190,65 @@ function shouldImplicitlyMoveCommentedIssueToTodoForAgent(input: {
return true;
}
function queueResolvedInteractionContinuationWakeup(input: {
heartbeat: ReturnType<typeof heartbeatService>;
issue: { id: string; assigneeAgentId: string | null; status: string };
interaction: {
id: string;
kind: string;
status: string;
continuationPolicy: string;
sourceCommentId?: string | null;
sourceRunId?: string | null;
};
actor: { actorType: "user" | "agent"; actorId: string };
source: string;
}) {
if (
input.interaction.continuationPolicy !== "wake_assignee"
&& input.interaction.continuationPolicy !== "wake_assignee_on_accept"
) return;
if (
input.interaction.continuationPolicy === "wake_assignee_on_accept"
&& input.interaction.status !== "accepted"
) return;
if (input.interaction.status === "expired") return;
if (!input.issue.assigneeAgentId || isClosedIssueStatus(input.issue.status)) return;
void input.heartbeat.wakeup(input.issue.assigneeAgentId, {
source: "automation",
triggerDetail: "system",
reason: "issue_commented",
payload: {
issueId: input.issue.id,
interactionId: input.interaction.id,
interactionKind: input.interaction.kind,
interactionStatus: input.interaction.status,
sourceCommentId: input.interaction.sourceCommentId ?? null,
sourceRunId: input.interaction.sourceRunId ?? null,
mutation: "interaction",
},
requestedByActorType: input.actor.actorType,
requestedByActorId: input.actor.actorId,
contextSnapshot: {
issueId: input.issue.id,
taskId: input.issue.id,
interactionId: input.interaction.id,
interactionKind: input.interaction.kind,
interactionStatus: input.interaction.status,
sourceCommentId: input.interaction.sourceCommentId ?? null,
sourceRunId: input.interaction.sourceRunId ?? null,
wakeReason: "issue_commented",
source: input.source,
},
}).catch((err) => logger.warn({
err,
issueId: input.issue.id,
interactionId: input.interaction.id,
agentId: input.issue.assigneeAgentId,
}, "failed to wake assignee on issue interaction resolution"));
}
function diffExecutionParticipants(
previousPolicy: NormalizedExecutionPolicy | null,
nextPolicy: NormalizedExecutionPolicy | null,
@ -351,6 +415,34 @@ export function issueRoutes(
return value === true || value === "true" || value === "1";
}
async function logExpiredRequestConfirmations(input: {
issue: { id: string; companyId: string; identifier?: string | null };
interactions: Array<{ id: string; kind: string; status: string; result?: unknown }>;
actor: ReturnType<typeof getActorInfo>;
source: string;
}) {
for (const interaction of input.interactions) {
await logActivity(db, {
companyId: input.issue.companyId,
actorType: input.actor.actorType,
actorId: input.actor.actorId,
agentId: input.actor.agentId,
runId: input.actor.runId,
action: "issue.thread_interaction_expired",
entityType: "issue",
entityId: input.issue.id,
details: {
identifier: input.issue.identifier ?? null,
interactionId: interaction.id,
interactionKind: interaction.kind,
interactionStatus: interaction.status,
source: input.source,
result: interaction.result ?? null,
},
});
}
}
function parseDateQuery(value: unknown, field: string) {
if (typeof value !== "string" || value.trim().length === 0) return undefined;
const parsed = new Date(value);
@ -1041,6 +1133,28 @@ export function issueRoutes(
},
});
if (!result.created) {
const expiredInteractions = await issueThreadInteractionService(db).expireStaleRequestConfirmationsForIssueDocument(
issue,
{
id: doc.id,
key: doc.key,
latestRevisionId: doc.latestRevisionId,
latestRevisionNumber: doc.latestRevisionNumber,
},
{
agentId: actor.agentId,
userId: actor.actorType === "user" ? actor.actorId : null,
},
);
await logExpiredRequestConfirmations({
issue,
interactions: expiredInteractions,
actor,
source: "issue.document_updated",
});
}
res.status(result.created ? 201 : 200).json(doc);
});
@ -1118,6 +1232,26 @@ export function issueRoutes(
},
});
const expiredInteractions = await issueThreadInteractionService(db).expireStaleRequestConfirmationsForIssueDocument(
issue,
{
id: result.document.id,
key: result.document.key,
latestRevisionId: result.document.latestRevisionId,
latestRevisionNumber: result.document.latestRevisionNumber,
},
{
agentId: actor.agentId,
userId: actor.actorType === "user" ? actor.actorId : null,
},
);
await logExpiredRequestConfirmations({
issue,
interactions: expiredInteractions,
actor,
source: "issue.document_restored",
});
res.json(result.document);
},
);
@ -1169,6 +1303,25 @@ export function issueRoutes(
}),
},
});
const expiredInteractions = await issueThreadInteractionService(db).expireStaleRequestConfirmationsForIssueDocument(
issue,
{
id: removed.id,
key: removed.key,
latestRevisionId: null,
latestRevisionNumber: null,
},
{
agentId: actor.agentId,
userId: actor.actorType === "user" ? actor.actorId : null,
},
);
await logExpiredRequestConfirmations({
issue,
interactions: expiredInteractions,
actor,
source: "issue.document_deleted",
});
res.json({ ok: true });
});
@ -2032,6 +2185,21 @@ export function issueRoutes(
},
});
const expiredInteractions = await issueThreadInteractionService(db).expireRequestConfirmationsSupersededByComment(
issue,
comment,
{
agentId: actor.agentId,
userId: actor.actorType === "user" ? actor.actorId : null,
},
);
await logExpiredRequestConfirmations({
issue,
interactions: expiredInteractions,
actor,
source: "issue.comment",
});
} else if (updateReferenceSummaryAfter) {
issueResponse = {
...issueResponse,
@ -2440,6 +2608,269 @@ export function issueRoutes(
res.json(comments);
});
router.get("/issues/:id/interactions", async (req, res) => {
const id = req.params.id as string;
const issue = await svc.getById(id);
if (!issue) {
res.status(404).json({ error: "Issue not found" });
return;
}
assertCompanyAccess(req, issue.companyId);
const interactions = await issueThreadInteractionService(db).listForIssue(id);
res.json(interactions);
});
router.post("/issues/:id/interactions", validate(createIssueThreadInteractionSchema), async (req, res) => {
const id = req.params.id as string;
const issue = await svc.getById(id);
if (!issue) {
res.status(404).json({ error: "Issue not found" });
return;
}
assertCompanyAccess(req, issue.companyId);
if (req.actor.type === "agent") {
if (!(await assertAgentIssueMutationAllowed(req, res, issue))) return;
} else {
assertBoard(req);
}
const actor = getActorInfo(req);
const agentSourceRunId = req.actor.type === "agent" ? requireAgentRunId(req, res) : null;
if (req.actor.type === "agent" && !agentSourceRunId) return;
const interaction = await issueThreadInteractionService(db).create(issue, {
...req.body,
sourceRunId: req.actor.type === "agent" ? agentSourceRunId : req.body.sourceRunId ?? null,
}, {
agentId: actor.agentId,
userId: actor.actorType === "user" ? actor.actorId : null,
});
await logActivity(db, {
companyId: issue.companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
runId: actor.runId,
action: "issue.thread_interaction_created",
entityType: "issue",
entityId: issue.id,
details: {
interactionId: interaction.id,
interactionKind: interaction.kind,
interactionStatus: interaction.status,
continuationPolicy: interaction.continuationPolicy,
},
});
res.status(201).json(interaction);
});
router.post(
"/issues/:id/interactions/:interactionId/accept",
validate(acceptIssueThreadInteractionSchema),
async (req, res) => {
const id = req.params.id as string;
const interactionId = req.params.interactionId as string;
const issue = await svc.getById(id);
if (!issue) {
res.status(404).json({ error: "Issue not found" });
return;
}
assertCompanyAccess(req, issue.companyId);
assertBoard(req);
const actor = getActorInfo(req);
const { interaction, createdIssues, continuationIssue } = await issueThreadInteractionService(db).acceptInteraction(issue, interactionId, req.body, {
agentId: actor.agentId,
userId: actor.actorType === "user" ? actor.actorId : null,
});
const continuationWakeIssue = continuationIssue ?? issue;
await logActivity(db, {
companyId: issue.companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
runId: actor.runId,
action: interaction.status === "expired"
? "issue.thread_interaction_expired"
: "issue.thread_interaction_accepted",
entityType: "issue",
entityId: issue.id,
details: {
interactionId: interaction.id,
interactionKind: interaction.kind,
interactionStatus: interaction.status,
createdTaskCount:
interaction.kind === "suggest_tasks"
? (interaction.result?.createdTasks?.length ?? 0)
: 0,
skippedTaskCount:
interaction.kind === "suggest_tasks"
? (interaction.result?.skippedClientKeys?.length ?? 0)
: 0,
},
});
if (continuationIssue) {
await logActivity(db, {
companyId: issue.companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
runId: actor.runId,
action: "issue.updated",
entityType: "issue",
entityId: issue.id,
details: {
identifier: issue.identifier,
status: continuationIssue.status,
assigneeAgentId: continuationIssue.assigneeAgentId ?? null,
assigneeUserId: continuationIssue.assigneeUserId ?? null,
source: "request_confirmation_accept",
interactionId: interaction.id,
_previous: {
status: issue.status,
assigneeAgentId: issue.assigneeAgentId ?? null,
assigneeUserId: issue.assigneeUserId ?? null,
},
},
});
}
for (const createdIssue of createdIssues) {
void queueIssueAssignmentWakeup({
heartbeat,
issue: createdIssue,
reason: "issue_assigned",
mutation: "interaction_accept",
contextSource: "issue.interaction.accept",
requestedByActorType: actor.actorType,
requestedByActorId: actor.actorId,
});
}
queueResolvedInteractionContinuationWakeup({
heartbeat,
issue: continuationWakeIssue,
interaction,
actor,
source: "issue.interaction.accept",
});
res.json(interaction);
},
);
router.post(
"/issues/:id/interactions/:interactionId/reject",
validate(rejectIssueThreadInteractionSchema),
async (req, res) => {
const id = req.params.id as string;
const interactionId = req.params.interactionId as string;
const issue = await svc.getById(id);
if (!issue) {
res.status(404).json({ error: "Issue not found" });
return;
}
assertCompanyAccess(req, issue.companyId);
assertBoard(req);
const actor = getActorInfo(req);
const interaction = await issueThreadInteractionService(db).rejectInteraction(issue, interactionId, req.body, {
agentId: actor.agentId,
userId: actor.actorType === "user" ? actor.actorId : null,
});
await logActivity(db, {
companyId: issue.companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
runId: actor.runId,
action: interaction.status === "expired"
? "issue.thread_interaction_expired"
: "issue.thread_interaction_rejected",
entityType: "issue",
entityId: issue.id,
details: {
interactionId: interaction.id,
interactionKind: interaction.kind,
interactionStatus: interaction.status,
rejectionReason:
interaction.kind === "suggest_tasks"
? (interaction.result?.rejectionReason ?? null)
: interaction.kind === "request_confirmation"
? (interaction.result?.reason ?? null)
: null,
},
});
queueResolvedInteractionContinuationWakeup({
heartbeat,
issue,
interaction,
actor,
source: "issue.interaction.reject",
});
res.json(interaction);
},
);
router.post(
"/issues/:id/interactions/:interactionId/respond",
validate(respondIssueThreadInteractionSchema),
async (req, res) => {
const id = req.params.id as string;
const interactionId = req.params.interactionId as string;
const issue = await svc.getById(id);
if (!issue) {
res.status(404).json({ error: "Issue not found" });
return;
}
assertCompanyAccess(req, issue.companyId);
assertBoard(req);
const actor = getActorInfo(req);
const interaction = await issueThreadInteractionService(db).answerQuestions(issue, interactionId, req.body, {
agentId: actor.agentId,
userId: actor.actorType === "user" ? actor.actorId : null,
});
await logActivity(db, {
companyId: issue.companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
runId: actor.runId,
action: "issue.thread_interaction_answered",
entityType: "issue",
entityId: issue.id,
details: {
interactionId: interaction.id,
interactionKind: interaction.kind,
interactionStatus: interaction.status,
answeredQuestionCount:
interaction.kind === "ask_user_questions"
? (interaction.result?.answers?.length ?? 0)
: 0,
},
});
queueResolvedInteractionContinuationWakeup({
heartbeat,
issue,
interaction,
actor,
source: "issue.interaction.respond",
});
res.json(interaction);
},
);
router.get("/issues/:id/comments/:commentId", async (req, res) => {
const id = req.params.id as string;
const commentId = req.params.commentId as string;
@ -2737,6 +3168,21 @@ export function issueRoutes(
},
});
const expiredInteractions = await issueThreadInteractionService(db).expireRequestConfirmationsSupersededByComment(
currentIssue,
comment,
{
agentId: actor.agentId,
userId: actor.actorType === "user" ? actor.actorId : null,
},
);
await logExpiredRequestConfirmations({
issue: currentIssue,
interactions: expiredInteractions,
actor,
source: "issue.comment",
});
// Merge all wakeups from this comment into one enqueue per agent to avoid duplicate runs.
void (async () => {
const wakeups = new Map<string, Parameters<typeof heartbeat.wakeup>[1]>();

View file

@ -19,6 +19,7 @@ export {
issueService,
type IssueFilters,
} from "./issues.js";
export { issueThreadInteractionService } from "./issue-thread-interactions.js";
export { issueApprovalService } from "./issue-approvals.js";
export { issueReferenceService } from "./issue-references.js";
export { goalService } from "./goals.js";

View file

@ -0,0 +1,215 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const mockCreateChild = vi.fn();
vi.mock("./issues.js", () => ({
issueService: () => ({
createChild: mockCreateChild,
}),
}));
type SelectRow = Record<string, unknown>;
function createSelectChain(rows: SelectRow[]) {
return {
from() {
return {
where() {
return {
then(callback: (rows: SelectRow[]) => unknown) {
return Promise.resolve(callback(rows));
},
};
},
};
},
};
}
function createFakeDb(args: {
interactionRow: Record<string, unknown>;
parentRows?: SelectRow[];
}) {
let interactionRow = { ...args.interactionRow };
const issueTouches: Array<Record<string, unknown>> = [];
const interactionUpdates: Array<Record<string, unknown>> = [];
let selectCallCount = 0;
const db: any = {
select: vi.fn(() => {
selectCallCount += 1;
return createSelectChain(selectCallCount === 1 ? [interactionRow] : (args.parentRows ?? []));
}),
update: vi.fn((table: unknown) => ({
set(values: Record<string, unknown>) {
return {
where() {
if ("status" in values || "result" in values || "resolvedAt" in values) {
interactionUpdates.push(values);
interactionRow = { ...interactionRow, ...values };
return {
returning: async () => [interactionRow],
};
}
if ("updatedAt" in values) {
issueTouches.push(values);
return Promise.resolve(undefined);
}
throw new Error(`Unexpected update target: ${String(table)}`);
},
};
},
})),
insert: vi.fn(),
transaction: async (callback: (tx: typeof db) => Promise<void>) => callback(db),
};
return {
db,
getInteractionRow: () => interactionRow,
issueTouches,
interactionUpdates,
};
}
describe("issueThreadInteractionService", () => {
beforeEach(() => {
vi.resetModules();
vi.clearAllMocks();
});
it("create reuses an existing interaction for the same idempotency key", async () => {
const { issueThreadInteractionService } = await import("./issue-thread-interactions.js");
const existingRow = {
id: "interaction-1",
companyId: "company-1",
issueId: "11111111-1111-4111-8111-111111111111",
kind: "suggest_tasks",
status: "pending",
continuationPolicy: "wake_assignee",
idempotencyKey: "run-1:suggest",
sourceCommentId: null,
sourceRunId: "22222222-2222-4222-8222-222222222222",
title: "Break the work down",
summary: "Created from the current agent run.",
createdByAgentId: "agent-1",
createdByUserId: null,
resolvedByAgentId: null,
resolvedByUserId: null,
payload: {
version: 1,
tasks: [{ clientKey: "task-1", title: "One" }],
},
result: null,
resolvedAt: null,
createdAt: new Date("2026-04-20T10:00:00.000Z"),
updatedAt: new Date("2026-04-20T10:00:00.000Z"),
};
const db: any = {
select: vi.fn(() => createSelectChain([existingRow])),
insert: vi.fn(),
update: vi.fn(),
};
const svc = issueThreadInteractionService(db as never);
const created = await svc.create({
id: "11111111-1111-4111-8111-111111111111",
companyId: "company-1",
}, {
kind: "suggest_tasks",
idempotencyKey: "run-1:suggest",
sourceRunId: "22222222-2222-4222-8222-222222222222",
title: "Break the work down",
summary: "Created from the current agent run.",
continuationPolicy: "wake_assignee",
payload: {
version: 1,
tasks: [{ clientKey: "task-1", title: "One" }],
},
}, {
agentId: "agent-1",
});
expect(created.id).toBe("interaction-1");
expect(created.idempotencyKey).toBe("run-1:suggest");
expect(db.insert).not.toHaveBeenCalled();
});
it("answerQuestions normalizes duplicate option ids and persists answered results", async () => {
const { issueThreadInteractionService } = await import("./issue-thread-interactions.js");
const interactionRow = {
id: "interaction-2",
companyId: "company-1",
issueId: "11111111-1111-4111-8111-111111111111",
kind: "ask_user_questions",
status: "pending",
continuationPolicy: "wake_assignee",
sourceCommentId: null,
sourceRunId: null,
title: null,
summary: null,
createdByAgentId: null,
createdByUserId: "local-board",
resolvedByAgentId: null,
resolvedByUserId: null,
payload: {
version: 1,
questions: [
{
id: "scope",
prompt: "Pick one scope",
selectionMode: "single",
required: true,
options: [
{ id: "phase-1", label: "Phase 1" },
{ id: "phase-2", label: "Phase 2" },
],
},
{
id: "extras",
prompt: "Pick extras",
selectionMode: "multi",
options: [
{ id: "tests", label: "Tests" },
{ id: "docs", label: "Docs" },
],
},
],
},
result: null,
resolvedAt: null,
createdAt: new Date("2026-04-20T10:00:00.000Z"),
updatedAt: new Date("2026-04-20T10:00:00.000Z"),
};
const state = createFakeDb({ interactionRow });
const svc = issueThreadInteractionService(state.db as never);
const result = await svc.answerQuestions({
id: "11111111-1111-4111-8111-111111111111",
companyId: "company-1",
}, "interaction-2", {
answers: [
{ questionId: "scope", optionIds: ["phase-1"] },
{ questionId: "extras", optionIds: ["docs", "tests", "docs"] },
],
summaryMarkdown: "Phase 1 with tests and docs.",
}, {
userId: "local-board",
});
expect(result.status).toBe("answered");
expect(result.result).toEqual({
version: 1,
answers: [
{ questionId: "scope", optionIds: ["phase-1"] },
{ questionId: "extras", optionIds: ["docs", "tests"] },
],
summaryMarkdown: "Phase 1 with tests and docs.",
});
expect(state.interactionUpdates).toHaveLength(1);
expect(state.issueTouches).toHaveLength(1);
});
});

File diff suppressed because it is too large Load diff

View file

@ -77,6 +77,7 @@ const OPERATION_CAPABILITIES: Record<string, readonly PluginCapability[]> = {
"issues.requestWakeup": ["issues.wakeup"],
"issues.requestWakeups": ["issues.wakeup"],
"issue.comments.create": ["issue.comments.create"],
"issue.interactions.create": ["issue.interactions.create"],
"activity.log": ["activity.log.write"],
"metrics.write": ["metrics.write"],
"telemetry.track": ["telemetry.track"],

View file

@ -21,11 +21,12 @@ import type {
PluginIssueAssigneeSummary,
PluginIssueOrchestrationSummary,
} from "@paperclipai/plugin-sdk";
import type { IssueDocumentSummary } from "@paperclipai/shared";
import type { CreateIssueThreadInteraction, IssueDocumentSummary } from "@paperclipai/shared";
import { companyService } from "./companies.js";
import { agentService } from "./agents.js";
import { projectService } from "./projects.js";
import { issueService } from "./issues.js";
import { issueThreadInteractionService } from "./issue-thread-interactions.js";
import { goalService } from "./goals.js";
import { documentService } from "./documents.js";
import { heartbeatService } from "./heartbeat.js";
@ -1506,6 +1507,29 @@ export function buildHostServices(
});
return comment;
},
async createInteraction(params) {
const companyId = ensureCompanyId(params.companyId);
await ensurePluginAvailableForCompany(companyId);
const issue = requireInCompany("Issue", await issues.getById(params.issueId), companyId);
const interaction = await issueThreadInteractionService(db).create(issue, params.interaction as CreateIssueThreadInteraction, {
agentId: params.authorAgentId ?? null,
});
await logPluginActivity({
companyId,
action: "issue.thread_interaction_created",
entityType: "issue",
entityId: issue.id,
actor: { actorAgentId: params.authorAgentId ?? null },
details: {
identifier: issue.identifier,
interactionId: interaction.id,
interactionKind: interaction.kind,
interactionStatus: interaction.status,
continuationPolicy: interaction.continuationPolicy,
},
});
return interaction as any;
},
},
issueDocuments: {