83e7ecc58e
## Thinking Path > - Paperclip orchestrates AI agents for zero-human companies > - The agent live-run route lets operators trigger a manual heartbeat invocation so an agent can pick up a specific issue or step out of band > - The current route flow drops the caller's scope (issue/run context) when forwarding the manual invoke into the heartbeat service, so the resulting run loses the targeting the operator specified > - This pull request threads the operator-supplied scope through the manual invoke path on both the server route and the UI client, with a regression test that confirms the scope round-trips > - The benefit is manual heartbeat invokes from the live-run UI actually pick up the scoped issue/run instead of falling through to the agent's default routine ## What Changed - `server/src/routes/agents.ts`: forward the operator-supplied scope into the manual invoke heartbeat service call - `server/src/__tests__/agent-live-run-routes.test.ts`: new test verifying the manual invoke path preserves scope - `ui/src/api/agents.ts`: pass scope through the live-run client API ## Verification - `pnpm vitest run --no-coverage server/src/__tests__/agent-live-run-routes.test.ts` - `pnpm typecheck` clean ## Risks Low. The change is purely additive on the route surface — handlers that did not previously pass scope continue to work; handlers that did pass it now have it preserved instead of dropped. ## Model Used Claude Opus 4.7 (1M context) ## 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 — new test covers the preserved-scope path - [x] If this change affects the UI, I have included before/after screenshots — N/A (internal API change, no visible UI shift) - [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
601 lines
19 KiB
TypeScript
601 lines
19 KiB
TypeScript
import express from "express";
|
|
import request from "supertest";
|
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
const mockAgentService = vi.hoisted(() => ({
|
|
getById: vi.fn(),
|
|
}));
|
|
|
|
const mockHeartbeatService = vi.hoisted(() => ({
|
|
buildRunOutputSilence: vi.fn(),
|
|
getRunIssueSummary: vi.fn(),
|
|
getActiveRunIssueSummaryForAgent: vi.fn(),
|
|
getRunLogAccess: vi.fn(),
|
|
readLog: vi.fn(),
|
|
wakeup: vi.fn(),
|
|
}));
|
|
|
|
const mockIssueService = vi.hoisted(() => ({
|
|
getById: vi.fn(),
|
|
getByIdentifier: vi.fn(),
|
|
}));
|
|
|
|
const mockInstanceSettingsService = vi.hoisted(() => ({
|
|
get: vi.fn(),
|
|
getExperimental: vi.fn(),
|
|
getGeneral: vi.fn(),
|
|
listCompanyIds: vi.fn(),
|
|
}));
|
|
|
|
const routeAgentId = "11111111-1111-4111-8111-111111111111";
|
|
|
|
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: () => ({}),
|
|
accessService: () => ({}),
|
|
approvalService: () => ({}),
|
|
companySkillService: () => ({ listRuntimeSkillEntries: vi.fn() }),
|
|
budgetService: () => ({}),
|
|
heartbeatService: () => mockHeartbeatService,
|
|
issueApprovalService: () => ({}),
|
|
issueService: () => mockIssueService,
|
|
logActivity: vi.fn(),
|
|
secretService: () => ({}),
|
|
syncInstructionsBundleConfigFromFilePath: vi.fn((_agent, config) => config),
|
|
workspaceOperationService: () => ({}),
|
|
}));
|
|
|
|
vi.doMock("../adapters/index.js", () => ({
|
|
findServerAdapter: vi.fn(),
|
|
listAdapterModels: vi.fn(),
|
|
detectAdapterModel: vi.fn(),
|
|
findActiveServerAdapter: vi.fn(),
|
|
requireServerAdapter: vi.fn(),
|
|
}));
|
|
}
|
|
|
|
async function createApp(db: Record<string, unknown> = {}) {
|
|
const [{ agentRoutes }, { errorHandler }] = await Promise.all([
|
|
vi.importActual<typeof import("../routes/agents.js")>("../routes/agents.js"),
|
|
vi.importActual<typeof import("../middleware/index.js")>("../middleware/index.js"),
|
|
]);
|
|
const app = express();
|
|
app.use(express.json());
|
|
app.use((req, _res, next) => {
|
|
(req as any).actor = {
|
|
type: "board",
|
|
userId: "local-board",
|
|
companyIds: ["company-1"],
|
|
source: "local_implicit",
|
|
isInstanceAdmin: false,
|
|
};
|
|
next();
|
|
});
|
|
app.use("/api", agentRoutes(db as any));
|
|
app.use(errorHandler);
|
|
return app;
|
|
}
|
|
|
|
function createLiveRunsDbStub(rows: Array<Record<string, unknown>>) {
|
|
const limit = vi.fn(async (value: number) => rows.slice(0, value));
|
|
const orderedQuery = {
|
|
limit,
|
|
then: (resolve: (value: Array<Record<string, unknown>>) => unknown) => Promise.resolve(rows).then(resolve),
|
|
};
|
|
const query = {
|
|
from: vi.fn().mockReturnThis(),
|
|
innerJoin: vi.fn().mockReturnThis(),
|
|
where: vi.fn().mockReturnThis(),
|
|
orderBy: vi.fn().mockReturnValue(orderedQuery),
|
|
};
|
|
|
|
return {
|
|
db: {
|
|
select: vi.fn().mockReturnValue(query),
|
|
},
|
|
limit,
|
|
};
|
|
}
|
|
|
|
async function requestApp(
|
|
app: express.Express,
|
|
buildRequest: (baseUrl: string) => request.Test,
|
|
) {
|
|
const { createServer } = await vi.importActual<typeof import("node:http")>("node:http");
|
|
const server = createServer(app);
|
|
try {
|
|
await new Promise<void>((resolve) => {
|
|
server.listen(0, "127.0.0.1", resolve);
|
|
});
|
|
const address = server.address();
|
|
if (!address || typeof address === "string") {
|
|
throw new Error("Expected HTTP server to listen on a TCP port");
|
|
}
|
|
return await buildRequest(`http://127.0.0.1:${address.port}`);
|
|
} finally {
|
|
if (server.listening) {
|
|
await new Promise<void>((resolve, reject) => {
|
|
server.close((error) => {
|
|
if (error) reject(error);
|
|
else resolve();
|
|
});
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
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");
|
|
vi.doUnmock("../middleware/index.js");
|
|
registerModuleMocks();
|
|
vi.clearAllMocks();
|
|
mockIssueService.getByIdentifier.mockResolvedValue({
|
|
id: "issue-1",
|
|
companyId: "company-1",
|
|
executionRunId: "run-1",
|
|
assigneeAgentId: "agent-1",
|
|
status: "in_progress",
|
|
});
|
|
mockIssueService.getById.mockResolvedValue(null);
|
|
mockAgentService.getById.mockResolvedValue({
|
|
id: "agent-1",
|
|
companyId: "company-1",
|
|
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.buildRunOutputSilence.mockResolvedValue(null);
|
|
mockHeartbeatService.getRunIssueSummary.mockResolvedValue({
|
|
id: "run-1",
|
|
status: "running",
|
|
invocationSource: "on_demand",
|
|
triggerDetail: "manual",
|
|
contextCommentId: "comment-1",
|
|
contextWakeCommentId: "comment-1",
|
|
startedAt: new Date("2026-04-10T09:30:00.000Z"),
|
|
finishedAt: null,
|
|
createdAt: new Date("2026-04-10T09:29:59.000Z"),
|
|
agentId: "agent-1",
|
|
issueId: "issue-1",
|
|
});
|
|
mockHeartbeatService.getActiveRunIssueSummaryForAgent.mockResolvedValue(null);
|
|
mockHeartbeatService.buildRunOutputSilence.mockResolvedValue(null);
|
|
mockHeartbeatService.getRunLogAccess.mockResolvedValue({
|
|
id: "run-1",
|
|
companyId: "company-1",
|
|
logStore: "local_file",
|
|
logRef: "logs/run-1.ndjson",
|
|
});
|
|
mockHeartbeatService.readLog.mockResolvedValue({
|
|
runId: "run-1",
|
|
store: "local_file",
|
|
logRef: "logs/run-1.ndjson",
|
|
content: "chunk",
|
|
nextOffset: 5,
|
|
});
|
|
mockHeartbeatService.wakeup.mockResolvedValue({
|
|
id: "run-1",
|
|
companyId: "company-1",
|
|
agentId: "agent-1",
|
|
status: "queued",
|
|
invocationSource: "on_demand",
|
|
triggerDetail: "manual",
|
|
});
|
|
});
|
|
|
|
it("returns a compact active run payload for issue polling", async () => {
|
|
const res = await requestApp(
|
|
await createApp(),
|
|
(baseUrl) => request(baseUrl).get("/api/issues/pc1a2-1295/active-run"),
|
|
);
|
|
|
|
expect(res.status, JSON.stringify(res.body)).toBe(200);
|
|
expect(mockIssueService.getByIdentifier).toHaveBeenCalledWith("PC1A2-1295");
|
|
expect(mockHeartbeatService.getRunIssueSummary).toHaveBeenCalledWith("run-1");
|
|
expect(res.body).toMatchObject({
|
|
id: "run-1",
|
|
status: "running",
|
|
invocationSource: "on_demand",
|
|
triggerDetail: "manual",
|
|
contextCommentId: "comment-1",
|
|
contextWakeCommentId: "comment-1",
|
|
startedAt: "2026-04-10T09:30:00.000Z",
|
|
finishedAt: null,
|
|
createdAt: "2026-04-10T09:29:59.000Z",
|
|
agentId: "agent-1",
|
|
issueId: "issue-1",
|
|
agentName: "Builder",
|
|
adapterType: "codex_local",
|
|
outputSilence: null,
|
|
});
|
|
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({
|
|
id: "run-foreign",
|
|
status: "running",
|
|
invocationSource: "assignment",
|
|
triggerDetail: "callback",
|
|
startedAt: new Date("2026-04-10T10:00:00.000Z"),
|
|
finishedAt: null,
|
|
createdAt: new Date("2026-04-10T09:59:00.000Z"),
|
|
agentId: "agent-1",
|
|
issueId: "issue-2",
|
|
});
|
|
mockHeartbeatService.getActiveRunIssueSummaryForAgent.mockResolvedValue({
|
|
id: "run-1",
|
|
status: "running",
|
|
invocationSource: "on_demand",
|
|
triggerDetail: "manual",
|
|
startedAt: new Date("2026-04-10T09:30:00.000Z"),
|
|
finishedAt: null,
|
|
createdAt: new Date("2026-04-10T09:29:59.000Z"),
|
|
agentId: "agent-1",
|
|
issueId: "issue-1",
|
|
});
|
|
|
|
const res = await requestApp(
|
|
await createApp(),
|
|
(baseUrl) => request(baseUrl).get("/api/issues/PC1A2-1295/active-run"),
|
|
);
|
|
|
|
expect(res.status, JSON.stringify(res.body)).toBe(200);
|
|
expect(mockHeartbeatService.getRunIssueSummary).toHaveBeenCalledWith("run-1");
|
|
expect(mockHeartbeatService.getActiveRunIssueSummaryForAgent).toHaveBeenCalledWith("agent-1");
|
|
expect(res.body).toMatchObject({
|
|
id: "run-1",
|
|
issueId: "issue-1",
|
|
agentId: "agent-1",
|
|
agentName: "Builder",
|
|
adapterType: "codex_local",
|
|
});
|
|
});
|
|
|
|
it("uses narrow run log metadata lookups for log polling", async () => {
|
|
const res = await requestApp(
|
|
await createApp(),
|
|
(baseUrl) => request(baseUrl).get("/api/heartbeat-runs/run-1/log?offset=12&limitBytes=64"),
|
|
);
|
|
|
|
expect(res.status, JSON.stringify(res.body)).toBe(200);
|
|
expect(mockHeartbeatService.getRunLogAccess).toHaveBeenCalledWith("run-1");
|
|
expect(mockHeartbeatService.readLog).toHaveBeenCalledWith({
|
|
id: "run-1",
|
|
companyId: "company-1",
|
|
logStore: "local_file",
|
|
logRef: "logs/run-1.ndjson",
|
|
}, {
|
|
offset: 12,
|
|
limitBytes: 64,
|
|
});
|
|
expect(res.body).toEqual({
|
|
runId: "run-1",
|
|
store: "local_file",
|
|
logRef: "logs/run-1.ndjson",
|
|
content: "chunk",
|
|
nextOffset: 5,
|
|
});
|
|
});
|
|
|
|
it("caps company live run polling by default", async () => {
|
|
const rows = Array.from({ length: 75 }, (_, index) => ({
|
|
id: `run-${index}`,
|
|
companyId: "company-1",
|
|
status: "running",
|
|
invocationSource: "on_demand",
|
|
triggerDetail: "manual",
|
|
startedAt: new Date("2026-04-10T09:30:00.000Z"),
|
|
finishedAt: null,
|
|
createdAt: new Date(`2026-04-10T09:${String(index % 60).padStart(2, "0")}:00.000Z`),
|
|
agentId: "agent-1",
|
|
agentName: "Builder",
|
|
adapterType: "codex_local",
|
|
logBytes: 0,
|
|
livenessState: "healthy",
|
|
livenessReason: null,
|
|
continuationAttempt: 0,
|
|
lastUsefulActionAt: null,
|
|
nextAction: null,
|
|
lastOutputAt: null,
|
|
lastOutputSeq: null,
|
|
lastOutputStream: null,
|
|
lastOutputBytes: 0,
|
|
processStartedAt: null,
|
|
issueId: "issue-1",
|
|
}));
|
|
const { db, limit } = createLiveRunsDbStub(rows);
|
|
|
|
const res = await requestApp(
|
|
await createApp(db),
|
|
(baseUrl) => request(baseUrl).get("/api/companies/company-1/live-runs"),
|
|
);
|
|
|
|
expect(res.status, JSON.stringify(res.body)).toBe(200);
|
|
expect(limit).toHaveBeenCalledWith(50);
|
|
expect(res.body).toHaveLength(50);
|
|
expect(mockHeartbeatService.buildRunOutputSilence).toHaveBeenCalledTimes(50);
|
|
});
|
|
|
|
it("treats explicit zero or invalid live run limit as the capped default", async () => {
|
|
const rows = Array.from({ length: 75 }, (_, index) => ({
|
|
id: `run-${index}`,
|
|
companyId: "company-1",
|
|
status: "running",
|
|
invocationSource: "on_demand",
|
|
triggerDetail: "manual",
|
|
startedAt: new Date("2026-04-10T09:30:00.000Z"),
|
|
finishedAt: null,
|
|
createdAt: new Date(`2026-04-10T09:${String(index % 60).padStart(2, "0")}:00.000Z`),
|
|
agentId: "agent-1",
|
|
agentName: "Builder",
|
|
adapterType: "codex_local",
|
|
logBytes: 0,
|
|
livenessState: "healthy",
|
|
livenessReason: null,
|
|
continuationAttempt: 0,
|
|
lastUsefulActionAt: null,
|
|
nextAction: null,
|
|
lastOutputAt: null,
|
|
lastOutputSeq: null,
|
|
lastOutputStream: null,
|
|
lastOutputBytes: 0,
|
|
processStartedAt: null,
|
|
issueId: "issue-1",
|
|
}));
|
|
const { db, limit } = createLiveRunsDbStub(rows);
|
|
|
|
const res = await requestApp(
|
|
await createApp(db),
|
|
(baseUrl) => request(baseUrl).get("/api/companies/company-1/live-runs?limit=0&minCount=0"),
|
|
);
|
|
|
|
expect(res.status, JSON.stringify(res.body)).toBe(200);
|
|
expect(limit).toHaveBeenCalledWith(50);
|
|
expect(res.body).toHaveLength(50);
|
|
});
|
|
|
|
it("does not pad with recent runs when no minCount is requested", async () => {
|
|
const liveRows = Array.from({ length: 8 }, (_, index) => ({
|
|
id: `run-live-${index}`,
|
|
companyId: "company-1",
|
|
status: "running",
|
|
invocationSource: "on_demand",
|
|
triggerDetail: "manual",
|
|
startedAt: new Date("2026-04-10T09:30:00.000Z"),
|
|
finishedAt: null,
|
|
createdAt: new Date(`2026-04-10T09:${String(index % 60).padStart(2, "0")}:00.000Z`),
|
|
agentId: "agent-1",
|
|
agentName: "Builder",
|
|
adapterType: "codex_local",
|
|
logBytes: 0,
|
|
livenessState: "healthy",
|
|
livenessReason: null,
|
|
continuationAttempt: 0,
|
|
lastUsefulActionAt: null,
|
|
nextAction: null,
|
|
lastOutputAt: null,
|
|
lastOutputSeq: null,
|
|
lastOutputStream: null,
|
|
lastOutputBytes: 0,
|
|
processStartedAt: null,
|
|
issueId: "issue-1",
|
|
}));
|
|
|
|
const selectCalls: Array<ReturnType<typeof vi.fn>> = [];
|
|
const db = {
|
|
select: vi.fn().mockImplementation(() => {
|
|
const limitFn = vi.fn(async (value: number) => liveRows.slice(0, value));
|
|
const orderedQuery = {
|
|
limit: limitFn,
|
|
then: (resolve: (value: typeof liveRows) => unknown) =>
|
|
Promise.resolve(liveRows).then(resolve),
|
|
};
|
|
const query = {
|
|
from: vi.fn().mockReturnThis(),
|
|
innerJoin: vi.fn().mockReturnThis(),
|
|
where: vi.fn().mockReturnThis(),
|
|
orderBy: vi.fn().mockReturnValue(orderedQuery),
|
|
};
|
|
selectCalls.push(limitFn);
|
|
return query;
|
|
}),
|
|
};
|
|
|
|
const res = await requestApp(
|
|
await createApp(db),
|
|
(baseUrl) => request(baseUrl).get("/api/companies/company-1/live-runs"),
|
|
);
|
|
|
|
expect(res.status, JSON.stringify(res.body)).toBe(200);
|
|
expect(res.body).toHaveLength(8);
|
|
expect(db.select).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("pads with recent runs when minCount is explicitly requested", async () => {
|
|
const liveRows = Array.from({ length: 2 }, (_, index) => ({
|
|
id: `run-live-${index}`,
|
|
companyId: "company-1",
|
|
status: "running",
|
|
invocationSource: "on_demand",
|
|
triggerDetail: "manual",
|
|
startedAt: new Date("2026-04-10T09:30:00.000Z"),
|
|
finishedAt: null,
|
|
createdAt: new Date(`2026-04-10T09:${String(index % 60).padStart(2, "0")}:00.000Z`),
|
|
agentId: "agent-1",
|
|
agentName: "Builder",
|
|
adapterType: "codex_local",
|
|
logBytes: 0,
|
|
livenessState: "healthy",
|
|
livenessReason: null,
|
|
continuationAttempt: 0,
|
|
lastUsefulActionAt: null,
|
|
nextAction: null,
|
|
lastOutputAt: null,
|
|
lastOutputSeq: null,
|
|
lastOutputStream: null,
|
|
lastOutputBytes: 0,
|
|
processStartedAt: null,
|
|
issueId: "issue-1",
|
|
}));
|
|
const recentRows = Array.from({ length: 4 }, (_, index) => ({
|
|
id: `run-recent-${index}`,
|
|
companyId: "company-1",
|
|
status: "succeeded",
|
|
invocationSource: "on_demand",
|
|
triggerDetail: "manual",
|
|
startedAt: new Date("2026-04-09T09:30:00.000Z"),
|
|
finishedAt: new Date("2026-04-09T09:35:00.000Z"),
|
|
createdAt: new Date(`2026-04-09T09:${String(index % 60).padStart(2, "0")}:00.000Z`),
|
|
agentId: "agent-1",
|
|
agentName: "Builder",
|
|
adapterType: "codex_local",
|
|
logBytes: 0,
|
|
livenessState: "healthy",
|
|
livenessReason: null,
|
|
continuationAttempt: 0,
|
|
lastUsefulActionAt: null,
|
|
nextAction: null,
|
|
lastOutputAt: null,
|
|
lastOutputSeq: null,
|
|
lastOutputStream: null,
|
|
lastOutputBytes: 0,
|
|
processStartedAt: null,
|
|
issueId: "issue-1",
|
|
}));
|
|
|
|
let selectCallCount = 0;
|
|
const db = {
|
|
select: vi.fn().mockImplementation(() => {
|
|
selectCallCount += 1;
|
|
const rows = selectCallCount === 1 ? liveRows : recentRows;
|
|
const limitFn = vi.fn(async (value: number) => rows.slice(0, value));
|
|
const orderedQuery = {
|
|
limit: limitFn,
|
|
then: (resolve: (value: typeof rows) => unknown) =>
|
|
Promise.resolve(rows).then(resolve),
|
|
};
|
|
return {
|
|
from: vi.fn().mockReturnThis(),
|
|
innerJoin: vi.fn().mockReturnThis(),
|
|
where: vi.fn().mockReturnThis(),
|
|
orderBy: vi.fn().mockReturnValue(orderedQuery),
|
|
};
|
|
}),
|
|
};
|
|
|
|
const res = await requestApp(
|
|
await createApp(db),
|
|
(baseUrl) => request(baseUrl).get("/api/companies/company-1/live-runs?minCount=4"),
|
|
);
|
|
|
|
expect(res.status, JSON.stringify(res.body)).toBe(200);
|
|
expect(res.body).toHaveLength(4);
|
|
expect(db.select).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it("passes scoped wake fields through the legacy heartbeat invoke route", async () => {
|
|
const res = await requestApp(
|
|
await createApp(),
|
|
(baseUrl) => request(baseUrl)
|
|
.post(`/api/agents/${routeAgentId}/heartbeat/invoke?companyId=company-1`)
|
|
.send({
|
|
reason: "issue_assigned",
|
|
payload: {
|
|
issueId: "issue-1",
|
|
taskId: "issue-1",
|
|
taskKey: "issue-1",
|
|
},
|
|
forceFreshSession: true,
|
|
}),
|
|
);
|
|
|
|
expect(res.status, JSON.stringify(res.body)).toBe(202);
|
|
// The legacy /heartbeat/invoke endpoint forwards only the wake fields the
|
|
// caller actually supplied so empty-body callers (e.g. e2e suites) match
|
|
// the original fixed-arg `heartbeat.invoke()` shape exactly. When the
|
|
// caller supplies reason / payload / forceFreshSession those are
|
|
// forwarded; idempotencyKey is omitted unless explicitly set.
|
|
expect(mockHeartbeatService.wakeup).toHaveBeenCalledWith(routeAgentId, {
|
|
source: "on_demand",
|
|
triggerDetail: "manual",
|
|
reason: "issue_assigned",
|
|
payload: {
|
|
issueId: "issue-1",
|
|
taskId: "issue-1",
|
|
taskKey: "issue-1",
|
|
},
|
|
requestedByActorType: "user",
|
|
requestedByActorId: "local-board",
|
|
contextSnapshot: {
|
|
triggeredBy: "board",
|
|
actorId: "local-board",
|
|
forceFreshSession: true,
|
|
},
|
|
});
|
|
});
|
|
|
|
it("calls heartbeat.wakeup with the legacy minimal shape when the body is empty", async () => {
|
|
const res = await requestApp(
|
|
await createApp(),
|
|
(baseUrl) => request(baseUrl)
|
|
.post(`/api/agents/${routeAgentId}/heartbeat/invoke?companyId=company-1`)
|
|
.send({}),
|
|
);
|
|
|
|
expect(res.status, JSON.stringify(res.body)).toBe(202);
|
|
expect(mockHeartbeatService.wakeup).toHaveBeenCalledWith(routeAgentId, {
|
|
source: "on_demand",
|
|
triggerDetail: "manual",
|
|
requestedByActorType: "user",
|
|
requestedByActorId: "local-board",
|
|
contextSnapshot: {
|
|
triggeredBy: "board",
|
|
actorId: "local-board",
|
|
},
|
|
});
|
|
});
|
|
});
|