This commit is contained in:
Luke Grimstrup 2026-09-13 12:08:45 +00:00 committed by GitHub
commit e58621c8f9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 145 additions and 5 deletions

View File

@ -5,6 +5,7 @@ import { registerAgentCommands } from "../commands/client/agent.js";
const COMPANY_ID = "22222222-2222-4222-8222-222222222222";
const AGENT_ID = "11111111-1111-4111-8111-111111111111";
const REVISION_ID = "33333333-3333-4333-8333-333333333333";
const ISSUE_ID = "44444444-4444-4444-8444-444444444444";
function createProgram(): Command {
const program = new Command();
@ -25,6 +26,16 @@ async function run(args: string[]): Promise<void> {
], { from: "user" });
}
async function expectCommandFailure(args: string[], message: string): Promise<void> {
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
vi.spyOn(process, "exit").mockImplementation((code) => {
throw new Error(`process.exit:${code}`);
});
await expect(run(args)).rejects.toThrow("process.exit:1");
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining(message));
}
describe("agent lifecycle commands", () => {
beforeEach(() => {
vi.restoreAllMocks();
@ -52,7 +63,7 @@ describe("agent lifecycle commands", () => {
await run(["agent", "resume", AGENT_ID]);
await run(["agent", "approve", AGENT_ID]);
await run(["agent", "terminate", AGENT_ID]);
await run(["agent", "heartbeat:invoke", AGENT_ID]);
await run(["agent", "heartbeat:invoke", AGENT_ID, "--allow-unscoped"]);
await run(["agent", "claude-login", AGENT_ID]);
await run(["agent", "delete", AGENT_ID, "--yes"]);
@ -70,6 +81,66 @@ describe("agent lifecycle commands", () => {
]);
});
it("binds a manual heartbeat invoke to the assigned issue", async () => {
const fetchMock = vi.fn().mockImplementation(() => Promise.resolve(jsonResponse({
id: ISSUE_ID,
identifier: "PC-42",
assigneeAgentId: AGENT_ID,
})));
vi.stubGlobal("fetch", fetchMock);
await run(["agent", "heartbeat:invoke", AGENT_ID, "--issue-id", "PC-42"]);
expect(fetchMock.mock.calls.map((call) => [call[1]?.method ?? "GET", call[0]])).toEqual([
["GET", "http://localhost:3100/api/issues/PC-42"],
["POST", `http://localhost:3100/api/agents/${AGENT_ID}/heartbeat/invoke`],
]);
expect(JSON.parse(String(fetchMock.mock.calls[1]?.[1]?.body))).toEqual({
reason: "manual_issue_wake",
payload: {
issueId: ISSUE_ID,
taskId: ISSUE_ID,
taskKey: ISSUE_ID,
},
});
});
it.each([
{
args: ["agent", "heartbeat:invoke", AGENT_ID],
message: "Specify --issue-id for task work or --allow-unscoped for a generic heartbeat",
},
{
args: ["agent", "heartbeat:invoke", AGENT_ID, "--issue-id", "PC-42", "--allow-unscoped"],
message: "Use either --issue-id or --allow-unscoped, not both",
},
])("rejects an invalid heartbeat scope choice", async ({ args, message }) => {
const fetchMock = vi.fn();
vi.stubGlobal("fetch", fetchMock);
await expectCommandFailure(args, message);
expect(fetchMock).not.toHaveBeenCalled();
});
it("rejects an issue assigned to another agent without posting a heartbeat", async () => {
const fetchMock = vi.fn().mockImplementation(() => Promise.resolve(jsonResponse({
id: ISSUE_ID,
identifier: "PC-42",
assigneeAgentId: "55555555-5555-4555-8555-555555555555",
})));
vi.stubGlobal("fetch", fetchMock);
await expectCommandFailure(
["agent", "heartbeat:invoke", AGENT_ID, "--issue-id", "PC-42"],
`Issue PC-42 is not assigned to agent ${AGENT_ID}`,
);
expect(fetchMock.mock.calls.map((call) => [call[1]?.method ?? "GET", call[0]])).toEqual([
["GET", "http://localhost:3100/api/issues/PC-42"],
]);
});
it("wraps configuration, runtime, skills, and instructions endpoints", async () => {
const fetchMock = vi.fn().mockImplementation(() => Promise.resolve(jsonResponse()));
vi.stubGlobal("fetch", fetchMock);

View File

@ -56,6 +56,11 @@ interface AgentWakeOptions extends BaseClientOptions {
forceFreshSession?: boolean;
}
interface AgentHeartbeatInvokeOptions extends BaseClientOptions {
issueId?: string;
allowUnscoped?: boolean;
}
interface AgentJsonPayloadOptions extends BaseClientOptions {
companyId?: string;
payloadJson: string;
@ -420,7 +425,6 @@ export function registerAgentCommands(program: Command): void {
["resume", "resume", "Resume an agent"],
["approve", "approve", "Approve a pending agent"],
["terminate", "terminate", "Terminate an agent"],
["heartbeat:invoke", "heartbeat/invoke", "Invoke an agent heartbeat"],
["claude-login", "claude-login", "Trigger Claude login for an agent"],
] as const) {
addCommonClientOptions(
@ -440,6 +444,53 @@ export function registerAgentCommands(program: Command): void {
);
}
addCommonClientOptions(
agent
.command("heartbeat:invoke")
.description("Invoke an issue-scoped agent heartbeat")
.argument("<agentId>", "Agent ID")
.option("--issue-id <idOrIdentifier>", "Issue UUID or identifier to bind to the run")
.option("--allow-unscoped", "Explicitly allow a run with no issue binding")
.action(async (agentId: string, opts: AgentHeartbeatInvokeOptions) => {
try {
if (opts.issueId && opts.allowUnscoped) {
throw new Error("Use either --issue-id or --allow-unscoped, not both");
}
if (!opts.issueId && !opts.allowUnscoped) {
throw new Error("Specify --issue-id for task work or --allow-unscoped for a generic heartbeat");
}
const ctx = resolveCommandContext(opts);
let body: Record<string, unknown> = {};
if (opts.issueId) {
const issue = await ctx.api.get<Issue>(`/api/issues/${encodeURIComponent(opts.issueId)}`);
if (!issue) {
throw new Error(`Issue not found: ${opts.issueId}`);
}
if (issue.assigneeAgentId !== agentId) {
throw new Error(`Issue ${issue.identifier} is not assigned to agent ${agentId}`);
}
body = {
reason: "manual_issue_wake",
payload: {
issueId: issue.id,
taskId: issue.id,
taskKey: issue.id,
},
};
}
const result = await ctx.api.post<AgentWakeupResponse>(
`${apiPath`/api/agents/${agentId}`}/heartbeat/invoke`,
body,
);
printOutput(result, { json: ctx.json });
} catch (err) {
handleCommandError(err);
}
}),
);
addCommonClientOptions(
agent
.command("permissions:update")

View File

@ -541,7 +541,8 @@ npx paperclipai agent pause <agent-id>
npx paperclipai agent resume <agent-id>
npx paperclipai agent approve <agent-id>
npx paperclipai agent terminate <agent-id>
npx paperclipai agent heartbeat:invoke <agent-id>
npx paperclipai agent heartbeat:invoke <agent-id> --issue-id <issue-id-or-identifier>
npx paperclipai agent heartbeat:invoke <agent-id> --allow-unscoped
npx paperclipai agent claude-login <agent-id>
npx paperclipai agent local-cli <agent-id-or-shortname> --company-id <company-id>
```

View File

@ -6773,8 +6773,25 @@ registry.registerPath({
path: "/api/agents/{id}/heartbeat/invoke",
tags: ["agents"],
summary: "Invoke agent heartbeat",
request: { params: z.object({ id: z.string() }) },
responses: { 200: r.ok(), 401: r.unauthorized },
description:
"Legacy manual invoke endpoint. Bind task work by supplying payload.issueId, payload.taskId, and payload.taskKey with the same issue UUID. An omitted body creates an intentionally unscoped run.",
request: {
params: z.object({ id: z.string() }),
body: {
content: {
"application/json": { schema: wakeAgentSchema.omit({ failedRunId: true }) },
},
required: false,
},
},
responses: {
202: r.ok(),
400: r.badRequest,
401: r.unauthorized,
403: r.forbidden,
404: r.notFound,
409: r.conflict,
},
});
registry.registerPath({