[codex] Add live-run stop finalization actions (#7679)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Operators supervise live agent runs from the issue detail chat
surface.
> - The existing run menu can pause/stop work, but operators sometimes
need to stop the active run and immediately finalize the task outcome.
> - Doing those as separate actions is slower and easier to leave
half-finished.
> - This pull request adds explicit live-run finalization actions to the
issue chat run menu.
> - The benefit is a clearer operator path for stopping a live run and
marking the task done or cancelled in one ordered flow.

## Linked Issues or Issue Description

No public GitHub issue exists for this internal Paperclip task. Internal
task: PAP-10535.

## Subsystem affected

ui/ — React + Vite board UI.

## Problem or motivation

Operators can stop an active run from the issue detail chat, but
finalizing the issue outcome requires a separate status action after the
run is stopped. That extra step makes live-run finalization slower and
easier to leave incomplete.

## Proposed solution

Add explicit issue chat run-menu actions for `Stop and cancel` and `Stop
and done`, where each action cancels the active heartbeat run before
updating the issue status.

## Alternatives considered

Keep the existing two-step flow of cancelling the run first and then
changing issue status separately. That preserves current behavior but
does not solve the operator workflow gap.

## Roadmap alignment

This is a small targeted UI control-plane improvement for supervising
live agent work. It does not duplicate a planned core roadmap item found
in `ROADMAP.md`.

This PR was split from the local `master` branch on June 7, 2026. It
covers the UI-only live-run finalization action. I searched GitHub for
duplicate/related PRs; the results were broader run-control PRs, not
this exact issue-detail menu action.

## What Changed

- Added optional `runFinalizationActions` support to `IssueChatThread`
assistant message run menus.
- Added `Stop and cancel` and `Stop and done` actions on the issue
detail chat tab.
- Each action cancels the active heartbeat run before updating the issue
status.
- Added focused UI coverage to assert cancellation happens before the
status update.
- Addressed Greptile feedback for partial-failure messaging and
duplicate run-state invalidation.

## Verification

- `git diff --check origin/master..HEAD`
- `git diff --check`
- `NODE_ENV=test pnpm exec vitest run ui/src/pages/IssueDetail.test.tsx`
- Storybook screenshot capture for the live-run menu before and after
the finalization actions.

## Screenshots

Before: existing live-run menu only offered the normal stop action.

![Before live-run
menu](https://raw.githubusercontent.com/paperclipai/paperclip/codex/live-run-stop-finalization/screenshots/PAP-10535-live-run-menu-before.png)

After: the live-run menu includes `Stop and cancel` and `Stop and done`.

![After live-run
menu](https://raw.githubusercontent.com/paperclipai/paperclip/codex/live-run-stop-finalization/screenshots/PAP-10535-live-run-menu-after.png)

## Risks

- Medium UI behavior risk: the new actions expose faster finalization
controls from the live-run menu. They are gated through the existing
issue detail management surface and still use the existing run cancel
and issue update APIs.
- Low migration risk: no schema, API contract, or dependency changes.

> 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 coding agent based on GPT-5, with shell, git, GitHub CLI,
local test execution, and Playwright browser screenshot capture. Exact
hosted model variant and context-window size were not exposed by 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 searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [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] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [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-06-07 06:26:17 -05:00
committed by GitHub
parent 4da79a88c6
commit 35c31ca63f
5 changed files with 243 additions and 1 deletions
+111
View File
@@ -219,6 +219,11 @@ vi.mock("../components/IssueChatThread", () => ({
onStopRun?: (runId: string) => Promise<void>;
stopRunLabel?: string;
stoppingRunLabel?: string;
runFinalizationActions?: readonly {
id: string;
label: string;
onSelect: (runId: string) => Promise<void> | void;
}[];
footer?: ReactNode;
}) => {
mockIssueChatThreadRender(props);
@@ -230,6 +235,15 @@ vi.mock("../components/IssueChatThread", () => ({
{props.stopRunLabel ?? "Stop run"}
</button>
) : null}
{props.runFinalizationActions?.map((action) => (
<button
key={action.id}
type="button"
onClick={() => void action.onSelect("run-active-1")}
>
{action.label}
</button>
))}
{props.footer}
</div>
);
@@ -1436,6 +1450,103 @@ describe("IssueDetail", () => {
expect(pauseMenuButton).toBeTruthy();
});
it("routes live-run finalization actions through run cancellation before issue status update", async () => {
mockIssuesApi.get.mockResolvedValue(createIssue({
status: "in_progress",
assigneeAgentId: "agent-1",
executionRunId: "run-active-1",
}));
mockIssuesApi.update.mockImplementation((_id, data) =>
Promise.resolve(createIssue({
status: data.status as Issue["status"],
assigneeAgentId: "agent-1",
})),
);
mockHeartbeatsApi.cancel.mockResolvedValue(undefined);
mockAgentsApi.list.mockResolvedValue([createAgent()]);
mockAuthApi.getSession.mockResolvedValue({
session: { userId: "user-1" },
user: { id: "user-1" },
});
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<IssueDetail />
</QueryClientProvider>,
);
});
await flushReact();
const stopAndDoneButton = Array.from(container.querySelectorAll("button"))
.find((button) => button.textContent?.trim() === "Stop and done");
expect(stopAndDoneButton).toBeTruthy();
await act(async () => {
stopAndDoneButton!.click();
});
await flushReact();
expect(mockHeartbeatsApi.cancel).toHaveBeenCalledWith("run-active-1");
expect(mockIssuesApi.update).toHaveBeenCalledWith("PAP-1", { status: "done" });
expect(mockHeartbeatsApi.cancel.mock.invocationCallOrder[0])
.toBeLessThan(mockIssuesApi.update.mock.invocationCallOrder[0]);
const stopAndCancelButton = Array.from(container.querySelectorAll("button"))
.find((button) => button.textContent?.trim() === "Stop and cancel");
expect(stopAndCancelButton).toBeTruthy();
await act(async () => {
stopAndCancelButton!.click();
});
await flushReact();
expect(mockIssuesApi.update).toHaveBeenLastCalledWith("PAP-1", { status: "cancelled" });
expect(mockHeartbeatsApi.cancel).toHaveBeenCalledTimes(2);
expect(mockHeartbeatsApi.cancel.mock.invocationCallOrder[1])
.toBeLessThan(mockIssuesApi.update.mock.invocationCallOrder[1]);
});
it("reports partial success when run finalization stops the run but task status update fails", async () => {
mockIssuesApi.get.mockResolvedValue(createIssue({
status: "in_progress",
assigneeAgentId: "agent-1",
executionRunId: "run-active-1",
}));
mockIssuesApi.update.mockRejectedValue(new Error("Status write failed"));
mockHeartbeatsApi.cancel.mockResolvedValue(undefined);
mockAgentsApi.list.mockResolvedValue([createAgent()]);
mockAuthApi.getSession.mockResolvedValue({
session: { userId: "user-1" },
user: { id: "user-1" },
});
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<IssueDetail />
</QueryClientProvider>,
);
});
await flushReact();
const stopAndDoneButton = Array.from(container.querySelectorAll("button"))
.find((button) => button.textContent?.trim() === "Stop and done");
expect(stopAndDoneButton).toBeTruthy();
await act(async () => {
stopAndDoneButton!.click();
});
await flushReact();
expect(mockHeartbeatsApi.cancel).toHaveBeenCalledWith("run-active-1");
expect(mockPushToast).toHaveBeenCalledWith(expect.objectContaining({
title: "Run stopped; task update failed",
body: "Run was stopped, but updating the task failed: Status write failed",
tone: "error",
}));
});
it("passes planning work mode to the issue chat thread", async () => {
mockIssuesApi.get.mockResolvedValue(createIssue({ workMode: "planning" }));
await act(async () => {