Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 58 additions & 1 deletion apps/app/src/hooks/queries/sidebar-navigation-query.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@ import {
type SidebarBootstrapResponse,
} from "@bb/server-contract";
import { afterEach, describe, expect, it, vi } from "vitest";
import { request } from "@/lib/api";
import { HttpError, request } from "@/lib/api";
import { shouldRetryTransientReadQuery } from "./query-helpers";
import {
MAX_CACHED_SIDEBAR_THREADS_PER_PROJECT,
SIDEBAR_BOOTSTRAP_CACHE_KEY,
Expand Down Expand Up @@ -68,6 +69,62 @@ afterEach(() => {
});

describe("useSidebarNavigation", () => {
it.each([
new TypeError("Failed to fetch"),
new HttpError({ status: 503, message: "Server starting" }),
])("recovers the startup project and thread list after a temporary outage: %s", async (error) => {
vi.useFakeTimers({ shouldAdvanceTime: true });
const harness = createQueryClientTestHarness({ queries: { retry: shouldRetryTransientReadQuery } });
try {
vi.mocked(request).mockReset();
vi.mocked(request).mockRejectedValueOnce(error).mockRejectedValueOnce(error).mockRejectedValueOnce(error).mockRejectedValueOnce(error).mockResolvedValue(BOOTSTRAP);
const { result } = renderHook(() => useSidebarNavigation(), { wrapper: harness.wrapper });
await act(async () => { await vi.advanceTimersByTimeAsync(35_000); });
expect(result.current.data).toEqual(BOOTSTRAP);
expect(result.current.isError).toBe(false);
expect(request).toHaveBeenCalledTimes(5);
} finally {
harness.queryClient.clear();
vi.useRealTimers();
}
});

it("recovers after a startup outage outlasts the initial retry budget", async () => {
vi.useFakeTimers({ shouldAdvanceTime: true });
const harness = createQueryClientTestHarness();
try {
vi.mocked(request).mockReset();
for (let index = 0; index < 11; index += 1) {
vi.mocked(request).mockRejectedValueOnce(new HttpError({ status: 503, message: "Starting" }));
}
vi.mocked(request).mockResolvedValue(BOOTSTRAP);
const { result } = renderHook(() => useSidebarNavigation(), { wrapper: harness.wrapper });
await act(async () => { await vi.advanceTimersByTimeAsync(40_000); });
expect(result.current.data).toEqual(BOOTSTRAP);
expect(request).toHaveBeenCalledTimes(12);
await act(async () => { await vi.advanceTimersByTimeAsync(20_000); });
expect(request).toHaveBeenCalledTimes(12);
} finally {
harness.queryClient.clear();
vi.useRealTimers();
}
});

it.each([401, 403, 404, 500])("does not repeatedly retry a permanent HTTP %s error", async (status) => {
vi.useFakeTimers({ shouldAdvanceTime: true });
const harness = createQueryClientTestHarness();
try {
vi.mocked(request).mockReset().mockRejectedValue(new HttpError({ status, message: "Permanent error" }));
const { result } = renderHook(() => useSidebarNavigation(), { wrapper: harness.wrapper });
await act(async () => { await vi.advanceTimersByTimeAsync(60_000); });
expect(result.current.isError).toBe(true);
expect(request).toHaveBeenCalledTimes(1);
} finally {
harness.queryClient.clear();
vi.useRealTimers();
}
});

it("replays the last bootstrap while the live one loads", async () => {
sidebarBootstrapResponseSchema.parse(BOOTSTRAP);

Expand Down
22 changes: 20 additions & 2 deletions apps/app/src/hooks/queries/sidebar-navigation-query.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,21 +4,36 @@ import { PERSONAL_PROJECT_ID, type ThreadListEntry } from "@bb/domain";
import type { SidebarBootstrapResponse } from "@bb/server-contract";
import { listSidebarNavigationThreads } from "@/hooks/cache-owners/query-cache";
import { apiClient } from "@/lib/api-server";
import { request, requestOptions } from "@/lib/api";
import { HttpError, request, requestOptions } from "@/lib/api";
import {
useEnvironmentListRealtimeSubscription,
useHostListRealtimeSubscription,
useProjectListRealtimeSubscription,
useThreadListRealtimeSubscription,
} from "@/hooks/useRealtimeSubscription";
import type { QueryOptions } from "./query-helpers";
import { isTransientReadError, type QueryOptions } from "./query-helpers";
import { sidebarNavigationQueryKey } from "./query-keys";
import { REALTIME_OWNED_STATIC_CACHE_QUERY_POLICY } from "./query-policies";
import {
readCachedSidebarBootstrap,
writeCachedSidebarBootstrap,
} from "@/lib/sidebar-bootstrap-cache";

function isRecoverableSidebarError(error: unknown): boolean {
return isTransientReadError(error) ||
(error instanceof HttpError && [502, 503, 504].includes(error.status));
}

const SIDEBAR_BOOTSTRAP_RECOVERY_POLICY = {
retry: (failureCount: number, error: Error) =>
failureCount < 10 && isRecoverableSidebarError(error),
retryDelay: (attempt: number) => Math.min(250 * 2 ** attempt, 5_000),
refetchInterval: (query: { state: { status: string; error: Error | null } }) =>
query.state.status === "error" && isRecoverableSidebarError(query.state.error)
? 5_000
: false,
} as const;

function fetchSidebarNavigation(
signal?: AbortSignal,
): Promise<SidebarBootstrapResponse> {
Expand All @@ -43,6 +58,7 @@ export function useSidebarNavigation(options?: QueryOptions) {
},
enabled,
...REALTIME_OWNED_STATIC_CACHE_QUERY_POLICY,
...SIDEBAR_BOOTSTRAP_RECOVERY_POLICY,
placeholderData: () => readCachedSidebarBootstrap() ?? undefined,
});
}
Expand All @@ -54,6 +70,7 @@ export function useProjectDisplayName(
queryKey: sidebarNavigationQueryKey(),
queryFn: ({ signal }) => fetchSidebarNavigation(signal),
...REALTIME_OWNED_STATIC_CACHE_QUERY_POLICY,
...SIDEBAR_BOOTSTRAP_RECOVERY_POLICY,
enabled: Boolean(projectId),
});
if (!data || !projectId) {
Expand Down Expand Up @@ -82,6 +99,7 @@ export function useSidebarNavigationThreadSelection<T>(
queryKey: sidebarNavigationQueryKey(),
queryFn: ({ signal }) => fetchSidebarNavigation(signal),
...REALTIME_OWNED_STATIC_CACHE_QUERY_POLICY,
...SIDEBAR_BOOTSTRAP_RECOVERY_POLICY,
enabled: false,
select: selectFromNavigation,
});
Expand Down
12 changes: 8 additions & 4 deletions apps/server/test/public/public-system-usage-limits.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,9 @@ describe("GET /api/v1/system/usage-limits", () => {
).toBe(false);
expect(
responder.requests.some(
(request) => request.command.type === "provider.health",
(request) =>
request.command.type === "provider.health" &&
request.command.providerId === "no-usage",
),
).toBe(false);
});
Expand Down Expand Up @@ -143,10 +145,12 @@ describe("GET /api/v1/system/usage-limits", () => {
),
).toEqual(["codex"]);
expect(
responder.requests.some(
(request) => request.command.type === "provider.health",
responder.requests.flatMap((request) =>
request.command.type === "provider.health"
? [request.command.providerId]
: [],
),
).toBe(false);
).toEqual(["acp-grok"]);
});
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ const FIRST_PARTY_PROVIDER_DECLARATIONS = [
supportsThreadRename: false,
fork: "none",
supportsManualCompaction: false,
supportsUsage: false,
supportsUsage: true,
visibility: "installed",
hasLogo: true,
},
Expand Down
2 changes: 1 addition & 1 deletion packages/domain/src/plugin-sdk-version.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
export const PLUGIN_SDK_VERSION = "0.4.48";
export const PLUGIN_SDK_VERSION = "0.4.49";

export const PLUGIN_SDK_MAJOR = Number(PLUGIN_SDK_VERSION.split(".", 1)[0]);
2 changes: 1 addition & 1 deletion packages/plugin-sdk/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@get-bb/plugin-sdk",
"version": "0.4.48",
"version": "0.4.49",
"homepage": "https://github.com/get-bb/bb#readme",
"bugs": {
"url": "https://github.com/get-bb/bb/issues"
Expand Down
25 changes: 25 additions & 0 deletions packages/provider-bridge-acp/src/bridge/bridge.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2372,6 +2372,31 @@ describe("acp bridge", () => {
expect(agentMessageTexts()).not.toContain("echo:/compact");
});

it("reports Cursor's terminal PING timeout as a failed turn", async () => {
const { providerThreadId } = await startThread({ dialectId: "cursor", envVars: { FAKE_ACP_TEXT_FAILURE: "1" } });
const id = sendTurnRequest("turn/start", providerThreadId, { input: [{ type: "text", text: "probe" }] });
expect((await waitForResponse(id)).error).toBeUndefined();
expect(await waitForTurnCompleted()).toMatchObject({ status: "failed" });
expect(notifications("error")).toEqual(expect.arrayContaining([
expect.objectContaining({ params: expect.objectContaining({ message: "RetriableError: [unavailable] PING timed out" }) }),
]));
});

it.each([
{ dialectId: "cursor", parts: ["\n\nError: RetriableError: [unavailable] ", "PING timed out"], status: "failed" },
{ dialectId: "cursor", parts: ["The log says: Error: RetriableError: [unavailable] PING timed out"], status: "completed" },
{ dialectId: "cursor", parts: ["```\nError: RetriableError: [unavailable] PING timed out\n```"], status: "completed" },
{ dialectId: "cursor", parts: ["\n\nError: RetriableError: [unavailable] PING timed out", "\nI recovered and completed the task."], status: "completed" },
{ dialectId: "cursor", parts: ["Error: RetriableError: [unavailable] PING timed out"], status: "completed" },
{ dialectId: "cursor", parts: ["\n\nError: RetriableError: [unavailable] PING timed out" + " ".repeat(512) + "Recovered"], status: "completed" },
{ dialectId: "generic", parts: ["\n\nError: RetriableError: [unavailable] PING timed out"], status: "completed" },
])("normalizes only a terminal Cursor transport failure: $dialectId $parts", async ({ dialectId, parts, status }) => {
const { providerThreadId } = await startThread({ dialectId, envVars: { FAKE_ACP_TEXT_FAILURE: "1", FAKE_ACP_TEXT_PARTS: JSON.stringify(parts) } });
const id = sendTurnRequest("turn/start", providerThreadId, { input: [{ type: "text", text: "probe" }] });
expect((await waitForResponse(id)).error).toBeUndefined();
expect(await waitForTurnCompleted()).toMatchObject({ status });
});

it("fails the compaction turn legibly when the agent rejects the request", async () => {
const { providerThreadId } = await startThread({
envVars: { FAKE_ACP_PROMPT_ERROR: "1" },
Expand Down
32 changes: 32 additions & 0 deletions packages/provider-bridge-acp/src/bridge/bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,7 @@ interface AcpThreadSession {
compactionAgentMessage: string;
queuedInputs: AcpPendingTurnInput[];
promptRequestPending: boolean;
cursorTerminalErrorText: string;
cancelRequested: boolean;
loading: boolean;
loadingSessionId: string | undefined;
Expand Down Expand Up @@ -1715,6 +1716,7 @@ async function startAgentSession(
compactionAgentMessage: "",
queuedInputs: [],
promptRequestPending: false,
cursorTerminalErrorText: "",
cancelRequested: false,
loading: false,
loadingSessionId: undefined,
Expand Down Expand Up @@ -2049,6 +2051,7 @@ function runTurn(
let stopReason: z.infer<typeof acpStopReasonSchema>;
session.cancelRequested = false;
try {
session.cursorTerminalErrorText = "";
session.promptRequestPending = true;
const promptResult = session.connection.request({
method: "session/prompt",
Expand All @@ -2064,6 +2067,15 @@ function runTurn(
}
const result = await promptResult;
stopReason = result.stopReason;
if (
stopReason === "end_turn" &&
session.dialect.id === "cursor" &&
session.cursorTerminalErrorText.startsWith("\n\nError:") &&
session.cursorTerminalErrorText.trim() ===
"Error: RetriableError: [unavailable] PING timed out"
) {
throw new Error("RetriableError: [unavailable] PING timed out");
}
} catch (error) {
session.promptRequestPending = false;
dropTurnInput(pending, "ACP turn failed before the prompt was sent");
Expand Down Expand Up @@ -2251,6 +2263,26 @@ function handleAgentNotification(
extractAcpContentText(chunk.data.content) ?? "";
}
}
if (session.dialect.id === "cursor" && session.activePromptKind === "turn") {
const event = parsed.data.update;
const chunk =
event.sessionUpdate === "agent_message_chunk"
? acpAgentMessageChunkUpdateSchema.safeParse(event)
: null;
const text =
chunk?.success === true
? extractAcpContentText(chunk.data.content)
: undefined;
if (text !== undefined) {
if (text.startsWith("\n\nError:")) {
session.cursorTerminalErrorText = "";
}
const combined = session.cursorTerminalErrorText + text;
session.cursorTerminalErrorText = combined.length <= 512 ? combined : "<non-terminal-text>";
} else if (event.sessionUpdate !== "usage_update") {
session.cursorTerminalErrorText = "";
}
}
emitForSession(session, ACP_UPDATE_METHOD, update);
}

Expand Down
10 changes: 10 additions & 0 deletions packages/provider-bridge-acp/src/bridge/fake-acp-agent.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -422,6 +422,16 @@ async function handlePrompt(message) {
);
}

if (process.env.FAKE_ACP_TEXT_FAILURE === "1") {
const parts = JSON.parse(process.env.FAKE_ACP_TEXT_PARTS ?? '["\\n\\nError: RetriableError: [unavailable] PING timed out"]');
for (const text of parts) {
notifyUpdate({ sessionUpdate: "agent_message_chunk", content: { type: "text", text } });
}
activePromptId = null;
send({ jsonrpc: "2.0", id: message.id, result: { stopReason: "end_turn" } });
return;
}

if (process.env.FAKE_ACP_PROMPT_ERROR === "1") {
activePromptId = null;
send({
Expand Down
Loading
Loading