mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-12 18:06:11 +02:00
Merge remote-tracking branch 'origin/master' into codex/provider-routing-connections
* origin/master: fix(tasks): surface Codex ChatGPT model rejection (#15299) fix(runner): continue restart-interrupted Codex turns (#15297) fix(agents): grant configuration access by default (#15283) Guard routine UUID lookups without narrowing valid inputs (#15313) fix(ssh): transport project repositories as their own git checkouts (#14782) feat(exe-dev): copy a source VM with exe.dev cp (#14975) Diagnose native model rejections and preserve repairable reviews (#15304) Verify native semantic input against its raw wire digest (#15301) Retry pooled execution projection reads after disconnects (#15300) Fix browser polling for unsaved agent chats (#15298) test(ui): keep initial reasoning with the saved reply (#15107) docs: add a product feature map (#15288)
This commit is contained in:
commit
7cbd61349a
126 files changed
+5961
-150
No files matched your search
@@ -16,6 +16,8 @@ export interface RunForIssue {
|
||||
invocationSource: string;
|
||||
responsibleUserId?: string | null;
|
||||
errorCode?: string | null;
|
||||
/** Bounded provider message for a failed model rejection. */
|
||||
error?: string | null;
|
||||
usageJson: Record<string, unknown> | null;
|
||||
resultJson: Record<string, unknown> | null;
|
||||
logBytes?: number | null;
|
||||
|
||||
@@ -107,6 +107,28 @@ describe("stopped task recovery notice", () => {
|
||||
expect(onRetried).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it.each([
|
||||
"The 'gpt-6.1-sol' model is not supported when using Codex with a ChatGPT account.",
|
||||
"The selected model is not supported by the current ChatGPT connection. Choose a supported model or a compatible AI connection.",
|
||||
])("shows the model rejection and repair action without expanding run logs: %s", async (runError) => {
|
||||
vi.mocked(activityApi.runsForIssue).mockResolvedValue([{ runId: "model-run", agentId: "agent",
|
||||
status: "failed", errorCode: "native_provider_model_rejected" }] as never);
|
||||
await act(async () => root.render(<QueryClientProvider client={client}>
|
||||
<ExecutionBlockerNotice companyId="company" issueId="model-task" onRetried={onRetried} blocker={{
|
||||
recoveryActionId: "recovery", runId: "model-run", agentId: "agent",
|
||||
cause: "native_continuation_requires_reconciliation", canRetry: true,
|
||||
runError,
|
||||
nextAction: "Inspect the original failure before continuing.",
|
||||
}} />
|
||||
</QueryClientProvider>));
|
||||
await act(async () => { await new Promise(resolve => setTimeout(resolve, 10)); });
|
||||
expect(container.textContent).toContain("Model unavailable.");
|
||||
expect(container.textContent).toContain(runError);
|
||||
expect(container.textContent).toContain("clear the task's model override, then retry");
|
||||
expect(container.textContent).toContain("Inspect the original failure before continuing.");
|
||||
expect(container.querySelector("button")?.textContent).toBe("Retry");
|
||||
});
|
||||
|
||||
it.each(["native_continuation_requires_reconciliation", "uncertain_external_action"])("offers Retry for a server-admitted native failure: %s", async cause => {
|
||||
await act(async () => root.render(<QueryClientProvider client={client}>
|
||||
<ExecutionBlockerNotice companyId="company" issueId="task" onRetried={onRetried} blocker={{
|
||||
|
||||
@@ -19,6 +19,7 @@ export function ExecutionBlockerNotice({ companyId, issueId, blocker, onRetried
|
||||
});
|
||||
const failedRun = runs?.find(run => run.runId === blocker.runId &&
|
||||
["failed", "timed_out"].includes(run.status));
|
||||
const modelRejected = failedRun?.errorCode === "native_provider_model_rejected";
|
||||
const requiresInspection = blocker.cause === "native_continuation_requires_reconciliation" ||
|
||||
blocker.cause === "native_session_cleanup_quarantined";
|
||||
const retry = useMutation({
|
||||
@@ -34,7 +35,8 @@ export function ExecutionBlockerNotice({ companyId, issueId, blocker, onRetried
|
||||
return (
|
||||
<div role="status" aria-label="Task recovery" className="mx-(--sz-execution-blocker-inline) my-(--sz-execution-blocker-block) flex flex-wrap items-center justify-between execution-blocker-notice border border-border bg-muted text-foreground">
|
||||
<div className="min-w-0 flex-1 break-words">
|
||||
<p>Recovery needed.{blocker.runError ? ` ${blocker.runError}` : ""}</p>
|
||||
<p>{modelRejected ? "Model unavailable." : "Recovery needed."}{blocker.runError ? ` ${blocker.runError}` : ""}</p>
|
||||
{modelRejected && <p>Choose a supported model or clear the task's model override, then retry.</p>}
|
||||
<p>{blocker.nextAction}</p>
|
||||
{Boolean(blocker.savedMessageCount) && (
|
||||
<p>{blocker.savedMessageCount} saved {blocker.savedMessageCount === 1 ? "message is" : "messages are"} waiting for recovery.</p>
|
||||
|
||||
@@ -437,7 +437,15 @@ describe.each(["legacy", "native"] as const)("%s task history readiness", (runti
|
||||
async (status) => {
|
||||
const props = {
|
||||
issueId: "issue-1",
|
||||
comments: createLongThreadComments(),
|
||||
comments: [...createLongThreadComments(), {
|
||||
...createLongThreadComments()[3],
|
||||
id: "agent-answer",
|
||||
authorType: "agent" as const,
|
||||
authorAgentId: "agent-1",
|
||||
body: "Final saved reply",
|
||||
runId: "started-run",
|
||||
createdAt: new Date("2026-08-25T18:00:02.000Z"),
|
||||
}],
|
||||
onAdd: async () => {},
|
||||
linkedRuns: [
|
||||
retryRun,
|
||||
@@ -455,9 +463,17 @@ describe.each(["legacy", "native"] as const)("%s task history readiness", (runti
|
||||
container.querySelector('[data-testid="task-chat-history-loading"]'),
|
||||
).not.toBeNull();
|
||||
expect(container.textContent).toContain("Thread message 1");
|
||||
|
||||
expect(container.querySelector('[data-thread-anchor="comment-1"]')?.closest('[inert]')).not.toBeNull();
|
||||
transcriptState.hydratedRunIds = new Set(["started-run"]);
|
||||
nativeTranscriptState.hydratedRunIds = new Set(["started-run"]);
|
||||
transcriptState.transcriptByRun.set("started-run", [{
|
||||
kind: "thinking", text: "Reasoning before the reply",
|
||||
ts: "2026-08-25T18:00:01.000Z",
|
||||
}]);
|
||||
nativeTranscriptState.transcriptByRun.set("started-run", [{
|
||||
kind: "assistant", text: "Reasoning before the reply", channel: "progress",
|
||||
ts: "2026-08-25T18:00:01.000Z",
|
||||
}]);
|
||||
render(<TaskChatThread {...props} />);
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => requestAnimationFrame(resolve));
|
||||
@@ -467,6 +483,15 @@ describe.each(["legacy", "native"] as const)("%s task history readiness", (runti
|
||||
expect(
|
||||
container.querySelector('[data-testid="task-chat-history-loading"]'),
|
||||
).toBeNull();
|
||||
expect(container.querySelector('[data-thread-anchor="comment-1"]')?.closest('[inert]')).toBeNull();
|
||||
expect(container.textContent).toContain("Thread message 1");
|
||||
if (status === "succeeded" && runtimeMode === "native") {
|
||||
expect(container.textContent).toContain("Reasoning before the reply");
|
||||
expect(container.textContent).toContain("Final saved reply");
|
||||
expect(container.textContent!.indexOf("Reasoning before the reply")).toBeLessThan(
|
||||
container.textContent!.indexOf("Final saved reply"),
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -1778,6 +1803,32 @@ describe("TaskChatThread runtime transcript selection", () => {
|
||||
expect(container.textContent).not.toContain("The runner stopped");
|
||||
});
|
||||
|
||||
it.each([
|
||||
"The 'gpt-6.1-sol' model is not supported when using Codex with a ChatGPT account.",
|
||||
"The selected model is not supported by the current ChatGPT connection. Choose a supported model or a compatible AI connection.",
|
||||
])("promotes a rejected model above the runner's generated failure response: %s", (error) => {
|
||||
nativeTranscriptState.transcriptByRun.set("model-rejected", [{
|
||||
kind: "run_result", ts: "2026-08-25T18:00:01.000Z",
|
||||
summary: "The Codex run failed before it completed.",
|
||||
disposition: "needs_review", objectiveSatisfied: false, verification: [],
|
||||
remainingWork: [], blocker: null, artifacts: [],
|
||||
}]);
|
||||
render(<TaskChatThread comments={[]} onAdd={async () => {}} linkedRuns={[{
|
||||
runId: "model-rejected", runtimeMode: "native", status: "failed",
|
||||
errorCode: "native_provider_model_rejected",
|
||||
error,
|
||||
agentId: "agent-1", agentName: "Runner", adapterType: "paperclip_runner",
|
||||
createdAt: "2026-08-25T18:00:00.000Z", startedAt: "2026-08-25T18:00:00.000Z",
|
||||
finishedAt: "2026-08-25T18:00:02.000Z",
|
||||
}]} />);
|
||||
const marker = container.querySelector('[data-testid="task-chat-collapsible-marker"]');
|
||||
expect(marker?.textContent).toContain("Model unavailable");
|
||||
flushSync(() => marker!.querySelector<HTMLButtonElement>('button[aria-expanded="false"]')!.click());
|
||||
expect(container.textContent).toContain(error);
|
||||
expect(container.textContent).toContain("clear the task's model override, then retry");
|
||||
expect(container.textContent).not.toContain("after returning a final response");
|
||||
});
|
||||
|
||||
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",
|
||||
|
||||
@@ -1637,7 +1637,9 @@ export function TaskChatThread(props: TaskChatThreadProps) {
|
||||
? "native_runner_timed_out"
|
||||
: "native_runner_process_exited");
|
||||
const label =
|
||||
code === "native_provider_approval_required" && source.status === "failed"
|
||||
code === "native_provider_model_rejected" && source.status === "failed"
|
||||
? "Model unavailable"
|
||||
: code === "native_provider_approval_required" && source.status === "failed"
|
||||
? "Approval required"
|
||||
: code === "native_provider_usage_limit" && source.status === "failed"
|
||||
? "Usage limit reached"
|
||||
@@ -1659,7 +1661,7 @@ export function TaskChatThread(props: TaskChatThreadProps) {
|
||||
: code === "native_provider_approval_required"
|
||||
? "This operation requires approval, but this runner has no interactive approval handler. Review the operation and update the agent's permission setting before retrying."
|
||||
: code === "native_provider_model_rejected"
|
||||
? "The provider rejected the selected model. Check the model ID and your account's access, save the agent configuration, then retry. View the run for the provider's full error."
|
||||
? `${meta?.error || "The provider rejected the selected model."} Choose a supported model or clear the task's model override, then retry.`
|
||||
: code === "native_provider_usage_limit" &&
|
||||
source.status === "failed"
|
||||
? "The model provider has reached its current usage limit. Try again after the limit resets."
|
||||
|
||||
@@ -3,18 +3,25 @@ import { act, createElement } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { afterEach, beforeEach, expect, it, vi } from "vitest";
|
||||
import type { TaskBrowser } from "@paperclipai/shared";
|
||||
import { useBrowserArrivals } from "./useTaskBrowsers";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { browserUseApi } from "@/api/browser-use";
|
||||
import { useBrowserArrivals, useTaskBrowsers } from "./useTaskBrowsers";
|
||||
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
const cleanups = new Set<() => void>();
|
||||
function cleanup() { for (const unmount of cleanups) unmount(); }
|
||||
function renderHook<R, P = undefined>(callback: (props: P) => R, options?: { initialProps: P }) {
|
||||
function renderHook<R, P = undefined>(callback: (props: P) => R, options?: { initialProps: P; client?: QueryClient }) {
|
||||
const host = document.createElement("div");
|
||||
document.body.append(host);
|
||||
const root = createRoot(host);
|
||||
let props = options?.initialProps as P;
|
||||
const result = {} as { current: R };
|
||||
function Harness() { result.current = callback(props); return null; }
|
||||
function rerender(next = props) { props = next; act(() => root.render(createElement(Harness))); }
|
||||
function rerender(next = props) {
|
||||
props = next;
|
||||
act(() => root.render(options?.client
|
||||
? createElement(QueryClientProvider, { client: options.client }, createElement(Harness))
|
||||
: createElement(Harness)));
|
||||
}
|
||||
function unmount() { act(() => root.unmount()); host.remove(); cleanups.delete(unmount); }
|
||||
cleanups.add(unmount);
|
||||
rerender();
|
||||
@@ -26,7 +33,32 @@ const browser = (id: string): TaskBrowser => ({
|
||||
error: null, createdAt: "2026-09-29T10:00:00Z",
|
||||
});
|
||||
beforeEach(() => { localStorage.clear(); sessionStorage.clear(); });
|
||||
afterEach(() => { cleanup(); vi.restoreAllMocks(); });
|
||||
afterEach(() => { cleanup(); vi.restoreAllMocks(); vi.useRealTimers(); });
|
||||
it("starts browser polling when a chat draft becomes a saved task and stops on the next draft", async () => {
|
||||
vi.useFakeTimers();
|
||||
const list = vi.spyOn(browserUseApi, "list").mockResolvedValue([]);
|
||||
const client = new QueryClient();
|
||||
const taskId = "12345678-1234-4234-8234-123456789abc";
|
||||
const { rerender, unmount } = renderHook(useTaskBrowsers, {
|
||||
initialProps: undefined as string | undefined,
|
||||
client,
|
||||
});
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(6000); });
|
||||
expect(list).not.toHaveBeenCalled();
|
||||
rerender(`chat:${taskId}`);
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(6000); });
|
||||
expect(list).not.toHaveBeenCalled();
|
||||
rerender(taskId);
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(1); });
|
||||
expect(list).toHaveBeenCalledExactlyOnceWith(taskId);
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(3000); });
|
||||
expect(list).toHaveBeenCalledTimes(2);
|
||||
rerender(`chat:${taskId}`);
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(6000); });
|
||||
expect(list).toHaveBeenCalledTimes(2);
|
||||
unmount();
|
||||
client.clear();
|
||||
});
|
||||
it("queues simultaneous arrivals and claims only after the panel acknowledges", () => {
|
||||
const browsers = [browser("first"), browser("next")];
|
||||
const { result, rerender } = renderHook(() => useBrowserArrivals("user", "task", browsers));
|
||||
|
||||
@@ -6,7 +6,7 @@ export function useTaskBrowsers(issueId?: string) {
|
||||
return useQuery({
|
||||
queryKey: ["task-browsers", issueId],
|
||||
queryFn: () => browserUseApi.list(issueId!),
|
||||
enabled: Boolean(issueId),
|
||||
enabled: Boolean(issueId) && !issueId?.startsWith("chat:"),
|
||||
refetchInterval: 3000,
|
||||
retry: false,
|
||||
});
|
||||
|
||||
@@ -59,6 +59,7 @@ export interface IssueChatLinkedRun {
|
||||
hasStoredOutput?: boolean;
|
||||
logBytes?: number | null;
|
||||
errorCode?: string | null;
|
||||
error?: string | null;
|
||||
scheduledRetryAt?: string | null;
|
||||
nextAction?: string | null;
|
||||
resultJson?: Record<string, unknown> | null;
|
||||
|
||||
Reference in new issue
Block a user