Skip to content

Commit

Permalink
Improve Copilot Integration (#3484)
Browse files Browse the repository at this point in the history
Link to Devin run:
https://app.devin.ai/sessions/32f8f17c4b494770b2e605a0c00f6c16

Improvements:
- Add more robust connection retries with configurable attempts
- Fix sign-in flow bugs and improve error handling
- Add error toasts with retry actions
- Improve logging with consistent levels and messages
- Add test coverage for error scenarios

Note: Local tests could not be run due to environment setup issues, but
changes include comprehensive test coverage that will be verified by CI.

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: Myles Scolnick <myles@marimo.io>
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
  • Loading branch information
3 people authored Jan 17, 2025
1 parent 61af9b0 commit 9d5c97b
Show file tree
Hide file tree
Showing 8 changed files with 262 additions and 78 deletions.
2 changes: 1 addition & 1 deletion .github/workflows/test_cli.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ jobs:
if: ${{ needs.changes.outputs.cli == 'true' }}
name: Build marimo wheel
runs-on: ubuntu-latest
timeout-minutes: 3 # 2024-01-18 avg: 0.8m max: 1.0m
timeout-minutes: 8 # 2024-01-18
defaults:
run:
shell: bash
Expand Down
9 changes: 7 additions & 2 deletions frontend/src/__tests__/setup.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
/* Copyright 2024 Marimo. All rights reserved. */

// Required for testing ser/deser of blobs
import { afterEach } from "vitest";
import { cleanup } from "@testing-library/react";
import "blob-polyfill";

// Cleanup after each test case (e.g., clearing jsdom)
afterEach(() => {
cleanup();
});
62 changes: 61 additions & 1 deletion frontend/src/components/editor/chrome/wrapper/copilot-status.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { Spinner } from "@/components/icons/spinner";
import {
isGitHubCopilotSignedInState,
githubCopilotLoadingVersion,
copilotSignedInState,
} from "@/core/codemirror/copilot/state";
import { atom, useAtomValue, useSetAtom } from "jotai";
import { aiEnabledAtom, resolvedMarimoConfigAtom } from "@/core/config/config";
Expand All @@ -13,7 +14,11 @@ import { SparklesIcon } from "lucide-react";
import { FooterItem } from "./footer-item";
import { activeUserConfigCategoryAtom } from "@/components/app-config/user-config-form";
import { settingDialogAtom } from "@/components/app-config/app-config-button";

import { toast } from "@/components/ui/use-toast";
import { getCopilotClient } from "@/core/codemirror/copilot/client";
import { Logger } from "@/utils/Logger";
import { Button } from "@/components/ui/button";
import { useOnMount } from "@/hooks/useLifecycle";
export const AIStatusIcon: React.FC = () => {
const ai = useAtomValue(aiAtom);
const aiEnabled = useAtomValue(aiEnabledAtom);
Expand Down Expand Up @@ -82,6 +87,61 @@ const GitHubCopilotStatus: React.FC = () => {
const { handleClick } = useOpenAISettings();

const label = isGitHubCopilotSignedIn ? "Ready" : "Not connected";
const setCopilotSignedIn = useSetAtom(isGitHubCopilotSignedInState);
const setStep = useSetAtom(copilotSignedInState);

// Check connection on mount
useOnMount(() => {
const client = getCopilotClient();
let mounted = true;

const checkConnection = async () => {
try {
// If we fail to initialize, show connection error
await client.initializePromise.catch((error) => {
Logger.error("Copilot#checkConnection: Failed to initialize", error);
client.close();
throw error;
});

if (!mounted) {
return;
}

const signedIn = await client.signedIn();
if (!mounted) {
return;
}

setCopilotSignedIn(signedIn);
setStep(signedIn ? "signedIn" : "signedOut");
} catch (error) {
if (!mounted) {
return;
}
Logger.warn("Copilot#checkConnection: Connection failed", error);
setCopilotSignedIn(false);
setStep("connectionError");
toast({
title: "GitHub Copilot Connection Error",
description:
"Failed to connect to GitHub Copilot. Check settings and try again.",
variant: "danger",
action: (
<Button variant="link" onClick={handleClick}>
Settings
</Button>
),
});
}
};

checkConnection();

return () => {
mounted = false;
};
});

return (
<FooterItem
Expand Down
56 changes: 42 additions & 14 deletions frontend/src/core/codemirror/copilot/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import type { JSONRPCRequestData } from "@open-rpc/client-js/build/Request";
import { waitForEnabledCopilot } from "./state";
import { waitForWs } from "@/utils/waitForWs";
import { resolveToWsUrl } from "@/core/websocket/createWsUrl";
import { Logger } from "@/utils/Logger";
import { toast } from "@/components/ui/use-toast";

// Dummy file for the copilot language server
export const COPILOT_FILENAME = "/marimo.py";
Expand All @@ -25,35 +27,65 @@ export const createWSTransport = once(() => {
*/
class LazyWebsocketTransport extends Transport {
private delegate: WebSocketTransport | undefined;
private readonly WS_URL = resolveToWsUrl("lsp/copilot");

constructor() {
super();
this.delegate = undefined;
}

private async tryConnect(retries = 3, delayMs = 1000): Promise<void> {
for (let attempt = 1; attempt <= retries; attempt++) {
try {
// Create delegate, if it doesn't exist
if (!this.delegate) {
this.delegate = new WebSocketTransport(this.WS_URL);
}
await this.delegate.connect();
Logger.log("Copilot#connect: Connected successfully");
return;
} catch (error) {
Logger.warn(
`Copilot#connect: Connection attempt ${attempt}/${retries} failed`,
error,
);
if (attempt === retries) {
this.delegate = undefined;
// Show error toast on final retry
toast({
variant: "danger",
title: "GitHub Copilot Connection Error",
description:
"Failed to connect to GitHub Copilot. Please check settings and try again.",
});
throw error;
}
await new Promise((resolve) => setTimeout(resolve, delayMs));
}
}
}

override async connect() {
// Wait for copilot to be enabled
await waitForEnabledCopilot();
// Wait for ws to be available
await waitForWs(createWsUrl(), 3);

// Create delegate, if it doesn't exist
if (!this.delegate) {
this.delegate = new WebSocketTransport(createWsUrl());
}
// Wait for ws to be available with retries
await waitForWs(this.WS_URL, 3);

// Connect
return this.delegate.connect();
// Try connecting with retries
return this.tryConnect();
}

override close() {
this.delegate?.close();
this.delegate = undefined;
}

override async sendData(
data: JSONRPCRequestData,
timeout?: number | null | undefined,
timeout: number | null | undefined,
) {
// Clamp timeout to 5 seconds
timeout = Math.min(timeout ?? 5000, 5000);
return this.delegate?.sendData(data, timeout);
}
}
Expand All @@ -79,7 +111,3 @@ export function copilotServer() {
languageId: LANGUAGE_ID,
});
}

export function createWsUrl(): string {
return resolveToWsUrl("lsp/copilot");
}
140 changes: 96 additions & 44 deletions frontend/src/core/codemirror/copilot/copilot-config.tsx
Original file line number Diff line number Diff line change
@@ -1,52 +1,25 @@
/* Copyright 2024 Marimo. All rights reserved. */
import { useAtom } from "jotai";
import { isGitHubCopilotSignedInState } from "./state";
import { memo, useEffect, useState } from "react";
import { copilotSignedInState, isGitHubCopilotSignedInState } from "./state";
import { memo, useState } from "react";
import { getCopilotClient } from "./client";
import { Button, buttonVariants } from "@/components/ui/button";
import { useOpenAISettings } from "@/components/editor/chrome/wrapper/copilot-status";
import { CheckIcon, CopyIcon, Loader2Icon, XIcon } from "lucide-react";
import { Label } from "@/components/ui/label";
import { toast } from "@/components/ui/use-toast";
import { copyToClipboard } from "@/utils/copy";

type Step =
| "signedIn"
| "signingIn"
| "signInFailed"
| "signedOut"
| "connecting"
| "notConnected";
import { Logger } from "@/utils/Logger";

export const CopilotConfig = memo(() => {
const [copilotSignedIn, copilotChangeSignIn] = useAtom(
isGitHubCopilotSignedInState,
);
const [step, setStep] = useState<Step>();

const [step, setStep] = useAtom(copilotSignedInState);
const { handleClick: openSettings } = useOpenAISettings();
const [localData, setLocalData] = useState<{ url: string; code: string }>();
const [loading, setLoading] = useState(false);

// Check connection on mount
useEffect(() => {
const client = getCopilotClient();
// If we fail to initialize, show not connected
client.initializePromise.catch(() => {
copilotChangeSignIn(false);
setStep("notConnected");
});
client
.signedIn()
.then((signedIn) => {
copilotChangeSignIn(signedIn);
setStep(signedIn ? "signedIn" : "signedOut");
})
.catch(() => {
copilotChangeSignIn(false);
setStep("notConnected");
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);

const trySignIn = async (evt: React.MouseEvent) => {
evt.preventDefault();
setLoading(true);
Expand All @@ -72,31 +45,96 @@ export const CopilotConfig = memo(() => {
return;
}
const client = getCopilotClient();
const MAX_RETRIES = 3;
const RETRY_DELAY_MS = 1000;

try {
setLoading(true);
Logger.log("Copilot#tryFinishSignIn: Attempting to confirm sign-in");
const { status } = await client.signInConfirm({
userCode: localData.code,
});

if (status === "OK" || status === "AlreadySignedIn") {
Logger.log("Copilot#tryFinishSignIn: Sign-in confirmed successfully");
copilotChangeSignIn(true);
setStep("signedIn");
} else {
Logger.warn(
"Copilot#tryFinishSignIn: Sign-in confirmation returned unexpected status",
{ status },
);
setStep("signInFailed");
}
} catch {
// If request failed, try seeing if we're already signed in
// otherwise, show the error
// We try 3 times, waiting 1 second between each try
for (let i = 0; i < 3; i++) {
await new Promise((resolve) => setTimeout(resolve, 1000));
const signedIn = await client.signedIn();
if (signedIn) {
copilotChangeSignIn(true);
setStep("signedIn");
return;
} catch (error) {
Logger.warn(
"Copilot#tryFinishSignIn: Initial sign-in confirmation failed, attempting retries",
);

// Check if it's a connection error
if (
error instanceof Error &&
(error.message.includes("ECONNREFUSED") ||
error.message.includes("WebSocket") ||
error.message.includes("network"))
) {
Logger.error(
"Copilot#tryFinishSignIn: Connection error during sign-in",
error,
);
setStep("connectionError");
toast({
title: "GitHub Copilot Connection Error",
description: "Lost connection during sign-in. Please try again.",
variant: "danger",
action: <Button onClick={trySignIn}>Retry</Button>,
});
return;
}

// If not a connection error, try seeing if we're already signed in
// We try multiple times with a delay between attempts
for (let i = 0; i < MAX_RETRIES; i++) {
try {
await new Promise((resolve) => setTimeout(resolve, RETRY_DELAY_MS));
const signedIn = await client.signedIn();
if (signedIn) {
Logger.log(
"Copilot#tryFinishSignIn: Successfully signed in after retry",
);
copilotChangeSignIn(true);
setStep("signedIn");
return;
}
} catch (retryError) {
Logger.warn("Copilot#tryFinishSignIn: Retry attempt failed", {
attempt: i + 1,
maxRetries: MAX_RETRIES,
});
// Check for connection errors during retry
if (
retryError instanceof Error &&
(retryError.message.includes("ECONNREFUSED") ||
retryError.message.includes("WebSocket") ||
retryError.message.includes("network"))
) {
setStep("connectionError");
toast({
title: "GitHub Copilot Connection Error",
description:
"Lost connection during sign-in. Please check settings and try again.",
variant: "danger",
action: (
<Button variant="link" onClick={openSettings}>
Settings
</Button>
),
});
return;
}
}
}
Logger.error("Copilot#tryFinishSignIn: All sign-in attempts failed");
setStep("signInFailed");
} finally {
setLoading(false);
Expand Down Expand Up @@ -200,6 +238,20 @@ export const CopilotConfig = memo(() => {
</div>
);

case "connectionError":
return (
<div className="flex flex-col gap-1">
<Label className="font-normal flex">
<XIcon className="h-4 w-4 mr-1" />
Connection Error
</Label>
<div className="text-sm">Unable to connect to GitHub Copilot.</div>
<Button onClick={trySignIn} size="xs" variant="link">
Retry Connection
</Button>
</div>
);

case "notConnected":
return (
<div className="flex flex-col gap-1">
Expand Down
Loading

0 comments on commit 9d5c97b

Please sign in to comment.