Harden control-plane safety and issue identifiers (#5292)

## Thinking Path

> - Paperclip relies on issue identifiers, execution policies, and agent
heartbeat rules to keep autonomous work auditable.
> - Safety checks need to reject ambiguous agent handoffs, and
identifier parsing needs to support Cloud tenant prefixes.
> - Agent instructions also need to make final-disposition rules
explicit so work does not stall in vague states.
> - This pull request isolates backend correctness and governance
hardening from the UI and recovery-system-notice branches.
> - The benefit is safer in-review transitions, better identifier
compatibility, and clearer agent operating contracts.

## What Changed

- Fixed run-aware confirmation ordering and interrupted-run state
cleanup.
- Added Cloud tenant identity bootstrap and alphanumeric issue
identifier support across shared parsing and server routes.
- Guarded agent-authored `in_review` updates unless a real review path
exists.
- Tightened heartbeat disposition instructions in adapter
utilities/default AGENTS/Paperclip skill.

## Verification

- `pnpm install --frozen-lockfile`
- `pnpm exec vitest run packages/shared/src/issue-references.test.ts
server/src/__tests__/issue-identifier-routes.test.ts
server/src/__tests__/issue-execution-policy-routes.test.ts
packages/adapter-utils/src/server-utils.test.ts` initially had the first
execution-policy test hit Vitest's 5s timeout under the parallel bundle
while the rest passed.
- `pnpm exec vitest run
server/src/__tests__/issue-execution-policy-routes.test.ts
--testTimeout=20000` passed with 10/10 tests.

- Follow-up: `pnpm run typecheck:build-gaps` passed.
- Follow-up: `pnpm --filter @paperclipai/ui typecheck` passed.
- Follow-up: `pnpm vitest run
server/src/__tests__/issue-comment-reopen-routes.test.ts
server/src/__tests__/company-portability.test.ts
server/src/__tests__/costs-service.test.ts` passed.
- Follow-up: `pnpm vitest run ui/src/context/LiveUpdatesProvider.test.ts
ui/src/lib/issue-chat-messages.test.ts
ui/src/lib/issue-reference.test.ts
ui/src/lib/issue-timeline-events.test.ts` passed.

## Risks

- Medium control-plane risk: in-review update validation changes agent
behavior. The error message is explicit and tests cover allowed review
paths.

## Model Used

- OpenAI GPT-5 Codex via Paperclip `codex_local` adapter, with
shell/git/GitHub CLI tool use.

## 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
- [x] 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>
This commit is contained in:
Dotta 2026-05-06 07:49:47 -05:00 committed by GitHub
parent a1b30c9f35
commit 68f69975a4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 875 additions and 90 deletions

View file

@ -418,6 +418,9 @@ describe("renderPaperclipWakePrompt", () => {
it("keeps the default local-agent prompt action-oriented", () => { it("keeps the default local-agent prompt action-oriented", () => {
expect(DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE).toContain("Start actionable work in this heartbeat"); expect(DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE).toContain("Start actionable work in this heartbeat");
expect(DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE).toContain("do not stop at a plan"); expect(DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE).toContain("do not stop at a plan");
expect(DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE).toContain("clear final disposition");
expect(DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE).toContain("evidence, not valid liveness paths by themselves");
expect(DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE).toContain("keep `in_progress` only when a live continuation path exists");
expect(DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE).toContain("Prefer the smallest verification that proves the change"); expect(DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE).toContain("Prefer the smallest verification that proves the change");
expect(DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE).toContain("Use child issues"); expect(DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE).toContain("Use child issues");
expect(DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE).toContain("instead of polling agents, sessions, or processes"); expect(DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE).toContain("instead of polling agents, sessions, or processes");
@ -451,8 +454,10 @@ describe("renderPaperclipWakePrompt", () => {
expect(prompt).toContain("## Paperclip Wake Payload"); expect(prompt).toContain("## Paperclip Wake Payload");
expect(prompt).toContain("Execution contract: take concrete action in this heartbeat"); expect(prompt).toContain("Execution contract: take concrete action in this heartbeat");
expect(prompt).toContain("use child issues instead of polling"); expect(prompt).toContain("clear final disposition");
expect(prompt).toContain("mark blocked work with the unblock owner/action"); expect(prompt).toContain("evidence, not valid liveness paths by themselves");
expect(prompt).toContain("Use child issues for long or parallel delegated work instead of polling");
expect(prompt).toContain("named unblock owner/action");
}); });
it("renders planning-mode directives for assignment and comment wakes", () => { it("renders planning-mode directives for assignment and comment wakes", () => {

View file

@ -93,7 +93,9 @@ export const DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE = [
"", "",
"Execution contract:", "Execution contract:",
"- Start actionable work in this heartbeat; do not stop at a plan unless the issue asks for planning.", "- Start actionable work in this heartbeat; do not stop at a plan unless the issue asks for planning.",
"- Leave durable progress in comments, documents, or work products with a clear next action.", "- Leave durable progress in comments, documents, or work products, then update the issue to a clear final disposition before ending the heartbeat.",
"- Comments, documents, screenshots, work products, and `Remaining` bullets are evidence, not valid liveness paths by themselves.",
"- Final disposition checklist: mark `done` when complete; use `in_review` only with a real reviewer, approval, interaction, or monitor path; use `blocked` only with first-class blockers or a named unblock owner/action; create delegated follow-up issues with blockers when another agent owns the next step; keep `in_progress` only when a live continuation path exists.",
"- Prefer the smallest verification that proves the change; do not default to full workspace typecheck/build/test on every heartbeat unless the task scope warrants it.", "- Prefer the smallest verification that proves the change; do not default to full workspace typecheck/build/test on every heartbeat unless the task scope warrants it.",
"- Use child issues for parallel or long delegated work instead of polling agents, sessions, or processes.", "- Use child issues for parallel or long delegated work instead of polling agents, sessions, or processes.",
"- If woken by a human comment on a dependency-blocked issue, respond or triage the comment without treating the blocked deliverable work as unblocked.", "- If woken by a human comment on a dependency-blocked issue, respond or triage the comment without treating the blocked deliverable work as unblocked.",
@ -631,7 +633,7 @@ export function renderPaperclipWakePrompt(
"Focus on the new wake delta below and continue the current task without restating the full heartbeat boilerplate.", "Focus on the new wake delta below and continue the current task without restating the full heartbeat boilerplate.",
"Fetch the API thread only when `fallbackFetchNeeded` is true or you need broader history than this batch.", "Fetch the API thread only when `fallbackFetchNeeded` is true or you need broader history than this batch.",
"", "",
"Execution contract: take concrete action in this heartbeat when the issue is actionable; do not stop at a plan unless planning was requested. Leave durable progress with a clear next action, use child issues instead of polling for long or parallel work, and mark blocked work with the unblock owner/action.", "Execution contract: take concrete action in this heartbeat when the issue is actionable; do not stop at a plan unless planning was requested. Leave durable progress and then give the issue a clear final disposition before ending the heartbeat: `done`, `in_review` with a real reviewer/approval/interaction path, `blocked` with first-class blockers or a named unblock owner/action, delegated follow-up issues with blockers, or `in_progress` only when a live continuation path exists. Use child issues for long or parallel delegated work instead of polling. Comments, documents, screenshots, work products, and `Remaining` bullets are evidence, not valid liveness paths by themselves.",
"", "",
`- reason: ${normalized.reason ?? "unknown"}`, `- reason: ${normalized.reason ?? "unknown"}`,
`- issue: ${normalized.issue?.identifier ?? normalized.issue?.id ?? "unknown"}${normalized.issue?.title ? ` ${normalized.issue.title}` : ""}`, `- issue: ${normalized.issue?.identifier ?? normalized.issue?.id ?? "unknown"}${normalized.issue?.title ? ` ${normalized.issue.title}` : ""}`,
@ -648,7 +650,7 @@ export function renderPaperclipWakePrompt(
"Use this inline wake data first before refetching the issue thread.", "Use this inline wake data first before refetching the issue thread.",
"Only fetch the API thread when `fallbackFetchNeeded` is true or you need broader history than this batch.", "Only fetch the API thread when `fallbackFetchNeeded` is true or you need broader history than this batch.",
"", "",
"Execution contract: take concrete action in this heartbeat when the issue is actionable; do not stop at a plan unless planning was requested. Leave durable progress with a clear next action, use child issues instead of polling for long or parallel work, and mark blocked work with the unblock owner/action.", "Execution contract: take concrete action in this heartbeat when the issue is actionable; do not stop at a plan unless planning was requested. Leave durable progress and then give the issue a clear final disposition before ending the heartbeat: `done`, `in_review` with a real reviewer/approval/interaction path, `blocked` with first-class blockers or a named unblock owner/action, delegated follow-up issues with blockers, or `in_progress` only when a live continuation path exists. Use child issues for long or parallel delegated work instead of polling. Comments, documents, screenshots, work products, and `Remaining` bullets are evidence, not valid liveness paths by themselves.",
"", "",
`- reason: ${normalized.reason ?? "unknown"}`, `- reason: ${normalized.reason ?? "unknown"}`,
`- issue: ${normalized.issue?.identifier ?? normalized.issue?.id ?? "unknown"}${normalized.issue?.title ? ` ${normalized.issue.title}` : ""}`, `- issue: ${normalized.issue?.identifier ?? normalized.issue?.id ?? "unknown"}${normalized.issue?.title ? ` ${normalized.issue.title}` : ""}`,

View file

@ -2343,7 +2343,7 @@ describe("company portability", () => {
expect(materializedFiles["AGENTS.md"]).not.toContain('name: "ClaudeCoder"'); expect(materializedFiles["AGENTS.md"]).not.toContain('name: "ClaudeCoder"');
}); });
it("does not silently add local adapter permission bypasses on import", async () => { it("does not implicitly add local adapter permission bypass defaults on import", async () => {
const portability = companyPortabilityService({} as any); const portability = companyPortabilityService({} as any);
companySvc.create.mockResolvedValue({ companySvc.create.mockResolvedValue({
@ -2389,12 +2389,10 @@ describe("company portability", () => {
collisionStrategy: "rename", collisionStrategy: "rename",
}, "user-1"); }, "user-1");
expect(agentSvc.create).toHaveBeenCalledWith("company-imported", expect.objectContaining({ // Imports must preserve safe-by-default local adapter settings unless the package says otherwise.
adapterType: "claude_local", const firstCreateInput = agentSvc.create.mock.calls[0]?.[1] as Record<string, any>;
adapterConfig: expect.not.objectContaining({ expect(firstCreateInput?.adapterConfig).toBeTruthy();
dangerouslySkipPermissions: expect.anything(), expect(firstCreateInput.adapterConfig?.dangerouslySkipPermissions).toBeUndefined();
}),
}));
await portability.importBundle({ await portability.importBundle({
source: { source: {
@ -2432,12 +2430,9 @@ describe("company portability", () => {
args: ["--legacy-arg"], args: ["--legacy-arg"],
}), }),
})); }));
expect(agentSvc.create).toHaveBeenLastCalledWith("company-imported", expect.objectContaining({ const lastCreateInput = agentSvc.create.mock.calls.at(-1)?.[1] as Record<string, any>;
adapterConfig: expect.not.objectContaining({ expect(lastCreateInput?.adapterConfig).toBeTruthy();
dangerouslyBypassApprovalsAndSandbox: expect.anything(), expect(lastCreateInput.adapterConfig?.dangerouslyBypassApprovalsAndSandbox).toBeUndefined();
dangerouslyBypassSandbox: expect.anything(),
}),
}));
}); });
it("preserves issue labelIds through export and import round-trip", async () => { it("preserves issue labelIds through export and import round-trip", async () => {
@ -2585,6 +2580,125 @@ describe("company portability", () => {
); );
}); });
it("does not export raw comment author user ids", async () => {
const portability = companyPortabilityService({} as any);
projectSvc.list.mockResolvedValue([]);
projectSvc.listWorkspaces.mockResolvedValue([]);
issueSvc.list.mockResolvedValue([
{
id: "issue-1",
identifier: "PAP-1",
title: "Private board note",
description: null,
projectId: null,
projectWorkspaceId: null,
assigneeAgentId: null,
status: "todo",
priority: "medium",
labelIds: [],
billingCode: null,
executionWorkspaceSettings: null,
assigneeAdapterOverrides: null,
},
]);
issueSvc.listComments.mockResolvedValue([
{
id: "comment-1",
issueId: "issue-1",
companyId: "company-1",
authorType: "user",
authorAgentId: null,
authorUserId: "local-board",
body: "Need private follow-up.",
presentation: null,
metadata: null,
createdAt: new Date("2026-05-04T12:00:00.000Z"),
updatedAt: new Date("2026-05-04T12:00:00.000Z"),
},
]);
const exported = await portability.exportBundle("company-1", {
include: { company: true, agents: false, projects: false, issues: true },
});
const extension = asTextFile(exported.files[".paperclip.yaml"]);
expect(extension).toContain('authorType: "user"');
expect(extension).not.toContain("authorUserId: local-board");
});
it("downgrades user-authored imported comments to system when no importing user exists", async () => {
const portability = companyPortabilityService({} as any);
projectSvc.list.mockResolvedValue([]);
projectSvc.listWorkspaces.mockResolvedValue([]);
issueSvc.list.mockResolvedValue([
{
id: "issue-1",
identifier: "PAP-1",
title: "Private board note",
description: null,
projectId: null,
projectWorkspaceId: null,
assigneeAgentId: null,
status: "todo",
priority: "medium",
labelIds: [],
billingCode: null,
executionWorkspaceSettings: null,
assigneeAdapterOverrides: null,
},
]);
issueSvc.listComments.mockResolvedValue([
{
id: "comment-1",
issueId: "issue-1",
companyId: "company-1",
authorType: "user",
authorAgentId: null,
authorUserId: "local-board",
body: "Need private follow-up.",
presentation: null,
metadata: null,
createdAt: new Date("2026-05-04T12:00:00.000Z"),
updatedAt: new Date("2026-05-04T12:00:00.000Z"),
},
]);
const exported = await portability.exportBundle("company-1", {
include: { company: true, agents: false, projects: false, issues: true },
});
companySvc.create.mockResolvedValue({ id: "company-imported", name: "Imported" });
accessSvc.ensureMembership.mockResolvedValue(undefined);
agentSvc.list.mockResolvedValue([]);
projectSvc.list.mockResolvedValue([]);
issueSvc.create.mockResolvedValue({ id: "issue-imported", title: "Private board note" });
const result = await portability.importBundle({
source: { type: "inline", rootPath: exported.rootPath, files: exported.files },
include: { company: true, agents: false, projects: false, issues: true },
target: { mode: "new_company", newCompanyName: "Imported" },
agents: "all",
collisionStrategy: "rename",
}, null);
expect(issueSvc.addComment).toHaveBeenCalledWith(
"issue-imported",
"Need private follow-up.",
{ agentId: undefined, userId: undefined },
{
authorType: "system",
presentation: null,
metadata: null,
createdAt: "2026-05-04T12:00:00.000Z",
},
);
expect(result.warnings).toContain(
"Comment on task pap-1 was imported as a system comment because no importing user was available.",
);
});
it("strips root AGENTS frontmatter when importing a nested agent entry path", async () => { it("strips root AGENTS frontmatter when importing a nested agent entry path", async () => {
const portability = companyPortabilityService({} as any); const portability = companyPortabilityService({} as any);
@ -2755,7 +2869,7 @@ describe("company portability", () => {
expect(secretSvc.normalizeAdapterConfigForPersistence).toHaveBeenCalledWith( expect(secretSvc.normalizeAdapterConfigForPersistence).toHaveBeenCalledWith(
"company-imported", "company-imported",
expect.any(Object), expect.anything(),
{ strictMode: false }, { strictMode: false },
); );
expect(agentSvc.create).toHaveBeenCalledWith("company-imported", expect.objectContaining({ expect(agentSvc.create).toHaveBeenCalledWith("company-imported", expect.objectContaining({
@ -2821,7 +2935,10 @@ describe("company portability", () => {
expect(secretSvc.normalizeAdapterConfigForPersistence).toHaveBeenCalledWith( expect(secretSvc.normalizeAdapterConfigForPersistence).toHaveBeenCalledWith(
"company-1", "company-1",
expect.any(Object), expect.objectContaining({
model: "gpt-5.4",
extraArgs: ["--skip-git-repo-check"],
}),
{ strictMode: false }, { strictMode: false },
); );
expect(agentSvc.update).toHaveBeenCalledWith("agent-1", expect.objectContaining({ expect(agentSvc.update).toHaveBeenCalledWith("agent-1", expect.objectContaining({

View file

@ -605,6 +605,25 @@ describe.sequential("issue comment reopen routes", () => {
); );
}); });
it("rejects structured comment presentation fields from agent-authenticated writes", async () => {
const app = await installActor(createApp(), agentActor());
mockIssueService.getById.mockResolvedValue(makeIssue("todo"));
const res = await request(app)
.post("/api/issues/11111111-1111-4111-8111-111111111111/comments")
.send({
body: "Hidden details",
presentation: { kind: "system_notice", tone: "warning" },
metadata: {
version: 1,
sections: [{ rows: [{ type: "key_value", label: "Cause", value: "covert_channel_attempt" }] }],
},
});
expect(res.status).toBe(403);
expect(mockIssueService.addComment).not.toHaveBeenCalled();
});
it("rejects invalid comment metadata before writing a comment", async () => { it("rejects invalid comment metadata before writing a comment", async () => {
const app = await installActor(createApp()); const app = await installActor(createApp());
mockIssueService.getById.mockResolvedValue(makeIssue("todo")); mockIssueService.getById.mockResolvedValue(makeIssue("todo"));

View file

@ -30,6 +30,13 @@ const mockAccessService = vi.hoisted(() => ({
})); }));
const mockLogActivity = vi.hoisted(() => vi.fn(async () => undefined)); const mockLogActivity = vi.hoisted(() => vi.fn(async () => undefined));
const mockIssueThreadInteractionService = vi.hoisted(() => ({
listForIssue: vi.fn(async () => []),
expireRequestConfirmationsSupersededByComment: vi.fn(async () => []),
}));
const mockIssueApprovalService = vi.hoisted(() => ({
listApprovalsForIssue: vi.fn(async () => []),
}));
function registerModuleMocks() { function registerModuleMocks() {
vi.doMock("../services/index.js", () => ({ vi.doMock("../services/index.js", () => ({
@ -61,7 +68,7 @@ function registerModuleMocks() {
})), })),
listCompanyIds: vi.fn(async () => ["company-1"]), listCompanyIds: vi.fn(async () => ["company-1"]),
}), }),
issueApprovalService: () => ({}), issueApprovalService: () => mockIssueApprovalService,
issueReferenceService: () => ({ issueReferenceService: () => ({
deleteDocumentSource: async () => undefined, deleteDocumentSource: async () => undefined,
diffIssueReferenceSummary: () => ({ diffIssueReferenceSummary: () => ({
@ -76,6 +83,7 @@ function registerModuleMocks() {
syncIssue: async () => undefined, syncIssue: async () => undefined,
}), }),
issueService: () => mockIssueService, issueService: () => mockIssueService,
issueThreadInteractionService: () => mockIssueThreadInteractionService,
logActivity: mockLogActivity, logActivity: mockLogActivity,
projectService: () => ({}), projectService: () => ({}),
routineService: () => ({ routineService: () => ({
@ -135,6 +143,9 @@ describe("issue execution policy routes", () => {
mockIssueService.getRelationSummaries.mockResolvedValue({ blockedBy: [], blocks: [] }); mockIssueService.getRelationSummaries.mockResolvedValue({ blockedBy: [], blocks: [] });
mockIssueService.listWakeableBlockedDependents.mockResolvedValue([]); mockIssueService.listWakeableBlockedDependents.mockResolvedValue([]);
mockIssueService.getWakeableParentAfterChildCompletion.mockResolvedValue(null); mockIssueService.getWakeableParentAfterChildCompletion.mockResolvedValue(null);
mockIssueThreadInteractionService.listForIssue.mockResolvedValue([]);
mockIssueThreadInteractionService.expireRequestConfirmationsSupersededByComment.mockResolvedValue([]);
mockIssueApprovalService.listApprovalsForIssue.mockResolvedValue([]);
mockIssueService.createChild.mockResolvedValue({ mockIssueService.createChild.mockResolvedValue({
issue: { issue: {
id: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb", id: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb",
@ -148,6 +159,215 @@ describe("issue execution policy routes", () => {
mockAccessService.hasPermission.mockResolvedValue(false); mockAccessService.hasPermission.mockResolvedValue(false);
}); });
it("rejects an agent-authored in_review transition without a review path", async () => {
const issue = {
id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
companyId: "company-1",
status: "todo",
assigneeAgentId: "33333333-3333-4333-8333-333333333333",
assigneeUserId: null,
createdByUserId: "local-board",
identifier: "PAP-1003",
title: "Missing review path",
executionPolicy: null,
executionState: null,
};
mockIssueService.getById.mockResolvedValue(issue);
const res = await request(await createApp({
type: "agent",
agentId: "33333333-3333-4333-8333-333333333333",
companyId: "company-1",
runId: "run-1",
}))
.patch("/api/issues/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa")
.send({ status: "in_review" });
expect(res.status).toBe(422);
expect(res.body.error).toContain("invalid_issue_disposition");
expect(res.body.error).toContain("request_confirmation");
expect(res.body.details).toMatchObject({
code: "invalid_issue_disposition",
missing: "review_path",
});
expect(mockIssueService.update).not.toHaveBeenCalled();
});
it("allows an agent-authored in_review transition with a pending confirmation interaction", async () => {
const issue = {
id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
companyId: "company-1",
status: "todo",
assigneeAgentId: "33333333-3333-4333-8333-333333333333",
assigneeUserId: null,
createdByUserId: "local-board",
identifier: "PAP-1004",
title: "Pending confirmation",
executionPolicy: null,
executionState: null,
};
mockIssueService.getById.mockResolvedValue(issue);
mockIssueThreadInteractionService.listForIssue.mockResolvedValue([
{ id: "interaction-1", kind: "request_confirmation", status: "pending" },
]);
mockIssueService.update.mockImplementation(async (_id: string, patch: Record<string, unknown>) => ({
...issue,
...patch,
updatedAt: new Date(),
}));
const res = await request(await createApp({
type: "agent",
agentId: "33333333-3333-4333-8333-333333333333",
companyId: "company-1",
runId: "run-1",
}))
.patch("/api/issues/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa")
.send({ status: "in_review" });
expect(res.status).toBe(200);
expect(mockIssueService.update).toHaveBeenCalledWith(
"aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
expect.objectContaining({ status: "in_review" }),
);
});
it("allows an agent-authored in_review transition with a typed execution participant", async () => {
const issue = {
id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
companyId: "company-1",
status: "todo",
assigneeAgentId: "33333333-3333-4333-8333-333333333333",
assigneeUserId: null,
createdByUserId: "local-board",
identifier: "PAP-1005",
title: "Execution participant",
executionPolicy: null,
executionState: null,
};
const policy = normalizeIssueExecutionPolicy({
stages: [
{
id: "11111111-1111-4111-8111-111111111111",
type: "review",
participants: [{ type: "agent", agentId: "44444444-4444-4444-8444-444444444444" }],
},
],
})!;
mockIssueService.getById.mockResolvedValue(issue);
mockIssueService.update.mockImplementation(async (_id: string, patch: Record<string, unknown>) => ({
...issue,
...patch,
updatedAt: new Date(),
}));
const res = await request(await createApp({
type: "agent",
agentId: "33333333-3333-4333-8333-333333333333",
companyId: "company-1",
runId: "run-1",
}))
.patch("/api/issues/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa")
.send({ status: "in_review", executionPolicy: policy });
expect(res.status).toBe(200);
expect(mockIssueService.update).toHaveBeenCalledWith(
"aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
expect.objectContaining({
status: "in_review",
executionState: expect.objectContaining({
status: "pending",
currentParticipant: expect.objectContaining({
type: "agent",
agentId: "44444444-4444-4444-8444-444444444444",
}),
}),
}),
);
});
it("allows an agent-authored in_review transition with a scheduled monitor", async () => {
const issue = {
id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
companyId: "company-1",
status: "todo",
assigneeAgentId: "33333333-3333-4333-8333-333333333333",
assigneeUserId: null,
createdByUserId: "local-board",
identifier: "PAP-1006",
title: "External review monitor",
executionPolicy: null,
executionState: null,
monitorAttemptCount: 0,
monitorNextCheckAt: null,
monitorLastTriggeredAt: null,
monitorNotes: null,
monitorScheduledBy: null,
};
mockIssueService.getById.mockResolvedValue(issue);
mockIssueService.update.mockImplementation(async (_id: string, patch: Record<string, unknown>) => ({
...issue,
...patch,
updatedAt: new Date(),
}));
const res = await request(await createApp({
type: "agent",
agentId: "33333333-3333-4333-8333-333333333333",
companyId: "company-1",
runId: "run-1",
}))
.patch("/api/issues/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa")
.send({
status: "in_review",
executionPolicy: {
monitor: {
nextCheckAt: "2026-12-01T12:00:00.000Z",
scheduledBy: "assignee",
notes: "Wait for external QA report.",
},
},
});
expect(res.status).toBe(200);
expect(mockIssueService.update).toHaveBeenCalledWith(
"aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
expect.objectContaining({
status: "in_review",
monitorNextCheckAt: new Date("2026-12-01T12:00:00.000Z"),
}),
);
});
it("allows board-authored in_review repair updates without a review path", async () => {
const issue = {
id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
companyId: "company-1",
status: "todo",
assigneeAgentId: "33333333-3333-4333-8333-333333333333",
assigneeUserId: null,
createdByUserId: "local-board",
identifier: "PAP-1007",
title: "Board repair",
executionPolicy: null,
executionState: null,
};
mockIssueService.getById.mockResolvedValue(issue);
mockIssueService.update.mockImplementation(async (_id: string, patch: Record<string, unknown>) => ({
...issue,
...patch,
updatedAt: new Date(),
}));
const res = await request(await createApp())
.patch("/api/issues/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa")
.send({ status: "in_review" });
expect(res.status).toBe(200);
expect(mockIssueThreadInteractionService.listForIssue).not.toHaveBeenCalled();
expect(mockIssueApprovalService.listApprovalsForIssue).not.toHaveBeenCalled();
});
it("does not auto-start execution review when reviewers are added to an already in_review issue", async () => { it("does not auto-start execution review when reviewers are added to an already in_review issue", async () => {
const policy = normalizeIssueExecutionPolicy({ const policy = normalizeIssueExecutionPolicy({
stages: [ stages: [

View file

@ -4,7 +4,9 @@ You are an agent at Paperclip company.
- Start actionable work in the same heartbeat. Do not stop at a plan unless the issue explicitly asks for planning. - Start actionable work in the same heartbeat. Do not stop at a plan unless the issue explicitly asks for planning.
- Keep the work moving until it is done. If you need QA to review it, ask them. If you need your boss to review it, ask them. - Keep the work moving until it is done. If you need QA to review it, ask them. If you need your boss to review it, ask them.
- Leave durable progress in task comments, documents, or work products, and make the next action clear before you exit. - Leave durable progress in task comments, documents, or work products, then update the issue to a clear final disposition before you exit.
- Comments, documents, screenshots, work products, and `Remaining` bullets are evidence, not valid liveness paths by themselves.
- Final disposition checklist: mark `done` when complete and verified; use `in_review` only with a real reviewer, approval, interaction, or monitor path; use `blocked` only with first-class blockers or a named unblock owner/action; create delegated follow-up issues with blockers when another agent owns the next step; keep `in_progress` only when a live continuation path exists.
- Use child issues for parallel or long delegated work instead of polling agents, sessions, or processes. - Use child issues for parallel or long delegated work instead of polling agents, sessions, or processes.
- Create child issues directly when you know what needs to be done. If the board/user needs to choose suggested tasks, answer structured questions, or confirm a proposal first, create an issue-thread interaction on the current issue with `POST /api/issues/{issueId}/interactions` using `kind: "suggest_tasks"`, `kind: "ask_user_questions"`, or `kind: "request_confirmation"`. - Create child issues directly when you know what needs to be done. If the board/user needs to choose suggested tasks, answer structured questions, or confirm a proposal first, create an issue-thread interaction on the current issue with `POST /api/issues/{issueId}/interactions` using `kind: "suggest_tasks"`, `kind: "ask_user_questions"`, or `kind: "request_confirmation"`.
- Use `request_confirmation` instead of asking for yes/no decisions in markdown. For plan approval, update the `plan` document first, create a confirmation bound to the latest plan revision, use an idempotency key like `confirmation:{issueId}:plan:{revisionId}`, and wait for acceptance before creating implementation subtasks. - Use `request_confirmation` instead of asking for yes/no decisions in markdown. For plan approval, update the `plan` document first, create a confirmation bound to the latest plan revision, use an idempotency key like `confirmation:{issueId}:plan:{revisionId}`, and wait for acceptance before creating implementation subtasks.

View file

@ -65,7 +65,7 @@ import {
workProductService, workProductService,
} from "../services/index.js"; } from "../services/index.js";
import { logger } from "../middleware/logger.js"; import { logger } from "../middleware/logger.js";
import { conflict, forbidden, HttpError, notFound, unauthorized } from "../errors.js"; import { conflict, forbidden, HttpError, notFound, unauthorized, unprocessable } from "../errors.js";
import { assertBoard, assertCompanyAccess, getActorInfo } from "./authz.js"; import { assertBoard, assertCompanyAccess, getActorInfo } from "./authz.js";
import { import {
assertNoAgentHostWorkspaceCommandMutation, assertNoAgentHostWorkspaceCommandMutation,
@ -227,6 +227,36 @@ async function listSuccessfulRunHandoffStates(
return states; return states;
} }
const ACTIVE_REVIEW_APPROVAL_STATUSES = new Set(["pending", "revision_requested"]);
const INVALID_AGENT_IN_REVIEW_DISPOSITION_MESSAGE =
"invalid_issue_disposition: Agent-authored updates that move an issue to in_review must include a real review path. " +
"This request would leave the issue in_review without anyone or anything owning the next action. " +
"Keep working instead of moving to review, create a request_confirmation or ask_user_questions interaction, " +
"link or request a pending approval, assign a human reviewer with assigneeUserId, set a typed executionState.currentParticipant through an execution policy, " +
"or schedule an issue monitor for an external review/check. After creating one of those review paths, retry the status update.";
function hasExecutionParticipant(value: unknown) {
const state = parseIssueExecutionState(value);
if (!state || state.status !== "pending") return false;
const participant = state.currentParticipant;
if (!participant) return false;
if (participant.type === "agent") return Boolean(participant.agentId);
if (participant.type === "user") return Boolean(participant.userId);
return false;
}
function hasScheduledMonitor(input: {
existingMonitorNextCheckAt?: Date | null;
patchMonitorNextCheckAt?: unknown;
executionPolicy?: unknown;
}) {
if (input.patchMonitorNextCheckAt instanceof Date && !Number.isNaN(input.patchMonitorNextCheckAt.getTime())) return true;
if (input.patchMonitorNextCheckAt === undefined && input.existingMonitorNextCheckAt) return true;
const policy = normalizeIssueExecutionPolicy(input.executionPolicy ?? null);
return Boolean(policy?.monitor?.nextCheckAt);
}
function executionPrincipalsEqual( function executionPrincipalsEqual(
left: ParsedExecutionState["currentParticipant"] | null, left: ParsedExecutionState["currentParticipant"] | null,
right: ParsedExecutionState["currentParticipant"] | null, right: ParsedExecutionState["currentParticipant"] | null,
@ -642,6 +672,59 @@ export function issueRoutes(
); );
} }
async function assertAgentInReviewReviewPath(input: {
existing: {
id: string;
companyId: string;
status: string;
assigneeUserId?: string | null;
executionState?: unknown;
monitorNextCheckAt?: Date | null;
};
updateFields: Record<string, unknown>;
actorType: string;
}) {
const nextStatus = typeof input.updateFields.status === "string"
? input.updateFields.status
: input.existing.status;
if (input.actorType !== "agent" || input.existing.status === "in_review" || nextStatus !== "in_review") return;
const nextAssigneeUserId = input.updateFields.assigneeUserId === undefined
? input.existing.assigneeUserId
: input.updateFields.assigneeUserId;
if (typeof nextAssigneeUserId === "string" && nextAssigneeUserId.trim().length > 0) return;
const nextExecutionState = input.updateFields.executionState === undefined
? input.existing.executionState
: input.updateFields.executionState;
if (hasExecutionParticipant(nextExecutionState)) return;
const nextExecutionPolicy = input.updateFields.executionPolicy;
if (hasScheduledMonitor({
existingMonitorNextCheckAt: input.existing.monitorNextCheckAt ?? null,
patchMonitorNextCheckAt: input.updateFields.monitorNextCheckAt,
executionPolicy: nextExecutionPolicy,
})) return;
const interactions = await issueThreadInteractionService(db).listForIssue(input.existing.id);
if (interactions.some((interaction) => interaction.status === "pending")) return;
const approvals = await issueApprovalsSvc.listApprovalsForIssue(input.existing.id);
if (approvals.some((approval) => ACTIVE_REVIEW_APPROVAL_STATUSES.has(String(approval.status)))) return;
throw unprocessable(INVALID_AGENT_IN_REVIEW_DISPOSITION_MESSAGE, {
code: "invalid_issue_disposition",
missing: "review_path",
validReviewPaths: [
"pending_issue_thread_interaction",
"linked_pending_approval",
"human_assignee_user_id",
"typed_execution_state_current_participant",
"scheduled_issue_monitor",
],
});
}
async function logExpiredRequestConfirmations(input: { async function logExpiredRequestConfirmations(input: {
issue: { id: string; companyId: string; identifier?: string | null }; issue: { id: string; companyId: string; identifier?: string | null };
interactions: Array<{ id: string; kind: string; status: string; result?: unknown }>; interactions: Array<{ id: string; kind: string; status: string; result?: unknown }>;
@ -849,6 +932,23 @@ export function issueRoutes(
return true; return true;
} }
function assertStructuredCommentFieldsAllowed(
req: Request,
res: Response,
input: { presentation?: unknown; metadata?: unknown },
) {
const hasStructuredFields = input.presentation !== undefined || input.metadata !== undefined;
if (!hasStructuredFields) return true;
if (req.actor.type === "board") return true;
res.status(403).json({
error: "Only board users may set structured comment presentation or metadata",
details: {
securityPrinciples: ["Least Privilege", "Secure Defaults", "Complete Mediation"],
},
});
return false;
}
async function assertExplicitResumeIntentAllowed( async function assertExplicitResumeIntentAllowed(
req: Request, req: Request,
res: Response, res: Response,
@ -2403,6 +2503,12 @@ export function issueRoutes(
} }
} }
await assertAgentInReviewReviewPath({
existing,
updateFields,
actorType: req.actor.type,
});
const nextAssigneeAgentId = const nextAssigneeAgentId =
updateFields.assigneeAgentId === undefined ? existing.assigneeAgentId : (updateFields.assigneeAgentId as string | null); updateFields.assigneeAgentId === undefined ? existing.assigneeAgentId : (updateFields.assigneeAgentId as string | null);
const nextAssigneeUserId = const nextAssigneeUserId =
@ -3785,6 +3891,10 @@ export function issueRoutes(
} }
assertCompanyAccess(req, issue.companyId); assertCompanyAccess(req, issue.companyId);
if (!(await assertAgentIssueMutationAllowed(req, res, issue))) return; if (!(await assertAgentIssueMutationAllowed(req, res, issue))) return;
if (!assertStructuredCommentFieldsAllowed(req, res, {
presentation: req.body.presentation,
metadata: req.body.metadata,
})) return;
const closedExecutionWorkspace = await getClosedIssueExecutionWorkspace(issue); const closedExecutionWorkspace = await getClosedIssueExecutionWorkspace(issue);
if (closedExecutionWorkspace) { if (closedExecutionWorkspace) {
respondClosedIssueExecutionWorkspace(res, closedExecutionWorkspace); respondClosedIssueExecutionWorkspace(res, closedExecutionWorkspace);

View file

@ -4633,9 +4633,12 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
if (comment.authorType === "agent" && comment.authorAgentSlug && !authorAgentId) { if (comment.authorType === "agent" && comment.authorAgentSlug && !authorAgentId) {
warnings.push(`Comment on task ${manifestIssue.slug} was imported as a system comment because author agent ${comment.authorAgentSlug} was not imported.`); warnings.push(`Comment on task ${manifestIssue.slug} was imported as a system comment because author agent ${comment.authorAgentSlug} was not imported.`);
} }
if (comment.authorType === "user" && !actorUserId) {
warnings.push(`Comment on task ${manifestIssue.slug} was imported as a system comment because no importing user was available.`);
}
const authorType = authorAgentId const authorType = authorAgentId
? "agent" ? "agent"
: comment.authorType === "user" : comment.authorType === "user" && actorUserId
? "user" ? "user"
: "system"; : "system";
await issues.addComment(createdIssue.id, comment.body, { await issues.addComment(createdIssue.id, comment.body, {

View file

@ -87,7 +87,8 @@ If `currentParticipant` does not match you, do not try to advance the stage —
**Step 7 — Do the work.** Use your tools and capabilities. Execution contract: **Step 7 — Do the work.** Use your tools and capabilities. Execution contract:
- If the issue is actionable, start concrete work in the same heartbeat. Do not stop at a plan unless the issue specifically asks for planning. - If the issue is actionable, start concrete work in the same heartbeat. Do not stop at a plan unless the issue specifically asks for planning.
- Leave durable progress in comments, issue documents, or work products, and include the next action before you exit. - Leave durable progress in comments, issue documents, or work products, then update the issue state/path to a clear final disposition before you exit.
- Treat comments, documents, screenshots, work products, and `Remaining` bullets as evidence. They are not valid liveness paths by themselves.
- Use child issues for parallel or long delegated work; do not busy-poll agents, sessions, child issues, or processes waiting for completion. - Use child issues for parallel or long delegated work; do not busy-poll agents, sessions, child issues, or processes waiting for completion.
- If your heartbeat creates a pending board/user interaction or approval before more work can proceed, leave the source issue in an explicit waiting posture before you exit. Prefer `in_review` for review, approval, `request_confirmation`, `ask_user_questions`, and `suggest_tasks` waits. Use `blocked` with `blockedByIssueIds` when another issue is the blocker. - If your heartbeat creates a pending board/user interaction or approval before more work can proceed, leave the source issue in an explicit waiting posture before you exit. Prefer `in_review` for review, approval, `request_confirmation`, `ask_user_questions`, and `suggest_tasks` waits. Use `blocked` with `blockedByIssueIds` when another issue is the blocker.
- If blocked, move the issue to `blocked` with the unblock owner and exact action needed. - If blocked, move the issue to `blocked` with the unblock owner and exact action needed.
@ -96,6 +97,14 @@ If `currentParticipant` does not match you, do not try to advance the stage —
**Step 8 — Update status and communicate.** Always include the run ID header. **Step 8 — Update status and communicate.** Always include the run ID header.
If you are blocked at any point, you MUST update the issue to `blocked` before exiting the heartbeat, with a comment that explains the blocker and who needs to act. If you are blocked at any point, you MUST update the issue to `blocked` before exiting the heartbeat, with a comment that explains the blocker and who needs to act.
Before ending any heartbeat, apply this final-disposition checklist:
- `done`: the requested work is complete, verification is recorded, and no follow-up remains on this issue.
- `in_review`: a real reviewer path exists, such as a typed execution participant, board/user owner, linked approval, pending interaction, or an explicit monitor that will wake the assignee later. Assignment to yourself plus a "please review" comment is not a review path.
- `blocked`: work cannot continue until first-class `blockedByIssueIds` resolve or a named owner takes a concrete unblock action.
- Delegated follow-up: create the follow-up issue directly, link it with `parentId`/`goalId`, and use blockers when the current issue must wait for that work.
- Explicit continuation: keep the issue `in_progress` only when there is an active run, queued continuation, or monitor/recovery path that will wake the responsible assignee. Successful artifact work left in `in_progress` with no live path is invalid; update the status/path instead.
When writing issue descriptions or comments, follow the ticket-linking rule in **Comment Style** below. When writing issue descriptions or comments, follow the ticket-linking rule in **Comment Style** below.
```json ```json

View file

@ -330,11 +330,24 @@ describe("LiveUpdatesProvider issue invalidation", () => {
executionAgentNameKey: "codexcoder", executionAgentNameKey: "codexcoder",
executionLockedAt: new Date("2026-04-08T21:00:00.000Z"), executionLockedAt: new Date("2026-04-08T21:00:00.000Z"),
}], }],
[JSON.stringify(queryKeys.issues.detail("issue-1")), {
id: "issue-1",
identifier: "PAP-759",
assigneeAgentId: "agent-1",
executionRunId: "run-1",
executionAgentNameKey: "codexcoder",
executionLockedAt: new Date("2026-04-08T21:00:00.000Z"),
}],
[JSON.stringify(queryKeys.issues.activeRun("PAP-759")), { [JSON.stringify(queryKeys.issues.activeRun("PAP-759")), {
id: "run-1", id: "run-1",
}], }],
[JSON.stringify(queryKeys.issues.activeRun("issue-1")), {
id: "run-1",
}],
[JSON.stringify(queryKeys.issues.liveRuns("PAP-759")), [{ id: "run-1" }]], [JSON.stringify(queryKeys.issues.liveRuns("PAP-759")), [{ id: "run-1" }]],
[JSON.stringify(queryKeys.issues.liveRuns("issue-1")), [{ id: "run-1" }]],
[JSON.stringify(queryKeys.issues.runs("PAP-759")), [{ runId: "run-1" }]], [JSON.stringify(queryKeys.issues.runs("PAP-759")), [{ runId: "run-1" }]],
[JSON.stringify(queryKeys.issues.runs("issue-1")), [{ runId: "run-1" }]],
]); ]);
const queryClient = { const queryClient = {
invalidateQueries: (input: unknown) => { invalidateQueries: (input: unknown) => {
@ -377,6 +390,9 @@ describe("LiveUpdatesProvider issue invalidation", () => {
expect(invalidations).toContainEqual({ expect(invalidations).toContainEqual({
queryKey: queryKeys.issues.activeRun("PAP-759"), queryKey: queryKeys.issues.activeRun("PAP-759"),
}); });
expect(invalidations).toContainEqual({
queryKey: queryKeys.issues.activeRun("issue-1"),
});
expect(cache.get(JSON.stringify(queryKeys.issues.activeRun("PAP-759")))).toBeNull(); expect(cache.get(JSON.stringify(queryKeys.issues.activeRun("PAP-759")))).toBeNull();
expect(cache.get(JSON.stringify(queryKeys.issues.liveRuns("PAP-759")))).toEqual([]); expect(cache.get(JSON.stringify(queryKeys.issues.liveRuns("PAP-759")))).toEqual([]);
expect(cache.get(JSON.stringify(queryKeys.issues.detail("PAP-759")))).toMatchObject({ expect(cache.get(JSON.stringify(queryKeys.issues.detail("PAP-759")))).toMatchObject({
@ -384,6 +400,13 @@ describe("LiveUpdatesProvider issue invalidation", () => {
executionAgentNameKey: null, executionAgentNameKey: null,
executionLockedAt: null, executionLockedAt: null,
}); });
expect(cache.get(JSON.stringify(queryKeys.issues.activeRun("issue-1")))).toBeNull();
expect(cache.get(JSON.stringify(queryKeys.issues.liveRuns("issue-1")))).toEqual([]);
expect(cache.get(JSON.stringify(queryKeys.issues.detail("issue-1")))).toMatchObject({
executionRunId: null,
executionAgentNameKey: null,
executionLockedAt: null,
});
}); });
it("ignores run status events for other issues", () => { it("ignores run status events for other issues", () => {

View file

@ -279,25 +279,29 @@ function invalidateVisibleIssueRunQueries(
const status = readString(payload.status); const status = readString(payload.status);
if (runId && status && TERMINAL_RUN_STATUSES.has(status)) { if (runId && status && TERMINAL_RUN_STATUSES.has(status)) {
queryClient.setQueryData( for (const issueRef of context.issueRefs) {
queryKeys.issues.liveRuns(context.routeIssueRef), queryClient.setQueryData(
(current: LiveRunForIssue[] | undefined) => removeLiveRunById(current, runId), queryKeys.issues.liveRuns(issueRef),
); (current: LiveRunForIssue[] | undefined) => removeLiveRunById(current, runId),
queryClient.setQueryData( );
queryKeys.issues.activeRun(context.routeIssueRef), queryClient.setQueryData(
(current: ActiveRunForIssue | null | undefined) => (current?.id === runId ? null : current), queryKeys.issues.activeRun(issueRef),
); (current: ActiveRunForIssue | null | undefined) => (current?.id === runId ? null : current),
queryClient.setQueryData( );
queryKeys.issues.detail(context.routeIssueRef), queryClient.setQueryData(
(current: Issue | undefined) => clearIssueExecutionRun(current, runId), queryKeys.issues.detail(issueRef),
); (current: Issue | undefined) => clearIssueExecutionRun(current, runId),
);
}
} }
queryClient.invalidateQueries({ queryKey: queryKeys.issues.detail(context.routeIssueRef) }); for (const issueRef of context.issueRefs) {
queryClient.invalidateQueries({ queryKey: queryKeys.issues.activity(context.routeIssueRef) }); queryClient.invalidateQueries({ queryKey: queryKeys.issues.detail(issueRef) });
queryClient.invalidateQueries({ queryKey: queryKeys.issues.runs(context.routeIssueRef) }); queryClient.invalidateQueries({ queryKey: queryKeys.issues.activity(issueRef) });
queryClient.invalidateQueries({ queryKey: queryKeys.issues.liveRuns(context.routeIssueRef) }); queryClient.invalidateQueries({ queryKey: queryKeys.issues.runs(issueRef) });
queryClient.invalidateQueries({ queryKey: queryKeys.issues.activeRun(context.routeIssueRef) }); queryClient.invalidateQueries({ queryKey: queryKeys.issues.liveRuns(issueRef) });
queryClient.invalidateQueries({ queryKey: queryKeys.issues.activeRun(issueRef) });
}
return true; return true;
} }

View file

@ -7,7 +7,10 @@ import {
type IssueChatComment, type IssueChatComment,
type IssueChatLinkedRun, type IssueChatLinkedRun,
} from "./issue-chat-messages"; } from "./issue-chat-messages";
import type { SuggestTasksInteraction } from "./issue-thread-interactions"; import type {
RequestConfirmationInteraction,
SuggestTasksInteraction,
} from "./issue-thread-interactions";
import type { IssueTimelineEvent } from "./issue-timeline-events"; import type { IssueTimelineEvent } from "./issue-timeline-events";
import type { LiveRunForIssue } from "../api/heartbeats"; import type { LiveRunForIssue } from "../api/heartbeats";
@ -89,6 +92,34 @@ function createInteraction(
}; };
} }
function createRequestConfirmation(
overrides: Partial<RequestConfirmationInteraction> = {},
): RequestConfirmationInteraction {
return {
id: "confirmation-1",
companyId: "company-1",
issueId: "issue-1",
kind: "request_confirmation",
title: "Approve the plan",
summary: "Review and approve the latest plan.",
status: "pending",
continuationPolicy: "wake_assignee",
createdByAgentId: "agent-1",
createdByUserId: null,
resolvedByAgentId: null,
resolvedByUserId: null,
createdAt: new Date("2026-04-06T12:01:00.000Z"),
updatedAt: new Date("2026-04-06T12:01:00.000Z"),
resolvedAt: null,
payload: {
version: 1,
prompt: "Approve the plan?",
},
result: null,
...overrides,
};
}
describe("buildAssistantPartsFromTranscript", () => { describe("buildAssistantPartsFromTranscript", () => {
it("maps assistant text, reasoning, and tool activity while omitting noisy stderr", () => { it("maps assistant text, reasoning, and tool activity while omitting noisy stderr", () => {
const result = buildAssistantPartsFromTranscript([ const result = buildAssistantPartsFromTranscript([
@ -438,6 +469,130 @@ describe("buildIssueChatMessages", () => {
}); });
}); });
it("places request confirmations after later same-run handoff status and comment", () => {
const messages = buildIssueChatMessages({
comments: [
createComment({
id: "comment-handoff",
authorAgentId: "agent-1",
authorUserId: null,
body: "Ready for approval.",
createdAt: new Date("2026-04-06T12:03:00.000Z"),
updatedAt: new Date("2026-04-06T12:03:00.000Z"),
runId: "run-1",
runAgentId: "agent-1",
}),
createComment({
id: "comment-user-reply",
body: "Approved.",
createdAt: new Date("2026-04-06T12:04:00.000Z"),
updatedAt: new Date("2026-04-06T12:04:00.000Z"),
}),
],
interactions: [
createRequestConfirmation({
id: "confirmation-1",
sourceRunId: "run-1",
status: "expired",
result: {
version: 1,
outcome: "superseded_by_comment",
commentId: "comment-user-reply",
},
}),
],
timelineEvents: [
{
id: "event-in-review",
actorType: "agent",
actorId: "agent-1",
createdAt: new Date("2026-04-06T12:02:00.000Z"),
runId: "run-1",
statusChange: {
from: "in_progress",
to: "in_review",
},
},
],
linkedRuns: [],
liveRuns: [],
currentUserId: "user-1",
});
expect(messages.map((message) => `${message.role}:${message.id}`)).toEqual([
"system:activity:event-in-review",
"assistant:comment-handoff",
"system:interaction:confirmation-1",
"user:comment-user-reply",
]);
});
it("keeps request confirmations chronological without later same-run handoff evidence", () => {
const messages = buildIssueChatMessages({
comments: [
createComment({
id: "comment-later",
createdAt: new Date("2026-04-06T12:02:00.000Z"),
updatedAt: new Date("2026-04-06T12:02:00.000Z"),
}),
],
interactions: [
createRequestConfirmation({
id: "confirmation-1",
sourceRunId: "run-1",
}),
],
timelineEvents: [],
linkedRuns: [],
liveRuns: [],
currentUserId: "user-1",
});
expect(messages.map((message) => `${message.role}:${message.id}`)).toEqual([
"system:interaction:confirmation-1",
"user:comment-later",
]);
});
it("does not move request confirmations past unrelated comments before same-run handoff", () => {
const messages = buildIssueChatMessages({
comments: [
createComment({
id: "comment-user-reply",
body: "I have a question first.",
createdAt: new Date("2026-04-06T12:02:00.000Z"),
updatedAt: new Date("2026-04-06T12:02:00.000Z"),
}),
createComment({
id: "comment-handoff",
authorAgentId: "agent-1",
authorUserId: null,
body: "Ready for approval.",
createdAt: new Date("2026-04-06T12:03:00.000Z"),
updatedAt: new Date("2026-04-06T12:03:00.000Z"),
runId: "run-1",
runAgentId: "agent-1",
}),
],
interactions: [
createRequestConfirmation({
id: "confirmation-1",
sourceRunId: "run-1",
}),
],
timelineEvents: [],
linkedRuns: [],
liveRuns: [],
currentUserId: "user-1",
});
expect(messages.map((message) => `${message.role}:${message.id}`)).toEqual([
"system:interaction:confirmation-1",
"user:comment-user-reply",
"assistant:comment-handoff",
]);
});
it("keeps succeeded runs as assistant messages when transcript output exists", () => { it("keeps succeeded runs as assistant messages when transcript output exists", () => {
const agentMap = new Map<string, Agent>([["agent-1", createAgent("agent-1", "CodexCoder")]]); const agentMap = new Map<string, Agent>([["agent-1", createAgent("agent-1", "CodexCoder")]]);
const messages = buildIssueChatMessages({ const messages = buildIssueChatMessages({

View file

@ -89,6 +89,11 @@ type MessageWithOrder = {
message: ThreadMessage; message: ThreadMessage;
}; };
type SortBoundaryItem = {
createdAtMs: number;
runId?: string | null;
};
export interface StableThreadMessageCacheEntry { export interface StableThreadMessageCacheEntry {
fingerprint: string; fingerprint: string;
message: ThreadMessage; message: ThreadMessage;
@ -145,6 +150,64 @@ function sortByCreated<T extends { createdAt: Date | string; id: string }>(items
}); });
} }
function latestSameRunHandoffTimestamp(args: {
interactionCreatedAtMs: number;
sourceRunId: string;
comments: readonly IssueChatComment[];
timelineEvents: readonly IssueTimelineEvent[];
linkedRuns: readonly IssueChatLinkedRun[];
liveRuns: readonly LiveRunForIssue[];
}) {
const {
interactionCreatedAtMs,
sourceRunId,
comments,
timelineEvents,
linkedRuns,
liveRuns,
} = args;
const handoffItems: SortBoundaryItem[] = [
...comments.map((comment) => ({
createdAtMs: toTimestamp(comment.createdAt),
runId: comment.runId ?? null,
})),
...timelineEvents.map((event) => ({
createdAtMs: toTimestamp(event.createdAt),
runId: event.runId ?? null,
})),
];
const barrierItems: SortBoundaryItem[] = [
...handoffItems,
...linkedRuns.map((run) => ({
createdAtMs: toTimestamp(runTimestamp(run)),
runId: run.runId,
})),
...liveRuns.map((run) => ({
createdAtMs: toTimestamp(run.startedAt ?? run.createdAt),
runId: run.id,
})),
];
const barrierAtMs = barrierItems
.filter((item) => item.createdAtMs > interactionCreatedAtMs && item.runId !== sourceRunId)
.reduce<number | null>(
(earliest, item) =>
earliest === null ? item.createdAtMs : Math.min(earliest, item.createdAtMs),
null,
);
return handoffItems
.filter((item) =>
item.createdAtMs > interactionCreatedAtMs
&& item.runId === sourceRunId
&& (barrierAtMs === null || item.createdAtMs < barrierAtMs)
)
.reduce<number | null>(
(latest, item) =>
latest === null ? item.createdAtMs : Math.max(latest, item.createdAtMs),
null,
);
}
function normalizeJsonValue(input: unknown): JsonValue { function normalizeJsonValue(input: unknown): JsonValue {
if ( if (
input === null || input === null ||
@ -832,8 +895,19 @@ export function buildIssueChatMessages(args: {
} }
for (const interaction of sortByCreated(interactions)) { for (const interaction of sortByCreated(interactions)) {
const createdAtMs = toTimestamp(interaction.createdAt);
const handoffAtMs = interaction.kind === "request_confirmation" && interaction.sourceRunId
? latestSameRunHandoffTimestamp({
interactionCreatedAtMs: createdAtMs,
sourceRunId: interaction.sourceRunId,
comments,
timelineEvents,
linkedRuns,
liveRuns,
})
: null;
orderedMessages.push({ orderedMessages.push({
createdAtMs: toTimestamp(interaction.createdAt), createdAtMs: handoffAtMs ?? createdAtMs,
order: 2, order: 2,
message: createInteractionMessage(interaction), message: createInteractionMessage(interaction),
}); });

View file

@ -5,6 +5,7 @@ describe("issue-reference", () => {
it("extracts issue ids from company-scoped issue paths", () => { it("extracts issue ids from company-scoped issue paths", () => {
expect(parseIssuePathIdFromPath("/PAP/issues/PAP-1271")).toBe("PAP-1271"); expect(parseIssuePathIdFromPath("/PAP/issues/PAP-1271")).toBe("PAP-1271");
expect(parseIssuePathIdFromPath("/PAP/issues/pap-1272")).toBe("PAP-1272"); expect(parseIssuePathIdFromPath("/PAP/issues/pap-1272")).toBe("PAP-1272");
expect(parseIssuePathIdFromPath("/issues/pc1a2-7")).toBe("PC1A2-7");
expect(parseIssuePathIdFromPath("/PC1A2/issues/pc1a2-7")).toBe("PC1A2-7"); expect(parseIssuePathIdFromPath("/PC1A2/issues/pc1a2-7")).toBe("PC1A2-7");
expect(parseIssuePathIdFromPath("/issues/PAP-1179")).toBe("PAP-1179"); expect(parseIssuePathIdFromPath("/issues/PAP-1179")).toBe("PAP-1179");
expect(parseIssuePathIdFromPath("/issues/:id")).toBeNull(); expect(parseIssuePathIdFromPath("/issues/:id")).toBeNull();

View file

@ -66,6 +66,7 @@ describe("extractIssueTimelineEvents", () => {
createdAt: new Date("2026-03-31T12:01:00.000Z"), createdAt: new Date("2026-03-31T12:01:00.000Z"),
actorType: "user", actorType: "user",
actorId: "local-board", actorId: "local-board",
runId: null,
statusChange: { statusChange: {
from: "todo", from: "todo",
to: "in_progress", to: "in_progress",
@ -76,6 +77,7 @@ describe("extractIssueTimelineEvents", () => {
createdAt: new Date("2026-03-31T12:02:00.000Z"), createdAt: new Date("2026-03-31T12:02:00.000Z"),
actorType: "user", actorType: "user",
actorId: "local-board", actorId: "local-board",
runId: null,
assigneeChange: { assigneeChange: {
from: { from: {
agentId: "agent-1", agentId: "agent-1",
@ -118,6 +120,7 @@ describe("extractIssueTimelineEvents", () => {
createdAt: new Date("2026-03-31T12:01:00.000Z"), createdAt: new Date("2026-03-31T12:01:00.000Z"),
actorType: "agent", actorType: "agent",
actorId: "agent-1", actorId: "agent-1",
runId: "run-1",
statusChange: { statusChange: {
from: "done", from: "done",
to: "todo", to: "todo",
@ -157,6 +160,7 @@ describe("extractIssueTimelineEvents", () => {
createdAt: new Date("2026-03-31T12:01:00.000Z"), createdAt: new Date("2026-03-31T12:01:00.000Z"),
actorType: "agent", actorType: "agent",
actorId: "agent-1", actorId: "agent-1",
runId: "run-1",
commentId: "comment-1", commentId: "comment-1",
followUpRequested: true, followUpRequested: true,
statusChange: { statusChange: {
@ -194,6 +198,7 @@ describe("extractIssueTimelineEvents", () => {
createdAt: new Date("2026-03-31T12:01:00.000Z"), createdAt: new Date("2026-03-31T12:01:00.000Z"),
actorType: "agent", actorType: "agent",
actorId: "agent-1", actorId: "agent-1",
runId: "run-1",
commentId: "comment-1", commentId: "comment-1",
followUpRequested: true, followUpRequested: true,
}, },

View file

@ -10,6 +10,7 @@ export interface IssueTimelineEvent {
createdAt: Date | string; createdAt: Date | string;
actorType: ActivityEvent["actorType"]; actorType: ActivityEvent["actorType"];
actorId: string; actorId: string;
runId?: string | null;
statusChange?: { statusChange?: {
from: string | null; from: string | null;
to: string | null; to: string | null;
@ -67,6 +68,7 @@ export function extractIssueTimelineEvents(activity: ActivityEvent[] | null | un
createdAt: event.createdAt, createdAt: event.createdAt,
actorType: event.actorType, actorType: event.actorType,
actorId: event.actorId, actorId: event.actorId,
runId: event.runId ?? null,
commentId, commentId,
followUpRequested: true, followUpRequested: true,
}); });
@ -81,6 +83,7 @@ export function extractIssueTimelineEvents(activity: ActivityEvent[] | null | un
createdAt: event.createdAt, createdAt: event.createdAt,
actorType: event.actorType, actorType: event.actorType,
actorId: event.actorId, actorId: event.actorId,
runId: event.runId ?? null,
}; };
if (details.followUpRequested === true || details.resumeIntent === true) { if (details.followUpRequested === true || details.resumeIntent === true) {
timelineEvent.followUpRequested = true; timelineEvent.followUpRequested = true;

View file

@ -217,6 +217,15 @@ function resolveRunningIssueRun(
: (liveRuns ?? []).find((run) => run.status === "running") ?? null; : (liveRuns ?? []).find((run) => run.status === "running") ?? null;
} }
function dedupeLiveRunsById(liveRuns: readonly LiveRunForIssue[]) {
const seen = new Set<string>();
return liveRuns.filter((run) => {
if (seen.has(run.id)) return false;
seen.add(run.id);
return true;
});
}
function readIssueRunStateFromCache(queryClient: QueryClient, issueId: string) { function readIssueRunStateFromCache(queryClient: QueryClient, issueId: string) {
const liveRuns = queryClient.getQueryData<LiveRunForIssue[]>( const liveRuns = queryClient.getQueryData<LiveRunForIssue[]>(
queryKeys.issues.liveRuns(issueId), queryKeys.issues.liveRuns(issueId),
@ -1524,23 +1533,36 @@ export function IssueDetail() {
[comments, optimisticComments], [comments, optimisticComments],
); );
const breadcrumbTitle = issue?.title ?? issueId ?? "Issue"; const breadcrumbTitle = issue?.title ?? issueId ?? "Issue";
const issueCacheRefs = useMemo(() => {
const refs = new Set<string>();
if (issueId) refs.add(issueId);
if (issue?.id) refs.add(issue.id);
if (issue?.identifier) refs.add(issue.identifier);
return [...refs];
}, [issue?.id, issue?.identifier, issueId]);
const invalidateIssueDetail = useCallback(() => { const invalidateIssueDetail = useCallback(() => {
queryClient.invalidateQueries({ queryKey: queryKeys.issues.detail(issueId!) }); for (const ref of issueCacheRefs) {
queryClient.invalidateQueries({ queryKey: queryKeys.issues.activity(issueId!) }); queryClient.invalidateQueries({ queryKey: queryKeys.issues.detail(ref) });
queryClient.invalidateQueries({ queryKey: queryKeys.issues.interactions(issueId!) }); queryClient.invalidateQueries({ queryKey: queryKeys.issues.activity(ref) });
}, [issueId, queryClient]); queryClient.invalidateQueries({ queryKey: queryKeys.issues.interactions(ref) });
}
}, [issueCacheRefs, queryClient]);
const invalidateIssueThreadLazily = useCallback(() => { const invalidateIssueThreadLazily = useCallback(() => {
queryClient.invalidateQueries({ queryKey: queryKeys.issues.detail(issueId!), refetchType: "inactive" }); for (const ref of issueCacheRefs) {
queryClient.invalidateQueries({ queryKey: queryKeys.issues.activity(issueId!), refetchType: "inactive" }); queryClient.invalidateQueries({ queryKey: queryKeys.issues.detail(ref), refetchType: "inactive" });
queryClient.invalidateQueries({ queryKey: queryKeys.issues.interactions(issueId!), refetchType: "inactive" }); queryClient.invalidateQueries({ queryKey: queryKeys.issues.activity(ref), refetchType: "inactive" });
}, [issueId, queryClient]); queryClient.invalidateQueries({ queryKey: queryKeys.issues.interactions(ref), refetchType: "inactive" });
}
}, [issueCacheRefs, queryClient]);
const invalidateIssueRunState = useCallback(() => { const invalidateIssueRunState = useCallback(() => {
queryClient.invalidateQueries({ queryKey: queryKeys.issues.runs(issueId!) }); for (const ref of issueCacheRefs) {
queryClient.invalidateQueries({ queryKey: queryKeys.issues.liveRuns(issueId!) }); queryClient.invalidateQueries({ queryKey: queryKeys.issues.runs(ref) });
queryClient.invalidateQueries({ queryKey: queryKeys.issues.activeRun(issueId!) }); queryClient.invalidateQueries({ queryKey: queryKeys.issues.liveRuns(ref) });
}, [issueId, queryClient]); queryClient.invalidateQueries({ queryKey: queryKeys.issues.activeRun(ref) });
}
}, [issueCacheRefs, queryClient]);
const removeCommentFromCache = useCallback((commentId: string) => { const removeCommentFromCache = useCallback((commentId: string) => {
queryClient.setQueryData<InfiniteData<IssueComment[], string | null> | undefined>( queryClient.setQueryData<InfiniteData<IssueComment[], string | null> | undefined>(
@ -2203,18 +2225,26 @@ export function IssueDetail() {
const interruptQueuedComment = useMutation({ const interruptQueuedComment = useMutation({
mutationFn: (runId: string) => heartbeatsApi.cancel(runId), mutationFn: (runId: string) => heartbeatsApi.cancel(runId),
onMutate: async (runId) => { onMutate: async (runId) => {
await queryClient.cancelQueries({ queryKey: queryKeys.issues.runs(issueId!) }); await Promise.all(issueCacheRefs.flatMap((ref) => [
await queryClient.cancelQueries({ queryKey: queryKeys.issues.liveRuns(issueId!) }); queryClient.cancelQueries({ queryKey: queryKeys.issues.runs(ref) }),
await queryClient.cancelQueries({ queryKey: queryKeys.issues.activeRun(issueId!) }); queryClient.cancelQueries({ queryKey: queryKeys.issues.liveRuns(ref) }),
await queryClient.cancelQueries({ queryKey: queryKeys.issues.detail(issueId!) }); queryClient.cancelQueries({ queryKey: queryKeys.issues.activeRun(ref) }),
queryClient.cancelQueries({ queryKey: queryKeys.issues.detail(ref) }),
]));
const previousRuns = queryClient.getQueryData<RunForIssue[]>(queryKeys.issues.runs(issueId!)); const previousRunState = issueCacheRefs.map((ref) => ({
const previousLiveRuns = queryClient.getQueryData<LiveRunForIssue[]>(queryKeys.issues.liveRuns(issueId!)); ref,
const previousActiveRun = queryClient.getQueryData<ActiveRunForIssue | null>(queryKeys.issues.activeRun(issueId!)); runs: queryClient.getQueryData<RunForIssue[]>(queryKeys.issues.runs(ref)),
const previousIssue = queryClient.getQueryData<Issue>(queryKeys.issues.detail(issueId!)); liveRuns: queryClient.getQueryData<LiveRunForIssue[]>(queryKeys.issues.liveRuns(ref)),
activeRun: queryClient.getQueryData<ActiveRunForIssue | null>(queryKeys.issues.activeRun(ref)),
issue: queryClient.getQueryData<Issue>(queryKeys.issues.detail(ref)),
}));
const previousLocalQueuedCommentRunIds = locallyQueuedCommentRunIds; const previousLocalQueuedCommentRunIds = locallyQueuedCommentRunIds;
const liveRunList = previousLiveRuns ?? []; const cachedActiveRun =
const cachedActiveRun = previousActiveRun ?? null; previousRunState.find((state) => state.activeRun?.id === runId)?.activeRun ??
previousRunState.find((state) => state.activeRun)?.activeRun ??
null;
const liveRunList = dedupeLiveRunsById(previousRunState.flatMap((state) => state.liveRuns ?? []));
const runningIssueRun = resolveRunningIssueRun(cachedActiveRun, liveRunList); const runningIssueRun = resolveRunningIssueRun(cachedActiveRun, liveRunList);
const targetRun = const targetRun =
cachedActiveRun?.id === runId cachedActiveRun?.id === runId
@ -2223,34 +2253,35 @@ export function IssueDetail() {
if (targetRun) { if (targetRun) {
const interruptedAt = new Date().toISOString(); const interruptedAt = new Date().toISOString();
queryClient.setQueryData<RunForIssue[] | undefined>( for (const ref of issueCacheRefs) {
queryKeys.issues.runs(issueId!), queryClient.setQueryData<RunForIssue[] | undefined>(
(current) => upsertInterruptedRun(current, targetRun, interruptedAt), queryKeys.issues.runs(ref),
); (current) => upsertInterruptedRun(current, targetRun, interruptedAt),
);
}
} }
queryClient.setQueryData( for (const ref of issueCacheRefs) {
queryKeys.issues.liveRuns(issueId!), queryClient.setQueryData(
(current: LiveRunForIssue[] | undefined) => removeLiveRunById(current, runId), queryKeys.issues.liveRuns(ref),
); (current: LiveRunForIssue[] | undefined) => removeLiveRunById(current, runId),
queryClient.setQueryData( );
queryKeys.issues.activeRun(issueId!), queryClient.setQueryData(
(current: ActiveRunForIssue | null | undefined) => (current?.id === runId ? null : current), queryKeys.issues.activeRun(ref),
); (current: ActiveRunForIssue | null | undefined) => (current?.id === runId ? null : current),
queryClient.setQueryData( );
queryKeys.issues.detail(issueId!), queryClient.setQueryData(
(current: Issue | undefined) => clearIssueExecutionRun(current, runId), queryKeys.issues.detail(ref),
); (current: Issue | undefined) => clearIssueExecutionRun(current, runId),
);
}
setLocallyQueuedCommentRunIds((current) => { setLocallyQueuedCommentRunIds((current) => {
const next = new Map([...current].filter(([, targetRunId]) => targetRunId !== runId)); const next = new Map([...current].filter(([, targetRunId]) => targetRunId !== runId));
return next.size === current.size ? current : next; return next.size === current.size ? current : next;
}); });
return { return {
previousRuns, previousRunState,
previousLiveRuns,
previousActiveRun,
previousIssue,
previousLocalQueuedCommentRunIds, previousLocalQueuedCommentRunIds,
}; };
}, },
@ -2264,10 +2295,12 @@ export function IssueDetail() {
}); });
}, },
onError: (err, _runId, context) => { onError: (err, _runId, context) => {
queryClient.setQueryData(queryKeys.issues.runs(issueId!), context?.previousRuns); for (const state of context?.previousRunState ?? []) {
queryClient.setQueryData(queryKeys.issues.liveRuns(issueId!), context?.previousLiveRuns); queryClient.setQueryData(queryKeys.issues.runs(state.ref), state.runs);
queryClient.setQueryData(queryKeys.issues.activeRun(issueId!), context?.previousActiveRun); queryClient.setQueryData(queryKeys.issues.liveRuns(state.ref), state.liveRuns);
queryClient.setQueryData(queryKeys.issues.detail(issueId!), context?.previousIssue); queryClient.setQueryData(queryKeys.issues.activeRun(state.ref), state.activeRun);
queryClient.setQueryData(queryKeys.issues.detail(state.ref), state.issue);
}
if (context?.previousLocalQueuedCommentRunIds) { if (context?.previousLocalQueuedCommentRunIds) {
setLocallyQueuedCommentRunIds(context.previousLocalQueuedCommentRunIds); setLocallyQueuedCommentRunIds(context.previousLocalQueuedCommentRunIds);
} }