forked from farhoodlabs/paperclip
3cd26a78fc
## Thinking Path > - Paperclip orchestrates AI agents through issue comments and heartbeat runs > - The board UI needs to distinguish a comment that triggered a live run from comments queued after that run started > - The run payload already stores comment context, but active-run API responses did not expose the ids the UI needs > - Without those ids, the triggering comment can flash as queued while the agent is already responding to it > - This pull request exposes live-run comment context and teaches the optimistic comment helper to ignore the trigger comment > - The benefit is clearer issue-chat state during comment-triggered agent interruptions ## What Changed - Added `contextCommentId` and `contextWakeCommentId` to active/live run payloads. - Threaded those ids through server routes, heartbeat summaries, UI API types, and issue detail rendering. - Updated optimistic comment classification to avoid marking the triggering comment as queued. - Added server and UI regression coverage. ## Verification - `pnpm exec vitest run server/src/__tests__/agent-live-run-routes.test.ts ui/src/lib/optimistic-issue-comments.test.ts` ## Risks - Low-to-medium risk: adds optional fields to existing run payloads. Existing consumers should ignore unknown fields, and UI handling is null-safe. > 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 coding agent, tool use and local command execution. Exact context window was not exposed in the runtime. ## 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>
528 lines
17 KiB
TypeScript
528 lines
17 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(),
|
|
}));
|
|
|
|
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(),
|
|
}));
|
|
|
|
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,
|
|
});
|
|
});
|
|
|
|
it("returns a compact active run payload for issue polling", async () => {
|
|
const res = await requestApp(
|
|
await createApp(),
|
|
(baseUrl) => request(baseUrl).get("/api/issues/PAP-1295/active-run"),
|
|
);
|
|
|
|
expect(res.status, JSON.stringify(res.body)).toBe(200);
|
|
expect(mockIssueService.getByIdentifier).toHaveBeenCalledWith("PAP-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/PAP-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);
|
|
});
|
|
});
|