diff --git a/server/src/__tests__/agent-live-run-routes.test.ts b/server/src/__tests__/agent-live-run-routes.test.ts index 470ef85d..0eeebe52 100644 --- a/server/src/__tests__/agent-live-run-routes.test.ts +++ b/server/src/__tests__/agent-live-run-routes.test.ts @@ -12,6 +12,7 @@ const mockHeartbeatService = vi.hoisted(() => ({ getActiveRunIssueSummaryForAgent: vi.fn(), getRunLogAccess: vi.fn(), readLog: vi.fn(), + wakeup: vi.fn(), })); const mockIssueService = vi.hoisted(() => ({ @@ -26,6 +27,8 @@ const mockInstanceSettingsService = vi.hoisted(() => ({ listCompanyIds: vi.fn(), })); +const routeAgentId = "11111111-1111-4111-8111-111111111111"; + function registerModuleMocks() { vi.doMock("../routes/authz.js", async () => vi.importActual("../routes/authz.js")); @@ -210,6 +213,14 @@ describe("agent live run routes", () => { 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 () => { @@ -524,4 +535,66 @@ describe("agent live run routes", () => { 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", + }, + }); + }); }); diff --git a/server/src/routes/agents.ts b/server/src/routes/agents.ts index e104d8b0..ce830140 100644 --- a/server/src/routes/agents.ts +++ b/server/src/routes/agents.ts @@ -2883,7 +2883,25 @@ export function agentRoutes( res.json({ ok: true }); }); - router.post("/agents/:id/wakeup", validate(wakeAgentSchema), async (req, res) => { + // Shared handler body for the wakeup-style endpoints. The two routes differ + // only in: + // - `source` — the modern /wakeup endpoint reads it from the request body + // (timer|assignment|on_demand|automation) while the legacy + // /heartbeat/invoke endpoint hardcodes "on_demand", since it has only + // ever produced on-demand invocations. + // - skipped-response shape — the modern endpoint surfaces the rich + // SkippedWakeupResponse; the legacy endpoint stays on the simpler + // { status: "skipped" } shape for backward compat. + type HeartbeatSource = "timer" | "assignment" | "on_demand" | "automation"; + type WakeupRouteOpts = { + source: HeartbeatSource | undefined; + skippedResponse: (agent: NonNullable>>) => unknown | Promise; + }; + const handleWakeupRoute = async ( + req: Request, + res: Response, + opts: WakeupRouteOpts, + ): Promise => { const id = req.params.id as string; const agent = await svc.getById(id); if (!agent) { @@ -2902,7 +2920,7 @@ export function agentRoutes( } const run = await heartbeat.wakeup(id, { - source: req.body.source, + source: opts.source, triggerDetail: req.body.triggerDetail ?? "manual", reason: req.body.reason ?? null, payload: req.body.payload ?? null, @@ -2917,7 +2935,7 @@ export function agentRoutes( }); if (!run) { - res.status(202).json(await buildSkippedWakeupResponse(agent, req.body.payload ?? null)); + res.status(202).json(await opts.skippedResponse(agent)); return; } @@ -2935,9 +2953,23 @@ export function agentRoutes( }); res.status(202).json(run); + }; + + router.post("/agents/:id/wakeup", validate(wakeAgentSchema), async (req, res) => { + await handleWakeupRoute(req, res, { + source: req.body.source, + skippedResponse: (agent) => buildSkippedWakeupResponse(agent, req.body.payload ?? null), + }); }); router.post("/agents/:id/heartbeat/invoke", async (req, res) => { + // Legacy endpoint. Hardcodes `source: "on_demand"` (the prior behavior + // before the wakeup/invoke convergence). Reads scope fields directly off + // the body without `validate(wakeAgentSchema)` because callers — including + // the e2e suite — post an empty body, and the schema rejects undefined + // / missing bodies. Only forwards fields the caller actually supplied so + // an empty body produces the original fixed-arg `heartbeat.invoke()` + // shape exactly. const id = req.params.id as string; const agent = await svc.getById(id); if (!agent) { @@ -2955,19 +2987,37 @@ export function agentRoutes( await assertBoardCanManageAgentsForCompany(req, agent.companyId); } - const run = await heartbeat.invoke( - id, - "on_demand", - { - triggeredBy: req.actor.type, - actorId: req.actor.type === "agent" ? req.actor.agentId : req.actor.userId, - }, - "manual", - { - actorType: req.actor.type === "agent" ? "agent" : "user", - actorId: req.actor.type === "agent" ? req.actor.agentId ?? null : req.actor.userId ?? null, - }, - ); + const body = (req.body ?? {}) as Partial<{ + reason: unknown; + payload: unknown; + idempotencyKey: unknown; + forceFreshSession: unknown; + triggerDetail: unknown; + }>; + const contextSnapshot: Record = { + triggeredBy: req.actor.type, + actorId: req.actor.type === "agent" ? req.actor.agentId : req.actor.userId, + }; + if (body.forceFreshSession === true) { + contextSnapshot.forceFreshSession = true; + } + const wakeOpts: Parameters[1] = { + source: "on_demand", + triggerDetail: typeof body.triggerDetail === "string" ? body.triggerDetail as "manual" | "system" | "ping" | "callback" : "manual", + requestedByActorType: req.actor.type === "agent" ? "agent" : "user", + requestedByActorId: req.actor.type === "agent" ? req.actor.agentId ?? null : req.actor.userId ?? null, + contextSnapshot, + }; + if (typeof body.reason === "string" && body.reason.length > 0) { + wakeOpts.reason = body.reason; + } + if (body.payload && typeof body.payload === "object" && !Array.isArray(body.payload)) { + wakeOpts.payload = body.payload as Record; + } + if (typeof body.idempotencyKey === "string" && body.idempotencyKey.length > 0) { + wakeOpts.idempotencyKey = body.idempotencyKey; + } + const run = await heartbeat.wakeup(id, wakeOpts); if (!run) { res.status(202).json({ status: "skipped" }); diff --git a/ui/src/api/agents.ts b/ui/src/api/agents.ts index f25aa5b3..6478ec75 100644 --- a/ui/src/api/agents.ts +++ b/ui/src/api/agents.ts @@ -69,6 +69,15 @@ export interface AgentPermissionUpdate { canAssignTasks: boolean; } +export interface AgentWakeRequest { + source?: "timer" | "assignment" | "on_demand" | "automation"; + triggerDetail?: "manual" | "ping" | "callback" | "system"; + reason?: string | null; + payload?: Record | null; + idempotencyKey?: string | null; + forceFreshSession?: boolean; +} + function withCompanyScope(path: string, companyId?: string) { if (!companyId) return path; const separator = path.includes("?") ? "&" : "?"; @@ -204,16 +213,11 @@ export const agentsApi = { `/companies/${companyId}/adapters/${type}/test-environment`, data, ), - invoke: (id: string, companyId?: string) => api.post(agentPath(id, companyId, "/heartbeat/invoke"), {}), + invoke: (id: string, companyId?: string, data: AgentWakeRequest = {}) => + api.post(agentPath(id, companyId, "/heartbeat/invoke"), data), wakeup: ( id: string, - data: { - source?: "timer" | "assignment" | "on_demand" | "automation"; - triggerDetail?: "manual" | "ping" | "callback" | "system"; - reason?: string | null; - payload?: Record | null; - idempotencyKey?: string | null; - }, + data: AgentWakeRequest, companyId?: string, ) => api.post(agentPath(id, companyId, "/wakeup"), data), loginWithClaude: (id: string, companyId?: string) =>