Merge 6aa7cee733 into c9e3bb7ca4
This commit is contained in:
commit
e58621c8f9
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
```
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
Loading…
Reference in New Issue