Integrate runner recovery through work-folder PR 13048

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-09-12 20:00:15 -05:00
commit 557f6fd054
95 files changed
+3079 -473

No files matched your search

+63 -25
View File
@@ -3573,13 +3573,7 @@ describe("IssueChatThread", () => {
expect(send().disabled).toBe(false);
await act(async () => send().click());
const expectedBody = `Inspect the file\n\n[fresh.txt](/api/attachments/${id}/content)`;
expect(onAdd).toHaveBeenNthCalledWith(
1,
expectedBody,
undefined,
undefined,
[id],
);
expect(onAdd).toHaveBeenNthCalledWith(1, expectedBody, undefined, undefined, [id], expect.any(String));
expect(appendMock).not.toHaveBeenCalled();
await act(async () => root.unmount());
root = createRoot(container);
@@ -3591,13 +3585,7 @@ describe("IssueChatThread", () => {
).toBe("Inspect the file");
expect(container.textContent).toContain("fresh.txt");
await act(async () => send().click());
expect(onAdd).toHaveBeenNthCalledWith(
2,
expectedBody,
undefined,
undefined,
[id],
);
expect(onAdd).toHaveBeenNthCalledWith(2, expectedBody, undefined, undefined, [id], expect.any(String));
expect(onAttachImage).toHaveBeenCalledTimes(1);
await act(async () => root.unmount());
});
@@ -3668,6 +3656,64 @@ describe("IssueChatThread", () => {
},
);
it.each(["late receipt", "reload receipt", "navigation success"])(
"preserves a newer legacy draft after %s",
async (outcome) => {
const key = `legacy-next-draft-${outcome}`;
let resolveSend!: () => void;
let rejectSend!: (error: Error) => void;
const onAdd = vi.fn().mockReturnValue(new Promise<void>((resolve, reject) => {
resolveSend = resolve;
rejectSend = reject;
}));
const attachmentId = "aaf8228f-0be7-45ae-a104-6fbe0af6f1d3";
const onAttachImage = vi.fn().mockResolvedValue({
id: attachmentId, contentPath: `/api/attachments/${attachmentId}/content`, originalFilename: "next-draft.txt",
});
let root = createRoot(container);
const element = (requestId?: string) => (
<MemoryRouter>
<IssueChatThread
comments={requestId ? [{
...issueChatLongThreadComments[0]!,
id: "confirmed-legacy-comment", body: "Earlier message",
authorAgentId: null, authorUserId: "user-1", clientRequestId: requestId,
}] : []}
currentUserId="user-1"
linkedRuns={[]} timelineEvents={[]} liveRuns={[]}
onAdd={onAdd} onAttachImage={onAttachImage} draftKey={key} enableLiveTranscriptPolling={false}
/>
</MemoryRouter>
);
const editor = () => container.querySelector<HTMLTextAreaElement>('textarea[aria-label="Issue chat editor"]')!;
const type = (value: string) => act(() => {
Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, "value")!.set!.call(editor(), value);
editor().dispatchEvent(new Event("input", { bubbles: true }));
});
await act(async () => root.render(element()));
type("Earlier message");
await act(async () => Array.from(container.querySelectorAll("button")).find(button => button.textContent === "Send")!.click());
const requestId = onAdd.mock.calls[0]![4] as string;
type("Newer unsent draft");
await act(async () => container.querySelector('[data-testid="issue-chat-composer"]')!
.dispatchEvent(createFileDragEvent("drop", [new File(["next"], "next-draft.txt", { type: "text/plain" })])));
if (outcome !== "late receipt") await act(async () => root.unmount());
if (outcome === "navigation success") await act(async () => resolveSend());
else if (outcome === "late receipt") await act(async () => rejectSend(new CommentSubmissionUnknownError()));
if (outcome !== "late receipt") root = createRoot(container);
await act(async () => root.render(element(requestId)));
expect(editor().value).toBe("Newer unsent draft");
expect(localStorage.getItem(key)).toBe("Newer unsent draft");
expect(localStorage.getItem(`${key}:submission:v1`)).toBeNull();
expect(localStorage.getItem(`${key}:attachments:v1`)).toContain(attachmentId);
expect(container.textContent).toContain("next-draft.txt");
expect(container.textContent).not.toContain("We couldn’t confirm");
await act(async () => root.unmount());
await act(async () => resolveSend());
expect(localStorage.getItem(key)).toBe("Newer unsent draft");
},
);
it("keeps a reassigned legacy comment pending until its actual mutation promise settles", async () => {
let resolveSend!: () => void;
const onAdd = vi.fn().mockReturnValue(
@@ -3716,7 +3762,7 @@ describe("IssueChatThread", () => {
expect(onAdd).toHaveBeenCalledWith("Please review the result", undefined, {
assigneeAgentId: null,
assigneeUserId: "reviewer",
});
}, undefined, expect.any(String));
expect(appendMock).not.toHaveBeenCalled();
expect(container.textContent).toContain("Posting...");
expect(localStorage.getItem("legacy-awaited-reassignment")).toBe(
@@ -3967,11 +4013,7 @@ describe("IssueChatThread", () => {
submitButton?.click();
});
expect(onAdd).toHaveBeenCalledWith(
"Please pick this back up",
true,
undefined,
);
expect(onAdd).toHaveBeenCalledWith("Please pick this back up", true, undefined, undefined, expect.any(String));
act(() => {
root.unmount();
@@ -4044,11 +4086,7 @@ describe("IssueChatThread", () => {
});
expect(onAdd).toHaveBeenCalledTimes(1);
expect(onAdd).toHaveBeenCalledWith(
"Reply without assignee",
undefined,
undefined,
);
expect(onAdd).toHaveBeenCalledWith("Reply without assignee", undefined, undefined, undefined, expect.any(String));
expect(
document.querySelector('[data-testid="issue-chat-no-assignee-dialog"]'),
).toBeNull();
+79 -23
View File
@@ -64,6 +64,7 @@ import {
loadDraftSubmission,
saveDraftSubmission,
clearDraftSubmission,
settleDraftSubmission,
type ComposerDraftSubmission,
} from "../lib/composer-draft";
import { CommentSubmissionUnknownError } from "../lib/comment-submit-result";
@@ -502,6 +503,7 @@ export interface IssueChatComposerHandle {
interface IssueChatComposerProps {
onSend: IssueChatThreadProps["onAdd"];
confirmedSubmissionIds: ReadonlySet<string>;
onReviewConversation?: () => Promise<void>;
onStop?: () => Promise<void>;
stopPending?: boolean;
@@ -4668,6 +4670,7 @@ const IssueChatComposer = forwardRef<
>(function IssueChatComposer(
{
onSend,
confirmedSubmissionIds,
onReviewConversation,
onStop,
stopPending,
@@ -4713,6 +4716,30 @@ const IssueChatComposer = forwardRef<
}, [draftKey]);
const bodyRef = useRef(body);
bodyRef.current = body;
const pendingDraftRef = useRef<{
draftKey: string;
attemptId: string;
submittedBody: string;
submittedAttachmentIds: string[];
} | null>(null);
function changeBody(update: string | ((current: string) => string)) {
const value = typeof update === "function" ? update(bodyRef.current) : update;
bodyRef.current = value;
setBody(value);
const pending = pendingDraftRef.current;
if (!pending || pending.draftKey !== draftKey ||
loadDraftSubmission(pending.draftKey)?.attemptId !== pending.attemptId) return;
// Persist the next draft while delivery is pending, before navigation or a
// lost response can turn the original submission into an uncertain one.
saveDraft(pending.draftKey,
value ? `${pending.submittedBody}\n\n${value}` : pending.submittedBody,
pending.attemptId);
saveDraftSubmission(pending.draftKey, {
attemptId: pending.attemptId, reviewed: false,
nextDraftOffset: pending.submittedBody.length + (value ? 2 : 0),
submittedAttachmentIds: pending.submittedAttachmentIds,
});
}
const submittingRef = useRef(submitting);
submittingRef.current = submitting;
const [attaching, setAttaching] = useState(false);
@@ -4741,6 +4768,12 @@ const IssueChatComposer = forwardRef<
: update;
composerAttachmentsRef.current = next;
setComposerAttachmentState(next);
const pending = pendingDraftRef.current;
if (pending && pending.draftKey === draftKey) {
saveDraftAttachments(pending.draftKey, next
.filter(item => item.status === "attached" && item.attachmentId)
.map(item => ({ ...item, inline: item.inline === true })), pending.attemptId);
}
}
const dragDepthRef = useRef(0);
const effectiveSuggestedAssigneeValue =
@@ -4810,6 +4843,22 @@ const IssueChatComposer = forwardRef<
);
}, [draftKey]);
// A server receipt for this exact request settles a restored submission.
// Text equality is not delivery proof: users may intentionally repeat text.
useEffect(() => {
if (!uncertainSubmission || !confirmedSubmissionIds.has(uncertainSubmission.attemptId)) return;
const nextDraft = uncertainSubmission.nextDraftOffset === undefined
? "" : bodyRef.current.slice(uncertainSubmission.nextDraftOffset);
if (draftKey) settleDraftSubmission(draftKey, uncertainSubmission.attemptId, nextDraft);
setUncertainSubmission(null);
setBody(nextDraft);
bodyRef.current = nextDraft;
const submittedIds = uncertainSubmission.submittedAttachmentIds;
setComposerAttachments(current => submittedIds
? current.filter(item => !item.attachmentId || !submittedIds.includes(item.attachmentId))
: []);
}, [confirmedSubmissionIds, draftKey, uncertainSubmission]);
useEffect(() => {
if (
!draftKey ||
@@ -4960,6 +5009,7 @@ const IssueChatComposer = forwardRef<
const workModeChanged = pendingWorkMode !== resolvedIssueWorkMode;
if (draftKey) saveDraft(draftKey, trimmed);
setSubmitting(true);
bodyRef.current = "";
setBody("");
let attemptId: string | null = null;
try {
@@ -4976,37 +5026,45 @@ const IssueChatComposer = forwardRef<
if (draftKey) {
saveDraft(draftKey, trimmed);
saveDraftSubmission(draftKey, { attemptId, reviewed: false });
pendingDraftRef.current = { draftKey, attemptId, submittedBody: trimmed, submittedAttachmentIds: attachmentIds };
changeBody(bodyRef.current);
}
// assistant-ui thread.append is fire-and-forget. Await the actual Board
// mutation; it already owns optimistic echo and durable error handling.
const sendPromise = attachmentIds.length
? onSend(submittedBody, reopen, reassignment, attachmentIds)
: onSend(submittedBody, reopen, reassignment);
const sendPromise = onSend(
submittedBody, reopen, reassignment,
attachmentIds.length ? attachmentIds : undefined, attemptId,
);
queueViewportRestore(viewportSnapshot);
await sendPromise;
// Settle the captured task even if the user navigated away. The exact
// attempt guard preserves any newer submission in this or another tab.
if (draftKey) settleDraftSubmission(draftKey, attemptId,
mountedTaskKey.current === draftKey ? bodyRef.current : undefined);
if (mountedTaskKey.current !== draftKey) return;
if (draftKey) clearDraftSubmission(draftKey, attemptId);
if (draftKey) clearDraft(draftKey);
setComposerAttachments((current) =>
current.filter((item) => !submittedAttachmentKeys.has(item.id)),
);
setReassignTarget(effectiveSuggestedAssigneeValue);
} catch (error) {
if (mountedTaskKey.current !== draftKey) return;
const nextDraft = bodyRef.current;
if (attemptId && error instanceof CommentSubmissionUnknownError) {
const uncertain = { attemptId, reviewed: false };
const uncertain = {
attemptId, reviewed: false,
nextDraftOffset: trimmed.length + (nextDraft ? 2 : 0),
submittedAttachmentIds: attachmentIds,
};
setUncertainSubmission(uncertain);
if (draftKey && loadDraftSubmission(draftKey)?.attemptId === attemptId)
saveDraftSubmission(draftKey, uncertain);
} else if (draftKey && attemptId)
clearDraftSubmission(draftKey, attemptId);
const restoredBody = restoreSubmittedCommentDraft({
currentBody: bodyRef.current,
submittedBody: trimmed,
});
const restoredBody = nextDraft ? `${trimmed}\n\n${nextDraft}` : trimmed;
if (draftKey) saveDraft(draftKey, restoredBody, attemptId ?? undefined);
setBody(restoredBody);
} finally {
if (pendingDraftRef.current?.attemptId === attemptId) pendingDraftRef.current = null;
setSubmitting(false);
queueViewportRestore(viewportSnapshot);
}
@@ -5041,7 +5099,7 @@ const IssueChatComposer = forwardRef<
const safeName = file.name.replace(/[[\]]/g, "\\$&");
const markdown = `![${safeName}](${url})`;
if (insertInline)
setBody((prev) => (prev ? `${prev}\n\n${markdown}` : markdown));
changeBody((prev) => (prev ? `${prev}\n\n${markdown}` : markdown));
setComposerAttachments((prev) =>
prev.map((item) =>
item.id === attachmentId
@@ -5062,7 +5120,7 @@ const IssueChatComposer = forwardRef<
return undefined;
if (inline && insertInline) {
const markdown = `![${file.name.replace(/[[\]]/g, "\\$&")}](${attachment.contentPath})`;
setBody((prev) => (prev ? `${prev}\n\n${markdown}` : markdown));
changeBody((prev) => (prev ? `${prev}\n\n${markdown}` : markdown));
}
setComposerAttachments((prev) =>
prev.map((item) =>
@@ -5243,7 +5301,7 @@ const IssueChatComposer = forwardRef<
`(?<![\\w@/])${plainNameCandidate.matchedText.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}(?![\\w/])`,
"i",
);
setBody((current) => {
changeBody((current) => {
if (tokenRe.test(current))
return current.replace(tokenRe, markdown.trimEnd());
return current ? `${current} ${markdown}` : markdown;
@@ -5385,7 +5443,7 @@ const IssueChatComposer = forwardRef<
ref={editorRef}
readOnly={!!uncertainSubmission}
value={body}
onChange={setBody}
onChange={changeBody}
placeholder="Reply"
mentions={mentions}
onSubmit={handleSubmit}
@@ -5641,11 +5699,7 @@ const IssueChatComposer = forwardRef<
disabled={stopControl.stopping}
onClick={() => void stopControl.stop()}
aria-label={stopControl.stopping ? "Stopping…" : "Stop"}
title={
stopScope === "subtree"
? "Stop and pause subtree"
: "Stop and pause task"
}
title="Stop response"
>
{stopControl.stopping ? (
<Loader2 className="h-4 w-4 animate-spin" aria-hidden />
@@ -6053,11 +6107,9 @@ export function IssueChatThread({
}
const sendComposerComment = useCallback<IssueChatThreadProps["onAdd"]>(
(body, reopen, reassignment, attachmentIds) => {
(body, reopen, reassignment, attachmentIds, clientRequestId) => {
pendingSubmitScrollRef.current = true;
return attachmentIds?.length
? onAdd(body, reopen, reassignment, attachmentIds)
: onAdd(body, reopen, reassignment);
return onAdd(body, reopen, reassignment, attachmentIds, clientRequestId);
},
[onAdd],
);
@@ -6723,6 +6775,10 @@ export function IssueChatThread({
onImageUpload={imageUploadHandler}
onAttachImage={onAttachImage}
draftKey={draftKey}
confirmedSubmissionIds={new Set(comments.filter((comment) =>
comment.authorUserId === currentUserId && comment.clientRequestId &&
!("clientStatus" in comment && comment.clientStatus)
).map((comment) => comment.clientRequestId!))}
enableReassign={enableReassign}
reassignOptions={reassignOptions}
currentAssigneeValue={currentAssigneeValue}
@@ -29,6 +29,16 @@ function derived(overrides: Partial<DerivedMonitorState> & { state: DerivedMonit
}
describe("buildMonitorSurfaceCopy", () => {
it.each(["retrying", "due-now", "overdue"] as const)("keeps workspace contention neutral when %s", (state) => {
const copy = buildMonitorSurfaceCopy(derived({
state, source: "scheduled-retry", nextCheckAt: NOW.toISOString(), attemptCount: 4,
}), NOW, "workspace_busy");
expect(copy!.bannerTitle).toBe("Waiting for workspace");
expect(copy!.stripTitle).toBe("Waiting for workspace");
expect(copy!.tone).toBe("info");
expect(copy!.workspaceWait).toBe(true);
expect(copy!.bannerMeta.join(" ")).not.toMatch(/Attempt|overdue|retry/i);
});
it("leads with two-unit relative time while scheduled", () => {
const copy = buildMonitorSurfaceCopy(
derived({
@@ -135,6 +145,19 @@ describe("IssueMonitorBanner / IssueMonitorComposerStrip rendering", () => {
} as unknown as Issue;
}
it("explains automatic workspace waiting without promising that a reply bypasses the lock", () => {
const issue = {
status: "todo", scheduledRetry: { status: "scheduled_retry", scheduledRetryReason: "workspace_busy", scheduledRetryAt: NOW.toISOString() },
} as Issue;
const root = createRoot(container);
flushSync(() => root.render(<><IssueMonitorBanner issue={issue} onCheckNow={vi.fn()} /><IssueMonitorComposerStrip issue={issue} onCheckNow={vi.fn()} /></>));
expect(container.textContent).toContain("Waiting for workspace");
expect(container.textContent).toContain("You can keep sending instructions while the agent waits.");
expect(container.textContent).not.toContain("wakes the agent now");
expect(container.querySelector("button")).toBeNull();
flushSync(() => root.unmount());
});
it("renders the banner with a working Check now button while waiting", () => {
const onCheckNow = vi.fn();
expect(hasVisibleMonitorSurface(issueWithMonitor(new Date(NOW.getTime() + 2 * 60 * 60_000).toISOString()))).toBe(true);
+19 -4
View File
@@ -49,6 +49,7 @@ export interface MonitorSurfaceCopy {
stripMeta: string[];
/** `warning` (amber) once overdue, `info` (blue) while still on schedule. */
tone: "info" | "warning";
workspaceWait?: boolean;
}
function capitalize(value: string): string {
@@ -63,9 +64,21 @@ function capitalize(value: string): string {
export function buildMonitorSurfaceCopy(
derived: DerivedMonitorState,
now: MonitorDate,
scheduledRetryReason?: string | null,
): MonitorSurfaceCopy | null {
if (!isWaitingMonitorState(derived.state) || !derived.nextCheckAt) return null;
if (derived.source === "scheduled-retry" && scheduledRetryReason === "workspace_busy") {
return {
bannerTitle: "Waiting for workspace",
stripTitle: "Waiting for workspace",
bannerMeta: ["Another task is using this workspace. Work starts automatically when it is available."],
stripMeta: ["Work starts automatically when the workspace is available."],
tone: "info",
workspaceWait: true,
};
}
const eta = formatMonitorEta(derived.nextCheckAt, now); // "in 2h 12m" | "due now" | "overdue by 18m"
const absolute = formatMonitorAbsolute(derived.nextCheckAt, {}, now); // local time, e.g. "Today, 4:08 PM"
const isScheduledRetryOnly = derived.source === "scheduled-retry";
@@ -117,7 +130,7 @@ function useMonitorSurfaceCopy(issue: Issue): MonitorSurfaceCopy | null {
// roll scheduled → due → overdue on their own.
const nextCheckAt = useMemo(() => deriveMonitorState(issue).nextCheckAt, [issue]);
const now = useMonitorCountdown(nextCheckAt);
return useMemo(() => buildMonitorSurfaceCopy(deriveMonitorState(issue, now), now), [issue, now]);
return useMemo(() => buildMonitorSurfaceCopy(deriveMonitorState(issue, now), now, issue.scheduledRetry?.scheduledRetryReason), [issue, now]);
}
function CheckNowButton({
@@ -166,7 +179,7 @@ export function IssueMonitorBanner({
icon={Clock}
title={copy.bannerTitle}
className="my-3"
actions={onCheckNow ? <CheckNowButton onCheckNow={onCheckNow} checkingNow={checkingNow} /> : null}
actions={onCheckNow && !copy.workspaceWait ? <CheckNowButton onCheckNow={onCheckNow} checkingNow={checkingNow} /> : null}
>
<span>{copy.bannerMeta.join(" · ")}</span>
</InlineBanner>
@@ -201,10 +214,12 @@ export function IssueMonitorComposerStrip({
<div className="text-xs text-muted-foreground">{copy.stripMeta.join(" · ")}</div>
</div>
</div>
{onCheckNow ? <CheckNowButton onCheckNow={onCheckNow} checkingNow={checkingNow} /> : null}
{onCheckNow && !copy.workspaceWait ? <CheckNowButton onCheckNow={onCheckNow} checkingNow={checkingNow} /> : null}
</div>
<p className="mt-1.5 text-xs text-muted-foreground">
Sending a reply wakes the agent now — before the scheduled check.
{copy.workspaceWait
? "You can keep sending instructions while the agent waits."
: "Sending a reply wakes the agent now — before the scheduled check."}
</p>
</div>
);
@@ -121,6 +121,13 @@ function getRetryNowButton() {
}
describe("IssueScheduledRetryCard", () => {
it("shows workspace contention as an automatic wait without failure or retry controls", () => {
renderWithProviders(<IssueScheduledRetryCard issueId="issue-1" scheduledRetry={{ ...baseRetry, scheduledRetryReason: "workspace_busy" }} />);
expect(container.textContent).toContain("Waiting for workspace");
expect(container.textContent).toContain("Work starts automatically");
expect(container.textContent).not.toMatch(/failed|Retry|Attempt|Replaces run/);
expect(getRetryNowButton()).toBeNull();
});
it("renders nothing when there is no scheduled retry", () => {
renderWithProviders(<IssueScheduledRetryCard issueId="issue-1" scheduledRetry={null} />);
expect(getCard()).toBeNull();
@@ -7,6 +7,7 @@ import { formatRetryReason } from "@/lib/runRetryState";
import type { IssueScheduledRetry } from "@paperclipai/shared";
import { useRetryNowMutation, type RetryNowError } from "../hooks/useRetryNowMutation";
import { Badge } from "@/components/ui/badge";
import { InlineBanner } from "@/components/InlineBanner";
const MAX_TURN_CONTINUATION = "max_turns_continuation";
@@ -32,6 +33,14 @@ export function IssueScheduledRetryCard({
if (!scheduledRetry || !issueId) return null;
if (scheduledRetry.status !== "scheduled_retry") return null;
if (scheduledRetry.scheduledRetryReason === "workspace_busy") {
return (
<InlineBanner tone="info" icon={Clock} title="Waiting for workspace" className="mb-3">
Another task is using this workspace. Work starts automatically when it is available.
</InlineBanner>
);
}
const continuation = isContinuationReason(scheduledRetry.scheduledRetryReason);
const dueAtIso = scheduledRetry.scheduledRetryAt
? new Date(scheduledRetry.scheduledRetryAt).toISOString()
@@ -2012,6 +2012,52 @@ describe("OnboardingWizard restore-gate (stale localStorage across accounts)", (
return { root, queryClient };
}
it.each([
["claude_local", "anthropic", /Claude/, "claude-session-1", "claude-setup-token-status"],
["codex_local", "openai", /OpenAI/, "codex-session-1", "adapter-login-status"],
] as const)("finishes %s sign-in when its connection becomes visible before the completion poll", async (adapterType, provider, label, sessionId, statusKey) => {
mockAgentsApi.getAdapterAuthSignal.mockResolvedValue({ status: "absent" });
mockAgentsApi.hire.mockRejectedValueOnce(new Error("Temporary hire failure"));
const { root, queryClient } = await openStep4({ adapterType });
await pickSource(label);
for (let i = 0; i < 6; i++) await flushReact();
try {
// The connection activity event arrives before the login poll. It must
// not replace/unmount the controller that still owns the completion.
await act(async () => {
queryClient.setQueryData(["ai-connections", "company-new"], {
currentUserId: "user-1",
connections: [{ id: "managed-connection", grantId: "managed-grant", companyId: "company-new", provider, method: "subscription", name: "My subscription", ownership: "personal", ownerUserId: "user-1", status: "connected", isDefault: true }],
});
});
for (let i = 0; i < 4; i++) await flushReact();
expect(document.body.textContent).toContain(adapterType === "claude_local" ? "authorization code" : "Q2RJ-E1YIF");
expect(mockAgentsApi.hire).not.toHaveBeenCalled();
await act(async () => {
queryClient.setQueryData(
adapterType === "claude_local" ? [statusKey, "company-new", sessionId] : [statusKey, "company-new", adapterType, sessionId],
{ sessionId, status: "authenticated", expiresAt: new Date(Date.now() + 600_000).toISOString() },
);
});
for (let i = 0; i < 120 && !mockAgentsApi.hire.mock.calls.length; i++) {
await act(async () => { await new Promise(resolve => setTimeout(resolve, 25)); });
}
expect(mockAgentsApi.hire).toHaveBeenCalledTimes(1);
expect(mockAgentsApi.hire).toHaveBeenCalledWith("company-new", expect.objectContaining({ runtimeConfig: expect.objectContaining({ aiConnection: { provider, method: "subscription", mode: "responsible_user" } }) }));
for (let i = 0; i < 4; i++) await flushReact();
expect(document.body.textContent).toContain("Temporary hire failure");
const retry = [...document.body.querySelectorAll("button")].find(button => button.textContent?.trim() === "Connect");
expect(retry).toBeTruthy();
expect(retry!.disabled).toBe(false);
await act(async () => { retry!.click(); });
for (let i = 0; i < 6; i++) await flushReact();
expect(mockAgentsApi.hire).toHaveBeenCalledTimes(2);
expect(mockAgentsApi.startClaudeSetupTokenLogin.mock.calls.length + mockAgentsApi.startAdapterAuthLogin.mock.calls.length).toBe(1);
} finally {
await act(async () => root.unmount());
}
});
it("names the tiles for the provider, not the adapter type", async () => {
// `MODEL_SOURCE_NAMES` exists so this row says "Claude" and "OpenAI" —
// which provider you are signing in to, the question the step's heading
+16 -3
View File
@@ -1150,9 +1150,14 @@ function OnboardingWizardInner({
*/
const connectStepNeedsLogin = Boolean(
credentialMode !== "api" &&
(showAdapterLoginPanel || (canShowAdapterLogin && adapterType === "codex_local" && subscriptionId?.companyId === createdCompanyId && subscriptionId.id === "")) &&
!savedSubscription &&
!(adapterType === "claude_local" && savedKeys.storedLogin.data) &&
// Connection-list invalidation can arrive before the login's completion
// poll. Keep its controller mounted until it reports success; otherwise
// the saved account replaces the panel and "Connecting" never finishes.
(connectAuthUrl || (
(showAdapterLoginPanel || (canShowAdapterLogin && adapterType === "codex_local" && subscriptionId?.companyId === createdCompanyId && subscriptionId.id === "")) &&
!savedSubscription &&
!(adapterType === "claude_local" && savedKeys.storedLogin.data)
)) &&
!savedKeys.loading &&
createdCompanyId &&
resolvedLoginEnvironmentId,
@@ -2154,6 +2159,13 @@ function OnboardingWizardInner({
} finally {
hiringAgentRef.current = false;
setLoading(false);
// Authentication is already saved. A failed probe or hire must offer a
// retry with that account, rather than keep the completed login busy.
if (connectCredentialStored && stillTheSameCompany(createdCompanyId)) {
connectingSinceRef.current = null;
setConnectAuthUrl(null);
setConnectPhase((phase) => phase === "connecting" ? "ready" : phase);
}
}
}
@@ -2740,6 +2752,7 @@ function OnboardingWizardInner({
}}
onConnected={() => {
if (managedProvider) managedSubscriptionRef.current = { companyId: createdCompanyId, binding: { provider: managedProvider, method: "subscription", mode: "responsible_user" } };
setConnectAuthUrl(null);
// Not into a card the customer has left. The panel is
// still mounted through Back's exit, and a login that
// finished there pulled the step back into "Connecting"
+154 -28
View File
@@ -1,6 +1,6 @@
// @vitest-environment jsdom
import type { ReactElement } from "react";
import type { ComponentProps, ReactElement } from "react";
import { act, forwardRef, useImperativeHandle, type ForwardedRef } from "react";
import { flushSync } from "react-dom";
import { createRoot, type Root } from "react-dom/client";
@@ -1247,6 +1247,17 @@ describe("TaskChatThread runtime transcript selection", () => {
},
);
it("keeps workspace contention out of the conversation's cancellation markers", () => {
render(<TaskChatThread comments={[]} onAdd={async () => {}} linkedRuns={[{
runId: "workspace-wait", runtimeMode: "native", status: "cancelled", errorCode: "workspace_busy",
agentId: "agent-1", agentName: "Runner", adapterType: "paperclip_runner", startedAt: null,
createdAt: "2026-09-12T18:00:00.000Z", finishedAt: "2026-09-12T18:00:01.000Z",
}]} />);
expect(container.textContent).not.toContain("Run cancelled");
expect(container.textContent).not.toContain("Run failed");
expect(container.textContent).not.toContain("before returning an answer");
});
it("does not render an empty response notice for a conversation reset", () => {
render(<TaskChatThread comments={[]} onAdd={async () => {}} linkedRuns={[{
runId: "chat-reset", status: "succeeded", startedAt: null, resultJson: { conversationReset: true },
@@ -1986,41 +1997,156 @@ describe("TaskChatThread runtime transcript selection", () => {
expect(container.textContent).not.toContain("transcript withheld");
});
it("keeps an empty failed direct run on its legacy failure surface", () => {
it.each(["failed", "timed_out", "cancelled"] as const)(
"keeps an empty legacy %s actionable without turning Stop into Retry",
async (status) => {
const onRetryFailedRun = vi.fn();
render(
<TaskChatThread
comments={[]}
onAdd={async () => {}}
issueStatus="blocked"
onRetryFailedRun={onRetryFailedRun}
linkedRuns={[
{
runId: "legacy-failed",
runtimeMode: "legacy",
status,
errorCode: "legacy_process_exited",
agentId: "agent-1",
agentName: "Direct Codex",
adapterType: "codex_local",
createdAt: "2026-08-25T18:00:00.000Z",
startedAt: "2026-08-25T18:00:00.000Z",
finishedAt: "2026-08-25T18:00:02.000Z",
},
]}
/>,
);
expect(container.textContent).toContain(status === "cancelled" ? "Stopped" : "Run failed");
if (status !== "cancelled") {
expect(container.textContent).toContain("You can retry this message now.");
}
expect(
container.querySelector('[data-testid="task-chat-collapsible-marker"]'),
).toBeNull();
const retry = container.querySelector<HTMLButtonElement>('[data-testid="task-chat-run-failed-try-again"]');
if (status === "cancelled") {
expect(retry).toBeNull();
} else {
expect(retry).not.toBeNull();
flushSync(() => retry!.click());
await Promise.resolve();
expect(onRetryFailedRun).toHaveBeenCalledExactlyOnceWith("legacy-failed");
}
expect(
container.querySelector('[data-testid="task-chat-runner-turn"]'),
).toBeNull();
},
);
it.each(["active execution", "pending decision", "recovery hold"] as const)(
"does not promise legacy Retry during %s and restores it when the gate clears",
(gate) => {
const onRetryFailedRun = vi.fn();
const failedRun = {
runId: "legacy-failed",
runtimeMode: "legacy" as const,
status: "failed",
errorCode: "legacy_process_exited",
agentId: "agent-1",
agentName: "Direct Codex",
adapterType: "codex_local",
createdAt: "2026-08-25T18:00:00.000Z",
startedAt: "2026-08-25T18:00:00.000Z",
finishedAt: "2026-08-25T18:00:02.000Z",
};
const gateProps: Partial<ComponentProps<typeof TaskChatThread>> =
gate === "pending decision"
? { interactions: [planReviewInteraction()] }
: {
linkedRuns: [{
...failedRun,
execution: {
phase: gate === "active execution" ? "working" : "recovery_needed",
label: "Waiting",
cause: null,
lastConfirmedActivityAt: null,
retryAt: null,
attempt: 1,
maxAttempts: 3,
recoveryOwner: null,
nextAction: null,
permittedActions: [],
predecessorRunId: null,
successorRunId: null,
},
}],
};
const renderRun = (held: boolean) => render(
<TaskChatThread
comments={[]}
onAdd={async () => {}}
issueStatus="blocked"
onRetryFailedRun={onRetryFailedRun}
linkedRuns={[failedRun]}
{...(held ? gateProps : {})}
/>,
);
renderRun(true);
expect(container.textContent).toContain("Your message is preserved.");
expect(container.textContent).not.toContain("You can retry this message now.");
expect(container.querySelector('[data-testid="task-chat-run-failed-try-again"]')).toBeNull();
renderRun(false);
expect(container.textContent).toContain("You can retry this message now.");
expect(container.querySelector('[data-testid="task-chat-run-failed-try-again"]')).not.toBeNull();
},
);
it.each([
...["claude_local", "codex_local", "cursor", "gemini_local", "opencode_local",
"pi_local", "grok_local", "kimi_local", "hermes_local"].flatMap((adapterType) =>
["failed", "timed_out"].map((status) => ({ adapterType, status, runtimeMode: "legacy" as const,
cause: "legacy_execution_requires_reconciliation", canRetry: true }))),
{ adapterType: "paperclip_runner", status: "failed", runtimeMode: "native" as const,
cause: "legacy_execution_requires_reconciliation", canRetry: false },
{ adapterType: "process", status: "failed", runtimeMode: "legacy" as const,
cause: "legacy_execution_requires_reconciliation", canRetry: false },
{ adapterType: "claude_local", status: "failed", runtimeMode: "legacy" as const,
cause: "uncertain_provider_action", canRetry: false },
{ adapterType: "claude_local", status: "cancelled", runtimeMode: "legacy" as const,
cause: "legacy_execution_requires_reconciliation", canRetry: false },
])("offers only explicit conversation retries: $runtimeMode/$adapterType/$status/$cause", (testCase) => {
render(
<TaskChatThread
comments={[]}
onAdd={async () => {}}
issueStatus="blocked"
onRetryFailedRun={vi.fn()}
linkedRuns={[
{
runId: "legacy-failed",
runtimeMode: "legacy",
status: "failed",
errorCode: "legacy_process_exited",
agentId: "agent-1",
agentName: "Direct Codex",
adapterType: "codex_local",
createdAt: "2026-08-25T18:00:00.000Z",
startedAt: "2026-08-25T18:00:00.000Z",
finishedAt: "2026-08-25T18:00:02.000Z",
linkedRuns={[{
runId: "stopped-run",
runtimeMode: testCase.runtimeMode,
adapterType: testCase.adapterType,
status: testCase.status,
agentId: "agent-1",
agentName: "Stopped agent",
createdAt: "2026-08-25T18:00:00.000Z",
startedAt: "2026-08-25T18:00:00.000Z",
finishedAt: "2026-08-25T18:00:02.000Z",
execution: {
phase: "recovery_needed", label: "Stopped", cause: testCase.cause,
lastConfirmedActivityAt: null, retryAt: null, attempt: 1, maxAttempts: 3,
recoveryOwner: null, nextAction: null, permittedActions: [],
predecessorRunId: null, successorRunId: null,
},
]}
}]}
/>,
);
expect(container.textContent).toContain("Run failed");
expect(container.textContent).toContain("You can retry this message now.");
expect(
container.querySelector('[data-testid="task-chat-collapsible-marker"]'),
).toBeNull();
expect(
container.querySelector('[data-testid="task-chat-run-failed-try-again"]'),
).toBeNull();
expect(
container.querySelector('[data-testid="task-chat-runner-turn"]'),
).toBeNull();
expect(Boolean(container.querySelector('[data-testid="task-chat-run-failed-try-again"]')))
.toBe(testCase.canRetry);
if (testCase.canRetry) expect(container.textContent).toContain("You can retry this message now.");
else expect(container.textContent).not.toContain("You can retry this message now.");
});
it.each(DIRECT_ADAPTER_TYPES)(
@@ -3581,7 +3707,7 @@ describe("TaskChatThread composer execution controls", () => {
};
render(<TaskChatThread comments={[]} onAdd={async () => {}} issueStatus="in_progress" activeRun={run} onCancelRun={onStop} stopScope="subtree" />);
const button = container.querySelector<HTMLButtonElement>('[data-testid="task-chat-composer-stop"]')!;
expect(button.title).toBe("Stop and pause subtree");
expect(button.title).toBe("Stop response");
await act(async () => { button.click(); });
expect(onStop).toHaveBeenCalledOnce();
render(<TaskChatThread comments={[]} onAdd={async () => {}} issueStatus="in_progress" activeRun={run} onCancelRun={onStop} stopPending />);
+48 -23
View File
@@ -540,6 +540,38 @@ export function TaskChatThread(props: TaskChatThreadProps) {
onResumeAssignee,
resumeAssigneePending = false,
} = props;
const retryFailedRunHandler =
isTerminalIssueStatus(issueStatus) ||
interactions?.some((interaction) => interaction.status === "pending") ||
requiresExecutionReconciliation(props.recoveryAction?.cause) ||
props.scheduledRetry ||
linkedRuns?.some((run) => {
// The server accepts explicit new attempts for these stopped legacy
// conversations. It still proves process/lease termination and ownership;
// offering Retry does not certify prior action outcomes or resume them.
// Keep this set aligned with conversation-continuation.ts on the server.
if (
run.runtimeMode === "legacy" &&
(run.status === "failed" || run.status === "timed_out") &&
run.execution?.phase === "recovery_needed" &&
run.execution.cause === "legacy_execution_requires_reconciliation" &&
[
"claude_local", "codex_local", "cursor", "gemini_local", "opencode_local",
"pi_local", "grok_local", "kimi_local", "hermes_local",
].includes(run.adapterType ?? "")
) return false;
return [
"working",
"retry_scheduled",
"reconnecting",
"finishing",
"queued",
"recovery_needed",
].includes(run.execution?.phase ?? "");
})
? undefined
: onRetryFailedRun;
const canRetryFailedRun = Boolean(retryFailedRunHandler);
const queryClient = useQueryClient();
const createdProjectItems = useProjectCreatedItems(props.creationActivity ?? [], companyId);
const [pendingComposerAssignee, setPendingComposerAssignee] = useState<
@@ -1397,6 +1429,12 @@ export function TaskChatThread(props: TaskChatThreadProps) {
if (liveRun && source.id === liveRun.id) continue;
const entries = transcriptByRun.get(source.id) ?? [];
const meta = linkedRunMetaById.get(source.id);
// A workspace admission attempt never started provider work. Its live
// successor owns the waiting indicator; retain this attempt in the run log.
if (source.status === "cancelled" && meta?.errorCode === "workspace_busy") {
settledRunIds.add(source.id);
continue;
}
// /new is represented by its durable comment boundary, not an empty
// model response or a completed-run notice.
if (meta?.resultJson?.conversationReset === true) { settledRunIds.add(source.id); continue; }
@@ -1632,7 +1670,9 @@ export function TaskChatThread(props: TaskChatThreadProps) {
const code = meta?.errorCode ?? "native_runner_process_exited";
const retryDetail = meta?.scheduledRetryAt
? "Retry scheduled automatically."
: "You can retry this message now.";
: canRetryFailedRun
? "You can retry this message now."
: "Your message is preserved.";
const aiRequest = interactions?.find((interaction) => interaction.kind === "connection_intent" && interaction.payload.purpose === "ai" && interaction.sourceRunId === source.id);
const detail = aiRequest
? aiRequest.status === "pending"
@@ -1655,6 +1695,7 @@ export function TaskChatThread(props: TaskChatThreadProps) {
kind: "marker",
variant: "interrupted",
label: source.status === "cancelled" ? (meta?.startedAt ? "Stopped" : "Couldn't start") : "Run failed",
runId: source.status === "cancelled" ? undefined : source.id,
tone: source.status === "cancelled" ? "neutral" : "error",
detail,
},
@@ -1975,6 +2016,7 @@ export function TaskChatThread(props: TaskChatThreadProps) {
};
}, [
orderedEntries,
canRetryFailedRun,
interactions,
runs,
liveRun,
@@ -2782,28 +2824,7 @@ export function TaskChatThread(props: TaskChatThreadProps) {
tryAgainNoLiveExecutionPathPending={
tryAgainNoLiveExecutionPathPending
}
onRetryFailedRun={
isTerminalIssueStatus(issueStatus) ||
interactions?.some(
(interaction) => interaction.status === "pending",
) ||
requiresExecutionReconciliation(
props.recoveryAction?.cause,
) ||
props.scheduledRetry ||
linkedRuns?.some((run) =>
[
"working",
"retry_scheduled",
"reconnecting",
"finishing",
"queued",
"recovery_needed",
].includes(run.execution?.phase ?? ""),
)
? undefined
: onRetryFailedRun
}
onRetryFailedRun={retryFailedRunHandler}
retryFailedRunId={retryFailedRunId}
tail={
tailRunId ||
@@ -2969,6 +2990,10 @@ export function TaskChatThread(props: TaskChatThreadProps) {
<div className="relative z-10">
<TaskChatComposer
onAdd={handleThreadAdd}
confirmedSubmissionIds={new Set(comments.filter((comment) =>
comment.authorUserId === currentUserId && comment.clientRequestId &&
!("clientStatus" in comment && comment.clientStatus)
).map((comment) => comment.clientRequestId!))}
onReviewConversation={onReviewConversation}
onStop={liveRun ? onCancelRun : undefined}
stopPending={stopPending}
@@ -2643,7 +2643,11 @@ export function IssueProperties({
</PropertyRow>
)}
{showScheduledRetryRow && scheduledRetryContent ? (
{showScheduledRetryRow && scheduledRetry?.scheduledRetryReason === "workspace_busy" ? (
<PropertyRow label="Workspace">
<span className="text-sm text-muted-foreground">Waiting for workspace</span>
</PropertyRow>
) : showScheduledRetryRow && scheduledRetryContent ? (
<PropertyPicker
inline={inline}
label="Scheduled retry"
@@ -290,6 +290,108 @@ function autocompleteOption(matchText: string) {
}
describe("TaskChatComposer", () => {
it("settles an acknowledged submission after navigating away", async () => {
const key = "navigate-before-save";
let resolveSend!: () => void;
const onAdd = vi.fn().mockReturnValue(new Promise<void>(resolve => { resolveSend = resolve; }));
render(<TaskChatComposer onAdd={onAdd} workMode="standard" draftKey={key} />);
typeText("Please write a motto");
pressKey("Enter", { metaKey: true });
await flushAsync();
const intent = loadDraftSubmission(key)!;
expect(onAdd.mock.calls[0]?.[4]).toBe(intent.attemptId);
render(<div>Another task</div>);
resolveSend();
await flushAsync();
expect(loadDraftSubmission(key)).toBeNull();
expect(localStorage.getItem(key)).toBeNull();
render(<TaskChatComposer onAdd={onAdd} workMode="standard" draftKey={key} />);
expect(container.textContent).not.toContain("couldn’t confirm");
expect(editable().textContent).toBe("");
});
it("automatically reconciles a restored submission by its server receipt, not its text", async () => {
const key = "saved-receipt";
const attemptId = "9af8228f-0be7-45ae-a104-6fbe0af6f1d3";
saveDraft(key, "Already answered");
saveDraftSubmission(key, { attemptId, reviewed: false });
const onAdd = vi.fn();
render(<TaskChatComposer onAdd={onAdd} workMode="standard" draftKey={key}
confirmedSubmissionIds={new Set(["another-request"])} />);
expect(loadDraftSubmission(key)).not.toBeNull();
render(<TaskChatComposer onAdd={onAdd} workMode="standard" draftKey={key}
confirmedSubmissionIds={new Set([attemptId])} />);
await flushAsync();
expect(loadDraftSubmission(key)).toBeNull();
expect(editable().textContent).toBe("");
expect(onAdd).not.toHaveBeenCalled();
});
it("preserves the next draft when an uncertain submission is later confirmed after reload", async () => {
const key = "uncertain-with-next-draft";
let rejectSend!: (error: Error) => void;
const onAdd = vi.fn().mockReturnValue(new Promise<void>((_resolve, reject) => { rejectSend = reject; }));
render(<TaskChatComposer onAdd={onAdd} workMode="standard" draftKey={key} />);
typeText("First request");
pressKey("Enter", { metaKey: true });
await flushAsync();
const attemptId = loadDraftSubmission(key)!.attemptId;
typeText("Keep this next draft");
rejectSend(new CommentSubmissionUnknownError());
await flushAsync();
render(<div>Another task</div>);
render(<TaskChatComposer onAdd={onAdd} workMode="standard" draftKey={key}
confirmedSubmissionIds={new Set([attemptId])} />);
await flushAsync();
expect(editable().textContent).toBe("Keep this next draft");
expect(localStorage.getItem(key)).toBe("Keep this next draft");
expect(loadDraftSubmission(key)).toBeNull();
expect(onAdd).toHaveBeenCalledTimes(1);
});
it.each(["acknowledgment", "receipt after reload"])("preserves a next draft across navigation before %s", async (confirmation) => {
const key = "navigate-with-next-draft";
let resolveSend!: () => void;
const onAdd = vi.fn().mockReturnValue(new Promise<void>(resolve => { resolveSend = resolve; }));
render(<TaskChatComposer onAdd={onAdd} workMode="standard" draftKey={key} />);
typeText("First request");
pressKey("Enter", { metaKey: true });
await flushAsync();
const attemptId = loadDraftSubmission(key)!.attemptId;
typeText("Keep the unsent next request");
render(<div>Another task</div>);
if (confirmation === "acknowledgment") {
resolveSend();
await flushAsync();
}
render(<TaskChatComposer onAdd={onAdd} workMode="standard" draftKey={key}
confirmedSubmissionIds={new Set([attemptId])} />);
await flushAsync();
expect(editable().textContent).toBe("Keep the unsent next request");
expect(localStorage.getItem(key)).toBe("Keep the unsent next request");
expect(loadDraftSubmission(key)).toBeNull();
expect(onAdd).toHaveBeenCalledTimes(1);
resolveSend();
await flushAsync();
expect(localStorage.getItem(key)).toBe("Keep the unsent next request");
});
it("does not copy another task's draft into a late acknowledged submission", async () => {
let resolveSend!: () => void;
const onAdd = vi.fn().mockReturnValue(new Promise<void>(resolve => { resolveSend = resolve; }));
render(<TaskChatComposer onAdd={onAdd} workMode="standard" draftKey="first-task" />);
typeText("First request");
pressKey("Enter", { metaKey: true });
await flushAsync();
render(<TaskChatComposer onAdd={onAdd} workMode="standard" draftKey="second-task" />);
typeText("Second task draft");
resolveSend();
await flushAsync();
expect(loadDraftSubmission("first-task")).toBeNull();
expect(localStorage.getItem("first-task")).toBeNull();
expect(editable().textContent).toBe("Second task draft");
});
it.each(["success", "unknown"])(
"does not overwrite another retained attempt after an older %s",
async (outcome) => {
@@ -497,7 +599,7 @@ describe("TaskChatComposer", () => {
);
flushSync(() => sendButton().click());
await flushAsync();
expect(onAdd.mock.calls[0]).toHaveLength(3);
expect(onAdd.mock.calls[0]?.[3]).toBeUndefined();
});
it("submits multiple retained file and image receipts but not removed selections", async () => {
@@ -632,7 +734,7 @@ describe("TaskChatComposer", () => {
expect(localStorage.getItem("removed-pending:attachments:v1")).toBeNull();
flushSync(() => sendButton().click());
await flushAsync();
expect(onAdd.mock.calls[0]).toHaveLength(3);
expect(onAdd.mock.calls[0]?.[3]).toBeUndefined();
});
it("adds 10px to the composer's original 8px interior padding", () => {
render(<TaskChatComposer onAdd={async () => {}} workMode="standard" />);
@@ -724,7 +826,7 @@ describe("TaskChatComposer", () => {
await flushAsync();
await flushAsync();
expect(onAdd).toHaveBeenCalledWith("hello there", undefined, undefined);
expect(onAdd).toHaveBeenCalledWith("hello there", undefined, undefined, undefined, expect.any(String));
expect(editable().textContent).toBe("");
});
@@ -736,7 +838,7 @@ describe("TaskChatComposer", () => {
pressKey("Enter", { ctrlKey: true });
await flushAsync();
expect(onAdd).toHaveBeenCalledWith("hello", undefined, undefined);
expect(onAdd).toHaveBeenCalledWith("hello", undefined, undefined, undefined, expect.any(String));
});
it("does not submit on plain Enter or Shift+Enter (newline stays with the editor)", async () => {
@@ -778,7 +880,7 @@ describe("TaskChatComposer", () => {
await flushAsync();
expect(onWorkModeChange).toHaveBeenCalledWith("planning");
expect(onAdd).toHaveBeenCalledWith("do the plan", undefined, undefined);
expect(onAdd).toHaveBeenCalledWith("do the plan", undefined, undefined, undefined, expect.any(String));
});
it("cycles Auto, Plan, and Ask modes with Cmd+Period while focused", () => {
@@ -872,7 +974,7 @@ describe("TaskChatComposer", () => {
pressKey("Enter", { metaKey: true });
await flushAsync();
expect(onAdd).toHaveBeenCalledWith("wake up", true, undefined);
expect(onAdd).toHaveBeenCalledWith("wake up", true, undefined, undefined, expect.any(String));
});
it("hides the attach button without an upload handler and shows it with one", () => {
@@ -965,11 +1067,7 @@ describe("TaskChatComposer", () => {
expect(send.disabled).toBe(false);
flushSync(() => send.click());
await flushAsync();
expect(onAdd).toHaveBeenCalledWith(
"[notes.txt](/attachments/notes.txt)",
undefined,
undefined,
);
expect(onAdd).toHaveBeenCalledWith("[notes.txt](/attachments/notes.txt)", undefined, undefined, undefined, expect.any(String));
// Chips clear once the message posts.
expect(
container.querySelector('[data-testid="task-chat-composer-attachments"]'),
@@ -999,11 +1097,7 @@ describe("TaskChatComposer", () => {
)!;
flushSync(() => send.click());
await flushAsync();
expect(onAdd).toHaveBeenCalledWith(
"Please review this.\n\n[notes.txt](/attachments/notes.txt)",
undefined,
undefined,
);
expect(onAdd).toHaveBeenCalledWith("Please review this.\n\n[notes.txt](/attachments/notes.txt)", undefined, undefined, undefined, expect.any(String));
});
it("blocks send while a file upload is pending, then includes the file once it lands", async () => {
@@ -1046,11 +1140,7 @@ describe("TaskChatComposer", () => {
expect(sendButton().disabled).toBe(false);
flushSync(() => sendButton().click());
await flushAsync();
expect(onAdd).toHaveBeenCalledWith(
"Here is the file.\n\n[notes.txt](/attachments/notes.txt)",
undefined,
undefined,
);
expect(onAdd).toHaveBeenCalledWith("Here is the file.\n\n[notes.txt](/attachments/notes.txt)", undefined, undefined, undefined, expect.any(String));
});
it("blocks send while a failed attachment chip remains, then sends after it is removed", async () => {
@@ -1082,11 +1172,7 @@ describe("TaskChatComposer", () => {
expect(sendButton().disabled).toBe(false);
flushSync(() => sendButton().click());
await flushAsync();
expect(onAdd).toHaveBeenCalledWith(
"Here is the file.",
undefined,
undefined,
);
expect(onAdd).toHaveBeenCalledWith("Here is the file.", undefined, undefined, undefined, expect.any(String));
});
it("removes an attachment chip via its remove button", async () => {
@@ -1196,7 +1282,7 @@ describe("TaskChatComposer", () => {
pressKey("Enter", { metaKey: true });
await flushAsync();
expect(onAdd).toHaveBeenCalledWith(expected.trim(), undefined, undefined);
expect(onAdd).toHaveBeenCalledWith(expected.trim(), undefined, undefined, undefined, expect.any(String));
});
it("inserts a /-command from the autocomplete menu", async () => {
@@ -1596,11 +1682,7 @@ describe("TaskChatComposer", () => {
pressKey("Enter", { metaKey: true });
await flushAsync();
expect(onAdd).toHaveBeenCalledWith(
"queued message",
undefined,
undefined,
);
expect(onAdd).toHaveBeenCalledWith("queued message", undefined, undefined, undefined, expect.any(String));
expect(editable().textContent).toBe("");
expect(localStorage.getItem(draftKey)).toBe("queued message");
expect(localStorage.getItem(`${draftKey}:submission:v1`)).toContain(
@@ -1631,7 +1713,7 @@ describe("TaskChatComposer", () => {
await flushAsync();
await flushAsync();
expect(onAdd).toHaveBeenCalledWith("first message", undefined, undefined);
expect(onAdd).toHaveBeenCalledWith("first message", undefined, undefined, undefined, expect.any(String));
expect(editable().textContent).toBe("next message");
expect(localStorage.getItem(draftKey)).toBe("next message");
});
@@ -1665,7 +1747,7 @@ describe("TaskChatComposer", () => {
await flushAsync();
await flushAsync();
expect(onAdd).toHaveBeenCalledWith("first message", undefined, undefined);
expect(onAdd).toHaveBeenCalledWith("first message", undefined, undefined, undefined, expect.any(String));
expect(
container.querySelector(
'[data-testid="task-chat-composer-attachments"]',
@@ -1757,7 +1839,7 @@ describe("TaskChatComposer", () => {
act(() => root!.render(<TaskChatComposer {...props} />));
expect(editable().textContent).toBe("Please check mobile too.");
await act(async () => sendButton().click());
expect(onAdd).toHaveBeenCalledWith("Please check mobile too.", undefined, undefined);
expect(onAdd).toHaveBeenCalledWith("Please check mobile too.", undefined, undefined, undefined, expect.any(String));
});
it("takes precedence over pending questions and queued-message edits", () => {
@@ -2608,7 +2690,7 @@ describe("composer Stop", () => {
const onStop = vi.fn(async () => {});
const onAdd = vi.fn(async () => {});
render(<TaskChatComposer workMode="standard" onAdd={onAdd} onStop={onStop} stopScope="subtree" />);
expect(stopButton()?.title).toBe("Stop and pause subtree");
expect(stopButton()?.title).toBe("Stop response");
pressKey("Enter", { metaKey: true });
expect(onStop).not.toHaveBeenCalled();
expect(onAdd).not.toHaveBeenCalled();
@@ -2617,7 +2699,7 @@ describe("composer Stop", () => {
expect(sendButton().disabled).toBe(false);
flushSync(() => sendButton().click());
await flushAsync();
expect(onAdd).toHaveBeenCalledWith("Check mobile too.", undefined, undefined);
expect(onAdd).toHaveBeenCalledWith("Check mobile too.", undefined, undefined, undefined, expect.any(String));
expect(onStop).not.toHaveBeenCalled();
typeText(" \n ");
expect(stopButton()?.disabled).toBe(false);
@@ -20,6 +20,7 @@ import {
loadDraftSubmission,
saveDraftSubmission,
clearDraftSubmission,
settleDraftSubmission,
type ComposerDraftSubmission,
} from "@/lib/composer-draft";
import { CommentSubmissionUnknownError } from "@/lib/comment-submit-result";
@@ -104,6 +105,7 @@ interface TaskChatComposerProps {
attachmentIds?: string[],
clientRequestId?: string,
) => Promise<void> | void;
confirmedSubmissionIds?: ReadonlySet<string>;
onStop?: () => Promise<void>;
stopPending?: boolean;
stopScope?: "leaf" | "subtree";
@@ -381,9 +383,9 @@ function escapeMarkdownLabel(name: string): string {
*/
export function TaskChatComposer({
onAdd,
confirmedSubmissionIds,
onStop,
stopPending = false,
stopScope = "leaf",
workMode,
onWorkModeChange,
disabled = false,
@@ -460,6 +462,12 @@ export function TaskChatComposer({
typeof update === "function" ? update(attachmentsRef.current) : update;
attachmentsRef.current = next;
setAttachmentState(next);
const pending = pendingDraftRef.current;
if (pending && pending.draftKey === draftKey) {
saveDraftAttachments(pending.draftKey, next
.filter(item => item.status === "attached" && item.attachmentId)
.map(item => ({ ...item, inline: item.inline === true })), pending.attemptId);
}
}
const submittingRef = useRef(submitting);
submittingRef.current = submitting;
@@ -469,6 +477,29 @@ export function TaskChatComposer({
const editorRef = useRef<MarkdownEditorRef>(null);
const bodyRef = useRef(body);
bodyRef.current = body;
const pendingDraftRef = useRef<{
draftKey: string;
attemptId: string;
submittedBody: string;
submittedAttachmentIds: string[];
} | null>(null);
function changeBody(value: string) {
bodyRef.current = value;
setBody(value);
const pending = pendingDraftRef.current;
if (!pending || pending.draftKey !== draftKey ||
loadDraftSubmission(pending.draftKey)?.attemptId !== pending.attemptId) return;
// Keep text typed during delivery durable too. Navigation may happen before
// either the request promise or the matching live server receipt arrives.
saveDraft(pending.draftKey,
value ? `${pending.submittedBody}\n\n${value}` : pending.submittedBody,
pending.attemptId);
saveDraftSubmission(pending.draftKey, {
attemptId: pending.attemptId, reviewed: false,
nextDraftOffset: pending.submittedBody.length + (value ? 2 : 0),
submittedAttachmentIds: pending.submittedAttachmentIds,
});
}
const draftTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const queuedEditRef = useRef(queuedEdit);
queuedEditRef.current = queuedEdit;
@@ -971,20 +1002,16 @@ export function TaskChatComposer({
.map((item) => item.attachmentId!),
),
];
if (conversationMode)
await onAdd(fullBody, reopen, reassignment, attachmentIds.length ? attachmentIds : undefined, attemptId);
else if (attachmentIds.length > 0)
await onAdd(fullBody, reopen, reassignment, attachmentIds);
else await onAdd(fullBody, reopen, reassignment);
if (mountedTaskKey.current !== draftKey) return;
if (draftKey) clearDraftSubmission(draftKey, attemptId);
if (draftKey && bodyRef.current) {
// The editor stays writable while the request is pending. Preserve
// text entered after this submission started as the next draft.
saveDraft(draftKey, bodyRef.current);
} else if (draftKey) {
clearDraft(draftKey);
if (draftKey) {
pendingDraftRef.current = { draftKey, attemptId, submittedBody, submittedAttachmentIds: attachmentIds };
changeBody(bodyRef.current);
}
await onAdd(fullBody, reopen, reassignment, attachmentIds.length ? attachmentIds : undefined, attemptId);
// Navigation does not invalidate the server receipt. Settle the captured
// task before checking whether this composer is still on screen.
if (draftKey) settleDraftSubmission(draftKey, attemptId,
mountedTaskKey.current === draftKey ? bodyRef.current : undefined);
if (mountedTaskKey.current !== draftKey) return;
const submittedIds = new Set(submittedAttachments.map((item) => item.id));
setAttachments((current) =>
current.filter((item) => !submittedIds.has(item.id)),
@@ -994,8 +1021,13 @@ export function TaskChatComposer({
}
} catch (error) {
if (mountedTaskKey.current !== draftKey) return;
const nextDraft = bodyRef.current;
if (attemptId && error instanceof CommentSubmissionUnknownError) {
const uncertain = { attemptId, reviewed: false };
const uncertain = {
attemptId, reviewed: false,
nextDraftOffset: submittedBody.length + (nextDraft ? 2 : 0),
submittedAttachmentIds: submittedAttachments.flatMap(item => item.attachmentId ? [item.attachmentId] : []),
};
setUncertainSubmission(uncertain);
if (draftKey && loadDraftSubmission(draftKey)?.attemptId === attemptId)
saveDraftSubmission(draftKey, uncertain);
@@ -1003,7 +1035,6 @@ export function TaskChatComposer({
clearDraftSubmission(draftKey, attemptId);
// Restore the failed message for retry without discarding a next draft
// that was entered while the request was pending.
const nextDraft = bodyRef.current;
const restoredBody = nextDraft
? `${submittedBody}\n\n${nextDraft}`
: submittedBody;
@@ -1015,10 +1046,25 @@ export function TaskChatComposer({
if (draftKey) saveDraft(draftKey, restoredBody, attemptId ?? undefined);
setBody(restoredBody);
} finally {
if (pendingDraftRef.current?.attemptId === attemptId) pendingDraftRef.current = null;
setSubmitting(false);
}
}
useEffect(() => {
if (!uncertainSubmission || !confirmedSubmissionIds?.has(uncertainSubmission.attemptId)) return;
const nextDraft = uncertainSubmission.nextDraftOffset === undefined
? "" : bodyRef.current.slice(uncertainSubmission.nextDraftOffset);
if (draftKey) settleDraftSubmission(draftKey, uncertainSubmission.attemptId, nextDraft);
setUncertainSubmission(null);
bodyRef.current = nextDraft;
setBody(nextDraft);
const submittedIds = uncertainSubmission.submittedAttachmentIds;
setAttachments(current => submittedIds
? current.filter(item => !item.attachmentId || !submittedIds.includes(item.attachmentId))
: []);
}, [confirmedSubmissionIds, draftKey, uncertainSubmission]);
async function reviewUncertainSubmission() {
if (!uncertainSubmission) return;
setReviewError(false);
@@ -1265,7 +1311,7 @@ export function TaskChatComposer({
<MarkdownEditor
ref={editorRef}
value={body}
onChange={setBody}
onChange={changeBody}
placeholder={
disabled
? (disabledReason ?? "Composer disabled")
@@ -1537,9 +1583,7 @@ export function TaskChatComposer({
showStop
? stopControl.stopping
? "Stopping…"
: stopScope === "subtree"
? "Stop and pause subtree"
: "Stop and pause task"
: "Stop response"
: queuedEdit
? queuedEdit.stale
? "Queue as new message"
@@ -16,6 +16,17 @@ import { __liveUpdatesTestUtils } from "./LiveUpdatesProvider";
import { queryKeys } from "../lib/queryKeys";
describe("LiveUpdatesProvider issue invalidation", () => {
it.each(["issue.attachment_added", "issue.attachment_removed", "issue.work_product_created", "issue.work_product_updated"])("refreshes visible delivered files for %s", action => {
const client = new QueryClient();
client.setQueryData(queryKeys.issues.detail("issue-1"), { id: "issue-1", companyId: "company-1", identifier: "PAP-1" });
const invalidate = vi.spyOn(client, "invalidateQueries");
__liveUpdatesTestUtils.invalidateActivityQueries(client, "company-1", {
entityType: "issue", entityId: "issue-1", actorType: "agent", actorId: "agent-1", action,
}, { userId: "user-1", agentId: null }, { pathname: "/PAP/issues/PAP-1", isForegrounded: true });
expect(invalidate).toHaveBeenCalledWith({ queryKey: queryKeys.issues.attachments("issue-1") });
expect(invalidate).toHaveBeenCalledWith({ queryKey: queryKeys.issues.workProducts("issue-1") });
client.clear();
});
it("connects trusted local boards without admitting signed-out authenticated users", () => {
const canConnect = __liveUpdatesTestUtils.canUseLiveSession;
expect(canConnect("success", false, "local_trusted")).toBe(true);
+10
View File
@@ -527,6 +527,8 @@ function invalidateVisibleIssueRunQueries(
// A final comment can race the last in-flight history fetch. Reconcile
// persisted messages after the turn settles.
queryClient.invalidateQueries({ queryKey: queryKeys.issues.comments(issueRef) });
queryClient.invalidateQueries({ queryKey: queryKeys.issues.attachments(issueRef) });
queryClient.invalidateQueries({ queryKey: queryKeys.issues.workProducts(issueRef) });
queryClient.invalidateQueries({ queryKey: ["issues", "tree-control-state", issueRef] });
}
}
@@ -1075,6 +1077,8 @@ function buildRunStatusToast(
// Interrupt is an intentional conversation control. Its caller gives
// feedback; the terminal event must not announce a cancelled/failed run.
if (errorCode === "operator_interrupted") return null;
// Workspace contention is ordinary scheduling, not a failed user action.
if (errorCode === "workspace_busy") return null;
const contextSource = readString(payload.contextSource);
const triggerDetail = readString(payload.triggerDetail);
const name = nameOf(agentId) ?? "Agent";
@@ -1342,6 +1346,12 @@ function invalidateActivityQueries(
if (action === "issue.comment_added" || action === "issue.conversation_session_started") {
queryClient.invalidateQueries({ queryKey: queryKeys.issues.comments(ref), ...invalidationOptions });
}
if (action?.startsWith("issue.attachment_") || action?.startsWith("issue.work_product_")) {
// These cards are durable API objects, not streamed text. Refresh the
// visible task too, including attachments bound to an existing comment.
queryClient.invalidateQueries({ queryKey: queryKeys.issues.attachments(ref) });
queryClient.invalidateQueries({ queryKey: queryKeys.issues.workProducts(ref) });
}
if (action === "issue.conversation_session_started") {
queryClient.invalidateQueries({ queryKey: ["issues", "tree-control-state", ref] });
queryClient.invalidateQueries({ queryKey: queryKeys.issues.interactions(ref) });
+16
View File
@@ -9,6 +9,7 @@ import {
loadDraftSubmission,
saveDraftSubmission,
clearDraftSubmission,
settleDraftSubmission,
} from "./composer-draft";
describe("task draft upload receipts", () => {
@@ -22,6 +23,21 @@ describe("task draft upload receipts", () => {
contentPath: `/api/attachments/${id}/content`,
};
beforeEach(() => localStorage.clear());
it("settles only submitted text and attachments while preserving the next draft", () => {
const nextId = "aaf8228f-0be7-45ae-a104-6fbe0af6f1d3";
const nextReceipt = { ...receipt, attachmentId: nextId, contentPath: `/api/attachments/${nextId}/content` };
saveDraft(key, "Sent\n\nNext");
saveDraftAttachments(key, [receipt]);
saveDraftSubmission(key, { attemptId: id, reviewed: false, nextDraftOffset: 6, submittedAttachmentIds: [id] });
saveDraftAttachments(key, [receipt, nextReceipt], nextId);
expect(loadDraftAttachments(key)).toEqual([receipt]);
saveDraftAttachments(key, [receipt, nextReceipt], id);
expect(settleDraftSubmission(key, nextId)).toBe(false);
expect(settleDraftSubmission(key, id)).toBe(true);
expect(loadDraft(key)).toBe("Next");
expect(loadDraftAttachments(key)).toEqual([nextReceipt]);
expect(loadDraftSubmission(key)).toBeNull();
});
it("keeps chat drafts and pending submission fences within the current tab", () => {
sessionStorage.clear();
const chatKey = "paperclip:agent-chat-draft:company:user:agent";
+32 -10
View File
@@ -57,24 +57,32 @@ export function clearDraft(draftKey: string, attemptId?: string) {
export interface ComposerDraftSubmission {
attemptId: string;
reviewed: boolean;
/** Start of text typed after the submitted body in a restored uncertain draft. */
nextDraftOffset?: number;
submittedAttachmentIds?: string[];
}
/** Local uncertainty fence, not a server idempotency key or proof of delivery.
* Any retained in-flight intent is uncertain after a reload. */
/** Retained client request ID. It is not delivery proof until a matching
* server receipt is observed; use the same ID for submission and reconciliation. */
export function loadDraftSubmission(
draftKey: string,
): ComposerDraftSubmission | null {
try {
const raw = draftStorage(draftKey).getItem(`${draftKey}:submission:v1`);
if (!raw || raw.length > 2_048) return null;
if (!raw || raw.length > 16_384) return null;
const record = JSON.parse(raw);
return record?.version === 1 &&
record.draftKey === draftKey &&
Object.keys(record).length === 4 &&
Object.keys(record).every((key) => ["version", "draftKey", "attemptId", "reviewed", "nextDraftOffset", "submittedAttachmentIds"].includes(key)) &&
typeof record.attemptId === "string" &&
/^[0-9a-f]{8}-(?:[0-9a-f]{4}-){3}[0-9a-f]{12}$/i.test(record.attemptId) &&
typeof record.reviewed === "boolean"
? { attemptId: record.attemptId, reviewed: record.reviewed }
typeof record.reviewed === "boolean" &&
(record.nextDraftOffset === undefined || (Number.isSafeInteger(record.nextDraftOffset) && record.nextDraftOffset >= 0)) &&
(record.submittedAttachmentIds === undefined || (Array.isArray(record.submittedAttachmentIds) && record.submittedAttachmentIds.length <= 256 && record.submittedAttachmentIds.every((id: unknown) => typeof id === "string" && id.length <= 128)))
? { attemptId: record.attemptId, reviewed: record.reviewed,
...(record.nextDraftOffset !== undefined ? { nextDraftOffset: record.nextDraftOffset } : {}),
...(record.submittedAttachmentIds !== undefined ? { submittedAttachmentIds: record.submittedAttachmentIds } : {}),
}
: null;
} catch {
return null;
@@ -107,6 +115,20 @@ export function clearDraftSubmission(draftKey: string, attemptId: string) {
}
}
/** A late response can settle only its own retained intent, never a newer draft. */
export function settleDraftSubmission(draftKey: string, attemptId: string, nextDraft?: string): boolean {
const submission = loadDraftSubmission(draftKey);
if (submission?.attemptId !== attemptId) return false;
nextDraft ??= submission.nextDraftOffset === undefined
? "" : loadDraft(draftKey).slice(submission.nextDraftOffset);
const nextAttachments = submission.submittedAttachmentIds === undefined ? []
: loadDraftAttachments(draftKey).filter(item => !submission.submittedAttachmentIds!.includes(item.attachmentId));
clearDraft(draftKey, attemptId);
if (nextDraft) saveDraft(draftKey, nextDraft);
if (nextAttachments.length) saveDraftAttachments(draftKey, nextAttachments);
return true;
}
export interface ComposerDraftAttachment {
attachmentId: string;
name: string;
@@ -178,11 +200,11 @@ export function loadDraftAttachments(
}
}
export function saveDraftAttachments(draftKey: string, attachments: unknown) {
export function saveDraftAttachments(draftKey: string, attachments: unknown, attemptId?: string) {
try {
// In-flight/unknown receipts were saved before the intent. Generic effects
// (including a stale composer's cleanup) must not rewrite that snapshot.
if (!mayWriteDraft(draftKey)) return;
// Only the owning pending request may persist newly uploaded receipts.
// Generic effects and stale composer cleanups must not rewrite its snapshot.
if (!mayWriteDraft(draftKey, attemptId)) return;
const selected = draftAttachments(attachments);
if (selected.length)
draftStorage(draftKey).setItem(
+2 -2
View File
@@ -68,7 +68,7 @@ describe("stop confirmation", () => {
timeoutMs: 1000,
});
const assertion = expect(result).rejects.toThrow(
"pause was saved, but work is still stopping",
"stop was requested, but work is still stopping",
);
await vi.advanceTimersByTimeAsync(1000);
await assertion;
@@ -78,7 +78,7 @@ describe("stop confirmation", () => {
waitForStoppedRuns(["active"], {
getRun: vi.fn().mockRejectedValue(new Error("offline")),
}),
).rejects.toThrow("pause was saved, but stopping could not be verified");
).rejects.toThrow("stop was requested, but stopping could not be verified");
});
});
+2 -2
View File
@@ -29,7 +29,7 @@ export async function waitForStoppedRuns(
]);
} catch {
throw new Error(
"The pause was saved, but stopping could not be verified. Refresh and try Stop again if work is still running.",
"The stop was requested, but stopping could not be verified. Refresh and try Stop again if work is still running.",
);
} finally {
clearTimeout(timeout);
@@ -54,7 +54,7 @@ export async function waitForStoppedRuns(
if (remaining.length === 0) return;
if (Date.now() >= deadline) {
throw new Error(
"The pause was saved, but work is still stopping. Try Stop again if it continues.",
"The stop was requested, but work is still stopping. Try Stop again if it continues.",
);
}
await new Promise((resolve) =>
+12 -2
View File
@@ -84,6 +84,7 @@ const mockActivityApi = vi.hoisted(() => ({
}));
const mockHeartbeatsApi = vi.hoisted(() => ({
get: vi.fn(),
liveRunsForIssue: vi.fn(),
activeRunForIssue: vi.fn(),
cancel: vi.fn(),
@@ -4571,8 +4572,11 @@ describe("IssueDetail", () => {
});
it.each(["active-run", "composer"])(
"routes %s Stop and the menu through the same pause operation",
"keeps %s run controls distinct from pausing future work",
async (control) => {
mockIssuesApi.createTreeHold.mockClear();
mockHeartbeatsApi.cancel.mockClear();
mockHeartbeatsApi.get.mockReset();
const pausePreview = createPausePreview();
pausePreview.totals = {
...pausePreview.totals,
@@ -4614,6 +4618,7 @@ describe("IssueDetail", () => {
adapterType: "process",
},
]);
mockHeartbeatsApi.get.mockResolvedValue({ id: "run-active-1", status: "cancelled", runtimeMode: "legacy" });
mockAuthApi.getSession.mockResolvedValue({
session: { userId: "user-1" },
user: { id: "user-1" },
@@ -4650,7 +4655,11 @@ describe("IssueDetail", () => {
});
await flushReact();
expect(mockIssuesApi.createTreeHold).toHaveBeenCalledWith("PAP-1", {
if (control === "composer") {
expect(mockHeartbeatsApi.cancel).toHaveBeenCalledWith("run-active-1");
expect(mockHeartbeatsApi.get).toHaveBeenCalledWith("run-active-1");
expect(mockIssuesApi.createTreeHold).not.toHaveBeenCalled();
} else expect(mockIssuesApi.createTreeHold).toHaveBeenCalledWith("PAP-1", {
mode: "pause",
reason: null,
releasePolicy: { strategy: "manual", note: "leaf_pause" },
@@ -4699,6 +4708,7 @@ describe("IssueDetail", () => {
);
it("routes live-run finalization actions through run cancellation before issue status update", async () => {
mockHeartbeatsApi.cancel.mockClear();
mockIssuesApi.get.mockResolvedValue(
createIssue({
status: "in_progress",
+25 -6
View File
@@ -1281,6 +1281,8 @@ type IssueDetailChatTabProps = {
onInterruptQueued: (runId: string | null) => Promise<void>;
onDeleteComment?: (commentId: string) => Promise<void> | void;
onPauseWorkRun?: (runId: string, feedback?: "composer") => Promise<void>;
onStopResponse?: (runId: string) => Promise<void>;
stopResponsePending?: boolean;
pauseWorkPending?: boolean;
pauseWorkScope?: "leaf" | "subtree";
runFinalizationActions?: readonly IssueChatRunFinalizationAction[];
@@ -1395,6 +1397,8 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({
onInterruptQueued,
onDeleteComment,
onPauseWorkRun,
onStopResponse,
stopResponsePending,
pauseWorkPending,
pauseWorkScope,
runFinalizationActions,
@@ -2442,13 +2446,10 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({
onSubmitInteractionVerdicts={onSubmitInteractionVerdicts}
issueWorkMode={issueWorkMode}
onWorkModeChange={onWorkModeChange}
stopPending={pauseWorkPending}
stopScope={pauseWorkScope}
stopPending={stopResponsePending}
onCancelRun={
interruptibleIssueRun && onPauseWorkRun
? async () => {
await onPauseWorkRun(interruptibleIssueRun.id, "composer");
}
interruptibleIssueRun && onStopResponse
? () => onStopResponse(interruptibleIssueRun.id)
: undefined
}
onImageClick={onImageClick}
@@ -4175,6 +4176,16 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS
}
},
});
const stopResponse = useMutation({
mutationFn: async (runId: string) => {
await heartbeatsApi.cancel(runId);
await waitForStoppedRuns([runId]);
},
onSettled: () => Promise.all([
queryClient.invalidateQueries({ queryKey: queryKeys.issues.detail(issueId!) }),
queryClient.invalidateQueries({ queryKey: queryKeys.issues.runs(issueId!) }),
]),
});
const stopAndFinalizeRun = useMutation({
mutationFn: async ({
runId,
@@ -4772,15 +4783,18 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS
interrupt,
reassignment,
attachmentIds,
clientRequestId,
}: {
body: string;
reopen?: boolean;
interrupt?: boolean;
reassignment: CommentReassignment;
attachmentIds?: string[];
clientRequestId?: string;
}) =>
issuesApi.update(issueId!, {
comment: body,
commentClientRequestId: clientRequestId,
...(attachmentIds?.length ? { attachmentIds } : {}),
assigneeAgentId: reassignment.assigneeAgentId,
assigneeUserId: reassignment.assigneeUserId,
@@ -6125,6 +6139,7 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS
reopen,
reassignment,
attachmentIds,
clientRequestId,
});
return;
}
@@ -7781,6 +7796,10 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS
.mutateAsync({ commentId })
.then(() => undefined)
}
onStopResponse={canManageTreeControl
? (runId) => stopResponse.mutateAsync(runId)
: undefined}
stopResponsePending={stopResponse.isPending}
pauseWorkPending={
executeTreeControl.isPending &&
executeTreeControl.variables?.mode === "pause"
+25
View File
@@ -5,6 +5,7 @@ import type { ReactNode } from "react";
import { createRoot } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { getAppStoreDefinition } from "@paperclipai/shared";
import { AppDetail } from "./AppDetail";
import { APP_TABS } from "./app-tabs";
@@ -1172,6 +1173,30 @@ describe("AppDetail", () => {
expect(container.textContent).toContain("Which agents can use this connection?");
});
it.each(["permissions", "review"])("offers a supported replacement for an obsolete Anthropic connection on %s", async (tab) => {
mockParams.tab = tab;
listApplicationsMock.mockResolvedValue({ applications: [] });
listGalleryMock.mockResolvedValue({ apps: [getAppStoreDefinition("anthropic")!] });
getConnectionMock.mockResolvedValue(connection({
name: "Anthropic",
transport: "rest_api",
authKind: "api_key",
config: { sourceTemplateKey: "anthropic", connectionMethodKey: "api-key" },
healthStatus: "error",
healthMessage: "This connection has no supported tool integration.",
}));
await renderAppDetail();
expect(container.querySelector('input[type="password"]')).toBeNull();
expect(findButton("Check & reconnect")).toBeUndefined();
expect(findButton("Reconnect")).toBeUndefined();
expect(container.textContent).toContain("Connection no longer supported");
expect(container.textContent).toContain("then remove this connection");
expect(container.querySelector('a[href="/apps/connect?source=anthropic"]')?.textContent)
.toBe("Add supported connection");
});
it("offers retry for a transient GitHub error without asking for another login", async () => {
mockParams.tab = "permissions";
getConnectionMock.mockResolvedValue(connection({
+34 -10
View File
@@ -6,13 +6,18 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { CONNECTABLE_APP_DEFINITIONS, GOOGLE_WORKSPACE_CONNECTOR_PROFILES, getAppStoreDefinition } from "@paperclipai/shared";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { ApiError } from "@/api/client";
import { aiConnectionsApi } from "@/api/ai-connections";
import { queryKeys } from "@/lib/queryKeys";
import { ConnectionSetupFlow } from "@/features/connections/ConnectionSetupFlow";
import { AppsConnect } from "./AppsConnect";
const listGalleryMock = vi.hoisted(() => vi.fn());
const experimentalMock = vi.hoisted(() => vi.fn());
vi.mock("@/api/instanceSettings", () => ({ instanceSettingsApi: { getExperimental: experimentalMock } }));
vi.mock("@/api/instanceSettings", () => ({ instanceSettingsApi: {
getExperimental: experimentalMock,
get: async () => ({ defaultEnvironmentId: "local-env" }),
getGeneral: async () => ({}),
} }));
const listApplicationsMock = vi.hoisted(() => vi.fn());
const listConnectionsMock = vi.hoisted(() => vi.fn());
const getConnectionMock = vi.hoisted(() => vi.fn());
@@ -438,24 +443,43 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
// credential is entered.
// -------------------------------------------------------------------------
it("keeps the existing Anthropic tool method reachable alongside AI authentication", async () => {
it("offers supported Anthropic AI authentication without the obsolete REST tool method", async () => {
const createAiAccount = vi.spyOn(aiConnectionsApi, "create").mockResolvedValue({
connectionId: "anthropic-ai-account", grantId: "anthropic-ai-grant",
});
mockParams.appKey = "anthropic";
listGalleryMock.mockResolvedValue({ apps: [getAppStoreDefinition("anthropic")] });
await render();
const client = new QueryClient({ defaultOptions: { queries: {
retry: false,
staleTime: Infinity,
} } });
client.setQueryData(queryKeys.environments.list("company-1"), [
{ id: "local-env", name: "Local", driver: "local", status: "active", config: {} },
]);
client.setQueryData(queryKeys.environments.capabilities("company-1"), {});
client.setQueryData(queryKeys.instance.settings, { defaultEnvironmentId: "local-env" });
client.setQueryData(queryKeys.instance.generalSettings, {});
client.setQueryData(queryKeys.health, { deploymentMode: "authenticated", localAiLoginSupported: false });
await render(client);
await passAccessStep();
expect(container.textContent).toContain("How do you want to connect?");
expect(radioContaining("Claude subscription")).toBeTruthy();
expect(radioContaining("Claude API key")).toBeTruthy();
await act(async () => radioContaining("Use an API key")!.click());
expect(container.textContent).toContain("Connect account");
expect(container.textContent).toContain("Connection name");
expect(container.textContent).not.toContain("How do you want to connect?");
expect(radioContaining("Use an API key")).toBeUndefined();
expect(container.querySelector('[role="alert"]')).toBeNull();
await act(async () => buttonContaining("Use API key instead")!.click());
await act(async () => buttonContaining("Claude")!.click());
await flushReact();
const key = container.querySelector<HTMLInputElement>('input[type="password"]');
expect(key).toBeTruthy();
await act(async () => setInputValue(key!, "fixture-anthropic-tool-key"));
await act(async () => setInputValue(key!, "fixture-anthropic-ai-key"));
await act(async () => buttonByText("Connect")!.click());
await flushReact();
expect(connectAppMock).toHaveBeenCalledWith("company-1", expect.objectContaining({
galleryKey: "anthropic", connectionMethodKey: "api-key",
expect(createAiAccount).toHaveBeenCalledWith("company-1", expect.objectContaining({
provider: "anthropic", method: "api_key", apiKey: "fixture-anthropic-ai-key",
}));
expect(mockNavigate).toHaveBeenCalledWith("/apps/anthropic-ai-account/permissions");
expect(connectAppMock).not.toHaveBeenCalled();
expect(container.textContent).not.toContain("Connect for tool access instead");
});
@@ -3,6 +3,7 @@
import { flushSync } from "react-dom";
import { createRoot } from "react-dom/client";
import { afterEach, describe, expect, it, vi } from "vitest";
import { getAppStoreDefinition } from "@paperclipai/shared";
import { DangerZone } from "./AdvancedPanel";
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -50,6 +51,54 @@ function expandDangerZone(node: HTMLDivElement) {
* operator commits.
*/
describe("DangerZone", () => {
it("keeps removal available without reconnecting an obsolete Anthropic method", () => {
container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
const onRemove = vi.fn();
act(() => root.render(
<DangerZone
appName="Anthropic"
connection={{
id: "obsolete-anthropic",
uid: "obsolete-anthropic",
companyId: "company-1",
applicationId: "app-1",
name: "Anthropic",
connectionKind: "managed",
connectionPurpose: "tool",
ownership: "customer",
transport: "rest_api",
authKind: "api_key",
credentialSource: "paperclip_vault",
credentialPolicy: "shared",
transportConfig: {},
config: { sourceTemplateKey: "anthropic", connectionMethodKey: "api-key" },
credentialSecretRefs: [],
healthStatus: "error",
healthCheckedAt: null,
lastError: "This connection has no supported tool integration.",
enabled: true,
createdByAgentId: null,
createdByUserId: "user-1",
createdAt: new Date("2026-09-12T00:00:00Z"),
updatedAt: new Date("2026-09-12T00:00:00Z"),
}}
galleryEntry={getAppStoreDefinition("anthropic")!}
removing={false}
onRemove={onRemove}
/>,
));
expandDangerZone(container);
const button = (label: string) => Array.from(container!.querySelectorAll("button"))
.find((candidate) => candidate.textContent?.trim() === label);
expect(button("Reconnect")).toBeUndefined();
act(() => button("Remove app")!.dispatchEvent(new MouseEvent("click", { bubbles: true })));
act(() => button("Yes, remove it")!.dispatchEvent(new MouseEvent("click", { bubbles: true })));
expect(onRemove).toHaveBeenCalledOnce();
act(() => root.unmount());
});
it("keeps dangerous actions folded by default", () => {
const node = renderDangerZone();
+24 -4
View File
@@ -22,6 +22,7 @@ import { redactUrlSecrets } from "@/lib/redact-url-secrets";
import { navigateTopLevel } from "@/lib/browserNavigation";
import { prepareOAuthNavigation, savePendingCloudHandoff } from "@/lib/oauthHandoff";
import { cn } from "@/lib/utils";
import { Link } from "@/lib/router";
import type { AppDetailSectionProps } from "./types";
import { RevokeGrantDialog } from "./IdentitiesSection";
@@ -88,6 +89,15 @@ export function AdvancedPanel({
);
}
function connectionMethodUnavailable(connection: ToolConnection, galleryEntry: AppDefinition | null): boolean {
const methodKey = connection.config?.connectionMethodKey;
return typeof methodKey === "string"
&& methodKey.length > 0
&& !!galleryEntry
&& Array.isArray(galleryEntry.methods)
&& !getAvailableConnectionMethod(galleryEntry, methodKey);
}
function KeySection({
connection,
galleryEntry,
@@ -200,15 +210,18 @@ export function ReconnectCard({
});
const oauth = connection.authKind === "oauth";
const managedByVercel = connection.credentialSource === "vercel_connect";
const methodUnavailable = connectionMethodUnavailable(connection, galleryEntry);
return (
<div className="flex flex-col gap-4 rounded-lg border border-amber-500/50 bg-amber-500/10 px-4 py-3 sm:flex-row sm:items-center sm:justify-between">
<div className="min-w-0">
<h2 className="text-sm font-semibold text-amber-900 dark:text-amber-100">
{oauth ? "Reconnect required" : "This app needs reconnecting"}
{methodUnavailable ? "Connection no longer supported" : oauth ? "Reconnect required" : "This app needs reconnecting"}
</h2>
<p className="mt-0.5 text-sm text-amber-800 dark:text-amber-200">
{connection.healthMessage?.trim() || (oauth
{methodUnavailable
? "Add a supported connection from Connectors, then remove this connection."
: connection.healthMessage?.trim() || (oauth
? "Authorization expired or was revoked. Sign in again to restore access."
: "The key stopped working. Paste a new one to get it back online.")}
</p>
@@ -218,6 +231,12 @@ export function ReconnectCard({
<p className="text-sm text-amber-800 dark:text-amber-200">
{reconnectUnavailableMessage ?? "You don't have permission to reconnect this identity."}
</p>
) : methodUnavailable ? (
<Button size="sm" variant="outline" asChild>
<Link to={`/apps/connect?source=${encodeURIComponent(galleryEntry!.slug)}`}>
Add supported connection
</Link>
</Button>
) : onReconnect ? (
<Button size="sm" variant="outline" onClick={onReconnect}>Reconnect</Button>
) : managedByVercel && !oauth ? (
@@ -449,6 +468,7 @@ export function DangerZone({
const paused = connection
? connection.enabled === false || connection.status === "disabled"
: false;
const methodUnavailable = connection ? connectionMethodUnavailable(connection, galleryEntry) : false;
return (
<Collapsible
@@ -487,7 +507,7 @@ export function DangerZone({
</div>
) : null}
{connection && connection.authKind !== "oauth" ? (
{connection && !methodUnavailable && connection.authKind !== "oauth" ? (
<div className="py-4">
<KeySection
connection={connection}
@@ -499,7 +519,7 @@ export function DangerZone({
</div>
) : null}
{connection?.authKind === "oauth" && (onReconnectIdentity || !canReplaceCredential) ? (
{connection?.authKind === "oauth" && !methodUnavailable && (onReconnectIdentity || !canReplaceCredential) ? (
<div className="flex flex-wrap items-center justify-between gap-3 py-4">
<div>
<p className="text-sm font-medium text-foreground">Reconnect</p>