mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-14 01:50:39 +09:00
[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:
parent
014aa0eb2d
commit
a957394420
93 changed files with 10089 additions and 752 deletions
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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" },
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
584
server/src/__tests__/issue-thread-interaction-routes.test.ts
Normal file
584
server/src/__tests__/issue-thread-interaction-routes.test.ts
Normal 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,
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
881
server/src/__tests__/issue-thread-interactions-service.test.ts
Normal file
881
server/src/__tests__/issue-thread-interactions-service.test.ts
Normal 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,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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: () => ({
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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([]);
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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]>();
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
215
server/src/services/issue-thread-interactions.test.ts
Normal file
215
server/src/services/issue-thread-interactions.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
1152
server/src/services/issue-thread-interactions.ts
Normal file
1152
server/src/services/issue-thread-interactions.ts
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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"],
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue