Skip to content

feat: add GitHub Copilot as an AI provider#253

Merged
chuks-qua merged 46 commits intomainfrom
feat/gh-copilot
Apr 13, 2026
Merged

feat: add GitHub Copilot as an AI provider#253
chuks-qua merged 46 commits intomainfrom
feat/gh-copilot

Conversation

@chuks-qua
Copy link
Copy Markdown
Contributor

@chuks-qua chuks-qua commented Apr 11, 2026

What

Integrates GitHub Copilot as a first-class AI provider via `@github/copilot-sdk`, giving users access to models from OpenAI, Anthropic, Google, and xAI families from within the app.

Why

GitHub Copilot subscribers already pay for access to a wide range of frontier models. Surfacing that as a selectable provider avoids redundant API key setup and broadens model coverage without adding new billing.

Key Changes

  • CopilotProvider - implements IAgentProvider using the Copilot SDK's long-running stdio process pattern; rewrites bootstrap to fix an Electron executor bug where utilityProcess.fork sets process.execPath to electron.exe; temporarily overrides process.execPath with the real node binary during client.start() and restores it in a finally block; fetches gh auth token explicitly so the headless subprocess does not need to discover auth from its environment
  • Provider routing - tracks provider as explicit state in Composer (not derived from model ID) so Copilot and Codex models that share IDs route to the correct backend
  • Live model listing - ModelSelector calls listProviderModels() on hover (new thread) and on mount (existing thread) to show the live model list from GitHub's API; falls back to the static registry if the fetch fails; gated behind a supportsModelListing flag so providers without listModels() do not trigger server errors
  • Model selector UI - Copilot submenu groups models by vendor (OpenAI / Anthropic / Google / xAI) with section headers; submenu opens upward to match the main picker direction; max-height uses min(480px, 100vh-8rem) so all groups are reachable
  • Settings - adds copilot.cliPath setting
  • Error surface - CliErrorBanner detects Copilot-specific "package not found" and "exited unexpectedly" errors and renders a copyable install/auth command
  • Icon - uses the official two-path SVG from primer/octicons copilot-24
  • ComposerDraft - persists provider alongside model so draft restore round-trips the correct backend

Configuration Changes

No new environment variables or Vault secrets. The optional copilot.cliPath setting defaults to auto-discovery via the npm package.

Closes #90

Covers SDK integration (@github/copilot-sdk), event mapping, session
lifecycle, 17-model registry, settings schema changes, and UX fixes
for model selector overflow with large model lists.
10-task plan covering SDK integration, DI registration, settings schema,
17-model registry, provider icon, and UX fixes for model selector overflow.
- Fix Copilot brand color collision with Gemini (sky-400 -> violet-400)
- Group Copilot's 17 models by vendor (OpenAI / Anthropic / Google / xAI)
  in both the composer submenu and settings Select dropdowns
- Add Check icon to selected model in submenu and provider-locked list
- Fix misleading reasoning hint when Copilot provider is selected
- Add group field to ModelDefinition interface
The SDK resolves @github/copilot as an npm package (not an external
binary), so checking `gh --version` was a false positive when GitHub
CLI happened to be installed.

- Use require.resolve('@github/copilot') when no custom cliPath is set,
  matching what the SDK itself does
- Translate "CLI server exited with code 0" into an actionable message
  about running `gh auth login` and verifying the Copilot subscription
- Translate "Could not find @github/copilot" into the install message
When the @github/copilot process exits unexpectedly (auth failure, etc.),
the client enters an error/disconnected state. The old check only looked at
whether this.client was non-null, so dead clients were reused and every
subsequent message would fail.

- refreshClient() now checks getState() and rebuilds if not "connected"
- sendMessage() nulls out this.client on "CLI server exited" so the next
  attempt gets a fresh process instead of failing on the dead one
findProviderForModel() always returned the first provider matching a
model ID. Since Codex comes before Copilot in MODEL_PROVIDERS, selecting
Copilot + GPT-5.3 Codex silently routed to the Codex provider instead.

- Add getDefaultProviderId() helper (reads settings.model.defaults.provider)
- ModelSelector.onSelect now passes (modelId, providerId) so callers know
  which provider the model was selected from, not guessing from ID alone
- Add selectedProviderId prop to ModelSelector for correct icon/label display
  and accurate checkmark placement when providers share model IDs
- Composer tracks explicit provider state; initialises from settings provider,
  restores from thread record on switch, and saves/restores with drafts
- Replace all findProviderForModel(modelId) send-path calls with provider state
- ComposerDraft gains optional provider field for persistence
CliErrorBanner only matched 'CLI not found' and 'not found at', so
Copilot-specific errors were silently swallowed with no visible feedback.

- Add 'package not found' marker (covers @github/copilot npm package missing)
- Add 'exited unexpectedly' marker (covers auth / subscription failures)
- Extend extractInstallCommand to also extract backtick-quoted commands so
  the auth error surfaces 'gh auth login' as a copyable code block
@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Apr 11, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Adds GitHub Copilot support end-to-end: new CopilotProvider with SDK/CLI session management on the server, DI/registry wiring, a provider.listModels RPC and transport, dynamic model discovery and UI changes (icons, selectors, settings, drafts), contracts/types for ProviderModelInfo, docs, tests, and package deps.

Changes

Cohort / File(s) Summary
Server provider & DI
apps/server/src/providers/copilot/copilot-provider.ts, apps/server/src/container.ts, apps/server/src/index.ts
New exported CopilotProvider implementing IAgentProvider with Copilot SDK/CLI lifecycle, per-session maps (resume/evict/stop/shutdown), event→AgentEvent mapping; registered as a Singleton and wired into startup/shutdown and WS server options.
Server RPC & Router
apps/server/src/transport/ws-router.ts, packages/contracts/src/ws/methods.ts
Added "provider.listModels" WebSocket RPC and router branch; router deps now accept providerRegistry for runtime provider resolution and exposure of provider APIs.
Contracts & provider model types
packages/contracts/src/providers/models.ts, packages/contracts/src/providers/interfaces.ts, packages/contracts/src/index.ts
Added ProviderModelInfoSchema and ModelPolicyStateSchema, exported ProviderModelInfo type, and optional IAgentProvider.listModels?(): Promise<ProviderModelInfo[]>.
Transport (client) wiring
apps/web/src/transport/ws-transport.ts, apps/web/src/transport/types.ts
Added transport method listProviderModels(providerId) and re-exported ProviderModelInfo type for client-side dynamic model fetching.
Frontend model registry & dynamic models
apps/web/src/lib/model-registry.ts, apps/web/src/stores/providerModelStore.ts
Added dynamic provider flag, copilot provider entry, toModelDefinition() and getDefaultProviderId() helpers; new Zustand store useProviderModelStore to fetch/cache provider models and surface loading/errors.
Composer & Model selector UI
apps/web/src/components/chat/Composer.tsx, apps/web/src/components/chat/ModelSelector.tsx
Composer now tracks provider in state/drafts; ModelSelector API changed to onSelect(modelId, providerId), supports dynamic loading, grouping, disabled/multiplier UI, selectedProviderId, and scrollable submenu for large lists.
Settings & provider CLI path UI
apps/web/src/components/settings/sections/ModelSection.tsx, packages/contracts/src/models/settings.ts
Added provider.cli.copilot to schemas and a "Copilot CLI path" setting row; settings UI integrates dynamic provider model resolution and switches to Select when >6 models.
Error handling & CLI banners
apps/server/src/services/agent-service.ts, apps/web/src/components/chat/CliErrorBanner.tsx
Normalized Copilot CLI-not-found errors to show Copilot-specific install/setup message; broadened CLI error markers and improved install-command extraction logic.
Icons, drafts, tests, docs & deps
apps/web/src/components/chat/ProviderIcons.tsx, apps/web/src/stores/composerDraftStore.ts, apps/web/src/__tests__/mocks/transport.ts, docs/plans/*, package.json
Added CopilotIcon export, extended ComposerDraft with provider?: string, added mock listProviderModels, new Copilot design/plan docs, and added @github/copilot/@github/copilot-sdk dependencies.
Misc wiring
apps/server/src/transport/ws-router.ts, apps/server/src/index.ts
Passed providerRegistry into WS/server wiring and updated RouterDeps to include IProviderRegistry enabling runtime provider resolution and provider shutdown sequencing.

Sequence Diagram(s)

sequenceDiagram
    participant Web as Web Composer
    participant WS as WS Transport/Router
    participant Server as Agent Service
    participant Provider as CopilotProvider
    participant SDK as Copilot SDK
    participant CLI as Copilot CLI

    Web->>WS: RPC sendMessage({ providerId: "copilot", sessionId, message, ... })
    WS->>Server: dispatch sendMessage
    Server->>Provider: sendMessage(params)

    Note over Provider: ensure SDK client exists / refresh if CLI path changed
    Provider->>SDK: create/get SDK client & session (or resume)
    Provider->>SDK: register callbacks (textDelta, message, tool events, usage, idle)
    SDK->>CLI: communicate via JSON-RPC over stdio

    loop streamed events
        CLI-->>SDK: stream events (deltas, tool events, usage, idle)
        SDK-->>Provider: invoke callbacks
        Provider->>Server: emit AgentEvent (textDelta / tool / usage)
        Server-->>WS: forward events
        WS-->>Web: deliver events to client
    end

    SDK-->>Provider: session.idle
    Provider->>Provider: deregister callbacks, persist sdkSessionId
    Provider->>Server: emit AgentEvent(ended)
    Server-->>Web: end stream
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~65 minutes

Possibly related PRs

Poem

🐰 I nibble bytes by moonlit code,

Copilot sessions hum and grow.
Models sparkle, icons gleam,
Drafts remember each small dream.
A happy hop — new provider, new glow.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title "feat: add GitHub Copilot as an AI provider" clearly and concisely describes the main change—adding Copilot as a new provider—which aligns with the substantial changeset across server, web, and contract files.
Description check ✅ Passed The PR description thoroughly covers What, Why, and Key Changes sections with comprehensive detail on CopilotProvider, model routing, registry updates, settings, error handling, icons, and draft persistence.
Linked Issues check ✅ Passed The PR meaningfully addresses core objectives from issue #90: implements a second provider (Copilot) alongside Claude, tracks provider state explicitly to enable mid-conversation switching, adds provider model discovery via listModels RPC, and supports provider switching via ModelSelector updates.
Out of Scope Changes check ✅ Passed All changes remain focused on implementing Copilot provider integration: backend adapter, DI wiring, provider routing, model registry, settings, error handling, UI components, and transport layer—all directly supporting the stated objectives.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/gh-copilot

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
apps/web/src/components/chat/Composer.tsx (1)

938-938: ⚠️ Potential issue | 🟠 Major

Add provider to the dependency array at line 938.

The handleSend callback uses provider at lines 819, 885, 910, and 921, but it's missing from the dependency array. Without it, the callback may use a stale provider value if the provider changes.

Dependency array missing `provider`
}, [input, attachments, isAgentRunning, isNewThread, newThreadMode, newThreadBranch, workspaceId, threadId, sendMessage, modelId, reasoning, mode, access, namingMode, customBranchName, selectedWorktree, injectFileContent, collectAndClearAttachments, clearDraftFromStore, preparingWorktree, branchFromMessageId, branchExecMode, branchTargetBranch, branchNamingMode, branchCustomName, branchWorktreePath, activeThread, branchThread, autoPreviewBranch, onBranchModeExit]);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/src/components/chat/Composer.tsx` at line 938, The handleSend
callback uses the provider variable but the useCallback dependency array (the
one ending with onBranchModeExit) does not include provider, which can cause
stale closures; update the dependency array for the handleSend useCallback to
include provider so the callback is recreated when provider changes (locate the
handleSend function and its useCallback dependency array and add provider to
that array).
🧹 Nitpick comments (3)
apps/web/src/components/chat/CliErrorBanner.tsx (1)

17-21: Greedy backtick match may extract unintended content.

The regex /([^]+)/will match the first backtick-quoted string in the error message, which could be a path or other non-command content. For example, if an error contains "File/usr/bin/copilotnot found", it would extract/usr/bin/copilot` instead of an install command.

Consider making the extraction more specific, e.g., matching command-like patterns starting with known tools:

♻️ Suggested improvement
 function extractInstallCommand(error: string): string | null {
   const npmMatch = error.match(/npm install[^\n]+/);
   if (npmMatch) return npmMatch[0];
-  const backtickMatch = error.match(/`([^`]+)`/);
-  return backtickMatch ? backtickMatch[1] : null;
+  // Match backtick-quoted commands starting with common CLI tools
+  const backtickMatch = error.match(/`((?:gh|npm|npx|brew|pip)\s[^`]+)`/);
+  return backtickMatch ? backtickMatch[1] : null;
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/src/components/chat/CliErrorBanner.tsx` around lines 17 - 21,
extractInstallCommand currently uses a generic backtick capture that may grab
non-command paths; update it to only capture backtick-quoted strings that look
like install/command patterns (e.g., start with
npm|yarn|pnpm|npx|pnpx|brew|curl|pip or include typical install verbs like
"install") or match command-like tokens (alphanumerics, dashes, slashes, spaces,
and flags) so paths like `/usr/bin/copilot` are ignored; modify the
backtickMatch regex in extractInstallCommand to require a command-prefix or
command-like pattern inside backticks and keep the existing npm install
detection as the primary match, falling back to the stricter backtick command
match if needed.
apps/web/src/components/settings/sections/ModelSection.tsx (1)

188-222: Consider extracting the duplicated grouping logic into a helper.

The grouped Select rendering logic (Map-based grouping, conditional SelectLabel, item mapping) is duplicated between the Default model and Fallback model selectors. This is functional but increases maintenance surface.

♻️ Optional: Extract a reusable grouped select renderer
// Helper outside the component
function renderGroupedOptions(options: Array<{ value: string; label: string; group?: string }>) {
  const groups = new Map<string, typeof options>();
  for (const m of options) {
    const g = m.group ?? "";
    if (!groups.has(g)) groups.set(g, []);
    groups.get(g)!.push(m);
  }
  return Array.from(groups.entries()).map(([g, items]) => (
    <SelectGroup key={g}>
      {g && <SelectLabel>{g}</SelectLabel>}
      {items.map((m) => (
        <SelectItem key={m.value} value={m.value}>
          {m.label}
        </SelectItem>
      ))}
    </SelectGroup>
  ));
}

// Then in JSX:
{modelOptions.some((m) => m.group)
  ? renderGroupedOptions(modelOptions)
  : modelOptions.map((m) => (
      <SelectItem key={m.value} value={m.value}>{m.label}</SelectItem>
    ))}

Also applies to: 230-271

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/src/components/settings/sections/ModelSection.tsx` around lines 188
- 222, The Select rendering duplicates Map-based grouping logic for modelOptions
in the Default and Fallback selectors; extract that logic into a helper (e.g.,
renderGroupedOptions) that accepts options:
Array<{value:string;label:string;group?:string}> and returns the grouped JSX
using SelectGroup/SelectLabel/SelectItem, then replace the inline IIFE in both
places with a call to renderGroupedOptions(modelOptions) while keeping the
non-grouped fallback mapping and preserving props like value=modelId and
onValueChange/handleModelChange (and the SegControl branch) to avoid changing
behavior.
docs/plans/2026-04-11-gh-copilot-provider-design.md (1)

13-18: Minor: Add language specifiers to fenced code blocks.

Static analysis flagged three code blocks without language specifiers. For documentation hygiene, consider adding text or removing the fences for plain prose content.

📝 Suggested fix
-```
+```text
 CopilotProvider (implements IAgentProvider)
   └── CopilotClient (from `@github/copilot-sdk`)
        └── spawns `@github/copilot` CLI via JSON-RPC over stdio
             └── uses gh CLI auth (pre-authenticated by user)

Apply similar changes to the error message blocks at lines 188 and 197.
</details>


Also applies to: 188-191, 197-199

<details>
<summary>🤖 Prompt for AI Agents</summary>

Verify each finding against the current code and only fix it if needed.

In @docs/plans/2026-04-11-gh-copilot-provider-design.md around lines 13 - 18,
Update the three fenced code blocks that contain plain text to include a
language specifier (e.g., change totext) or remove the fences entirely:
the block starting with "CopilotProvider (implements IAgentProvider) └──
CopilotClient (from @github/copilot-sdk) ..." and the two plain error message
blocks (the short error strings around the later messages) should be updated so
static analysis no longer flags them; ensure each fence uses "text" if you want
to keep the block formatting.


</details>

</blockquote></details>

</blockquote></details>

<details>
<summary>🤖 Prompt for all review comments with AI agents</summary>

Verify each finding against the current code and only fix it if needed.

Inline comments:
In @apps/web/src/lib/model-registry.ts:

  • Around line 103-108: The Copilot Claude model entries in the model registry
    use inconsistent dot vs hyphen version formatting which breaks exact lookups
    (see findModelById and matchDatedVariant). Update the id strings in the registry
    entries for the Copilot models so they use hyphens: change "claude-opus-4.6" ->
    "claude-opus-4-6", "claude-opus-4.5" -> "claude-opus-4-5", "claude-sonnet-4.6"
    -> "claude-sonnet-4-6", and "claude-sonnet-4.5" -> "claude-sonnet-4-5"; leave
    "claude-haiku-4-5" and "claude-opus-4-6-fast" unchanged. Ensure these updated
    ids match the native Claude provider naming so findModelById and
    matchDatedVariant will resolve consistently.

In @package.json:

  • Around line 39-41: Remove the @github/copilot-sdk entry from the root
    package.json dependencies and add it to apps/server/package.json dependencies
    with version "^0.2.2"; the SDK is only imported/used in
    apps/server/src/providers/copilot/copilot-provider.ts (imports CopilotClient and
    CopilotSession), so declaring it in the server package prevents installing it
    into unrelated workspaces—after updating package.json files, run your workspace
    install (pnpm/yarn/npm) to update the lockfile and node_modules.

Outside diff comments:
In @apps/web/src/components/chat/Composer.tsx:

  • Line 938: The handleSend callback uses the provider variable but the
    useCallback dependency array (the one ending with onBranchModeExit) does not
    include provider, which can cause stale closures; update the dependency array
    for the handleSend useCallback to include provider so the callback is recreated
    when provider changes (locate the handleSend function and its useCallback
    dependency array and add provider to that array).

Nitpick comments:
In @apps/web/src/components/chat/CliErrorBanner.tsx:

  • Around line 17-21: extractInstallCommand currently uses a generic backtick
    capture that may grab non-command paths; update it to only capture
    backtick-quoted strings that look like install/command patterns (e.g., start
    with npm|yarn|pnpm|npx|pnpx|brew|curl|pip or include typical install verbs like
    "install") or match command-like tokens (alphanumerics, dashes, slashes, spaces,
    and flags) so paths like /usr/bin/copilot are ignored; modify the
    backtickMatch regex in extractInstallCommand to require a command-prefix or
    command-like pattern inside backticks and keep the existing npm install
    detection as the primary match, falling back to the stricter backtick command
    match if needed.

In @apps/web/src/components/settings/sections/ModelSection.tsx:

  • Around line 188-222: The Select rendering duplicates Map-based grouping logic
    for modelOptions in the Default and Fallback selectors; extract that logic into
    a helper (e.g., renderGroupedOptions) that accepts options:
    Array<{value:string;label:string;group?:string}> and returns the grouped JSX
    using SelectGroup/SelectLabel/SelectItem, then replace the inline IIFE in both
    places with a call to renderGroupedOptions(modelOptions) while keeping the
    non-grouped fallback mapping and preserving props like value=modelId and
    onValueChange/handleModelChange (and the SegControl branch) to avoid changing
    behavior.

In @docs/plans/2026-04-11-gh-copilot-provider-design.md:

  • Around line 13-18: Update the three fenced code blocks that contain plain text
    to include a language specifier (e.g., change totext) or remove the
    fences entirely: the block starting with "CopilotProvider (implements
    IAgentProvider) └── CopilotClient (from @github/copilot-sdk) ..." and the two
    plain error message blocks (the short error strings around the later messages)
    should be updated so static analysis no longer flags them; ensure each fence
    uses "text" if you want to keep the block formatting.

</details>

<details>
<summary>🪄 Autofix (Beta)</summary>

Fix all unresolved CodeRabbit comments on this PR:

- [ ] <!-- {"checkboxId": "4b0d0e0a-96d7-4f10-b296-3a18ea78f0b9"} --> Push a commit to this branch (recommended)
- [ ] <!-- {"checkboxId": "ff5b1114-7d8c-49e6-8ac1-43f82af23a33"} --> Create a new PR with the fixes

</details>

---

<details>
<summary>ℹ️ Review info</summary>

<details>
<summary>⚙️ Run configuration</summary>

**Configuration used**: defaults

**Review profile**: CHILL

**Plan**: Pro

**Run ID**: `6eacefe0-6d08-4bcd-bd12-946b5bf08464`

</details>

<details>
<summary>📥 Commits</summary>

Reviewing files that changed from the base of the PR and between ae34100ece7645790fb9b38d4acd6d279a34f432 and e10095236204f7bd700bbe2b4b828ed7854cb8fd.

</details>

<details>
<summary>⛔ Files ignored due to path filters (1)</summary>

* `bun.lock` is excluded by `!**/*.lock`

</details>

<details>
<summary>📒 Files selected for processing (14)</summary>

* `apps/server/src/container.ts`
* `apps/server/src/providers/copilot/copilot-provider.ts`
* `apps/server/src/services/agent-service.ts`
* `apps/web/src/components/chat/CliErrorBanner.tsx`
* `apps/web/src/components/chat/Composer.tsx`
* `apps/web/src/components/chat/ModelSelector.tsx`
* `apps/web/src/components/chat/ProviderIcons.tsx`
* `apps/web/src/components/settings/sections/ModelSection.tsx`
* `apps/web/src/lib/model-registry.ts`
* `apps/web/src/stores/composerDraftStore.ts`
* `docs/plans/2026-04-11-gh-copilot-provider-design.md`
* `docs/plans/2026-04-11-gh-copilot-provider-plan.md`
* `package.json`
* `packages/contracts/src/models/settings.ts`

</details>

</details>

<!-- This is an auto-generated comment by CodeRabbit for review status -->

Comment on lines +39 to +41
"dependencies": {
"@github/copilot-sdk": "^0.2.2"
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check if the package exists on npm and get latest version info
curl -s https://registry.npmjs.org/@github/copilot-sdk | jq '{name: .name, latest: ."dist-tags".latest, versions: (.versions | keys | .[-5:])}'

Repository: Mzeey-Emipre/mcode

Length of output: 244


🏁 Script executed:

#!/bin/bash
# Find all package.json files to understand the monorepo structure
echo "=== Package.json files in repo ==="
find . -name "package.json" -type f | head -20

# Check the root package.json to see the actual content
echo -e "\n=== Root package.json (lines 35-45) ==="
sed -n '35,45p' package.json

# Search for imports of `@github/copilot-sdk`
echo -e "\n=== Imports of `@github/copilot-sdk` ==="
rg "@github/copilot-sdk" --type ts --type tsx --type js

Repository: Mzeey-Emipre/mcode

Length of output: 510


🏁 Script executed:

#!/bin/bash
# Check if copilot-provider.ts exists
echo "=== Looking for copilot provider file ==="
find . -name "*copilot*" -type f

# Search for any references to copilot-sdk with broader patterns
echo -e "\n=== Searching for copilot-sdk references (all files) ==="
rg "copilot-sdk" 

# Also search for any imports from `@github` scope
echo -e "\n=== Searching for `@github` imports ==="
rg "@github" --type js --type ts 2>/dev/null || rg "@github"

Repository: Mzeey-Emipre/mcode

Length of output: 2790


Move @github/copilot-sdk to apps/server/package.json.

The dependency is only used in apps/server/src/providers/copilot/copilot-provider.ts where it's imported as CopilotClient and CopilotSession. Since this is a monorepo, the dependency should be declared in apps/server/package.json rather than at the workspace root to avoid unnecessary installation in apps/desktop, apps/web, and packages/*.

The package version ^0.2.2 is confirmed valid—it's the latest published version on npm.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@package.json` around lines 39 - 41, Remove the `@github/copilot-sdk` entry from
the root package.json dependencies and add it to apps/server/package.json
dependencies with version "^0.2.2"; the SDK is only imported/used in
apps/server/src/providers/copilot/copilot-provider.ts (imports CopilotClient and
CopilotSession), so declaring it in the server package prevents installing it
into unrelated workspaces—after updating package.json files, run your workspace
install (pnpm/yarn/npm) to update the lockfile and node_modules.

…very

Defines ProviderModelInfoSchema and ModelPolicyStateSchema in a new
providers/models.ts module, and re-exports them from the contracts index.
Adds the provider.listModels WS method that takes a providerId and
returns an array of ProviderModelInfo, enabling dynamic model discovery
over the WebSocket RPC protocol.
Makes dynamic model discovery opt-in per provider. Copilot will implement
this; other providers (Claude, Codex, Gemini) use static model lists and
can leave the method unimplemented.
Add listModels() to CopilotProvider using the SDK client, with a helper
to infer vendor group from model ID prefixes. Wire the provider.listModels
RPC method through the router by adding providerRegistry to RouterDeps and
dispatching to the resolved provider's listModels().
…c registry

- Add listProviderModels to McodeTransport interface and ws-transport RPC
- Add ProviderModelInfo re-export through transport/types and index
- Create providerModelStore for caching dynamically fetched provider models
- Add dynamic flag and multiplier/policyState fields to model-registry types
- Replace hardcoded Copilot models with empty dynamic entry
- Add toModelDefinition converter for ProviderModelInfo -> ModelDefinition
- Update mock transport with listProviderModels stub
…cy indicators

ModelSelector fetches Copilot (and other dynamic provider) models on dropdown
open and merges them into the resolved provider list. Model rows now show a
multiplier badge (e.g. "3x") and a Lock icon for org-disabled models.

ModelSection settings panel fetches dynamic models on mount so Copilot models
appear in the default/fallback model selects with multiplier annotations.
Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (3)
apps/web/src/stores/providerModelStore.ts (1)

51-56: Consider resetting loading state in clearModels.

If clearModels is called while a fetch is in-flight (e.g., on disconnect), loading[providerId] remains true, which will block future fetchModels calls for that provider. Consider also resetting the loading state.

♻️ Proposed fix
   clearModels: (providerId: string) => {
     set((s) => ({
       models: { ...s.models, [providerId]: [] },
       errors: { ...s.errors, [providerId]: null },
+      loading: { ...s.loading, [providerId]: false },
     }));
   },
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/src/stores/providerModelStore.ts` around lines 51 - 56, The
clearModels function currently resets models and errors but leaves loading for
the provider unchanged; update clearModels to also reset loading for the
provider (set loading[providerId] = false) so an in-flight or cancelled fetch
doesn't block future fetchModels calls; modify the state update in clearModels
(referencing clearModels and the models, errors, loading keys) to include
loading: { ...s.loading, [providerId]: false } in the same set call.
apps/server/src/providers/copilot/copilot-provider.ts (1)

405-413: toolProgress emits empty toolName when SDK doesn't provide it.

The tool.execution_progress SDK event apparently doesn't include toolName, so the emitted toolProgress event has toolName: "". While this satisfies the schema, it may cause confusion in the UI if the tool name is displayed. Consider looking up the tool name from a stored mapping keyed by toolCallId, or documenting this limitation.

♻️ Possible enhancement to track tool names
+    // Track tool names by toolCallId for progress events
+    const toolNames = new Map<string, string>();

     // tool.execution_start — assistant is invoking a tool
     unsubscribers.push(
       session.on("tool.execution_start", (event) => {
         const { toolCallId, toolName, arguments: toolArgs } = event.data;
         toolStartTimes.set(toolCallId, Date.now());
+        toolNames.set(toolCallId, toolName);
         this.emit("event", {
           // ...
         });
       }),
     );

     // tool.execution_progress — heartbeat while a tool runs
     unsubscribers.push(
       session.on("tool.execution_progress", (event) => {
         const { toolCallId } = event.data;
         const startedAt = toolStartTimes.get(toolCallId) ?? Date.now();
         const elapsedSeconds = (Date.now() - startedAt) / 1000;
         this.emit("event", {
           type: "toolProgress",
           threadId,
           toolCallId,
-          toolName: "",
+          toolName: toolNames.get(toolCallId) ?? "",
           elapsedSeconds,
         } satisfies AgentEvent);
       }),
     );
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/server/src/providers/copilot/copilot-provider.ts` around lines 405 -
413, The emitted toolProgress event sets toolName to an empty string because the
SDK's tool.execution_progress doesn't include it; update the handler that emits
the "toolProgress" event to look up the tool name from a stored mapping keyed by
toolCallId (e.g., toolNamesByCallId or a similar map maintained when the tool
call was created) and set toolName to that value, falling back to an empty
string only if the lookup fails; ensure this change is applied where the code
calls this.emit("event", { type: "toolProgress", threadId, toolCallId, toolName:
"", elapsedSeconds }) so the UI receives a meaningful toolName when available.
apps/web/src/components/chat/ModelSelector.tsx (1)

62-69: useEffect dependency array may cause unnecessary iterations.

The effect includes dynamicModels and dynamicLoading in the dependency array, which means the loop over MODEL_PROVIDERS runs on every store update. While fetchModels has internal guards against duplicate requests, this could be optimized to only run once per dropdown open.

♻️ Suggested optimization
+  const hasFetchedRef = useRef(false);
+
   // Fetch dynamic models when the dropdown opens
   useEffect(() => {
-    if (!open) return;
+    if (!open) {
+      hasFetchedRef.current = false;
+      return;
+    }
+    if (hasFetchedRef.current) return;
+    hasFetchedRef.current = true;
     for (const p of MODEL_PROVIDERS) {
-      if (p.dynamic && !dynamicModels[p.id]?.length && !dynamicLoading[p.id]) {
+      if (p.dynamic) {
         fetchModels(p.id);
       }
     }
-  }, [open, fetchModels, dynamicModels, dynamicLoading]);
+  }, [open, fetchModels]);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/src/components/chat/ModelSelector.tsx` around lines 62 - 69, The
effect in useEffect is rerunning on any store update because dynamicModels and
dynamicLoading are in the dependency array; restrict it to run only when the
dropdown opens by removing those store-derived dependencies and depending only
on open (and fetchModels if not stable). Keep the same loop over MODEL_PROVIDERS
and the guards (p.dynamic && !dynamicModels[p.id]?.length &&
!dynamicLoading[p.id]) inside the effect, but ensure fetchModels is memoized (or
include it) so the effect executes only once per open event and still triggers
fetchModels(p.id) when needed; reference useEffect, MODEL_PROVIDERS,
dynamicModels, dynamicLoading, fetchModels, and open when making this change.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@apps/server/src/providers/copilot/copilot-provider.ts`:
- Around line 124-135: The current sdkModels.map transformation in
copilot-provider.ts unsafely casts supportedReasoningEfforts,
defaultReasoningEffort and policy.state to ProviderModelInfo types; update this
mapping to explicitly validate and sanitize those fields against the allowed
enums (e.g., allowed reasoning efforts ["low","medium","high","xhigh"] and
policy states ["enabled","disabled","unconfigured"]) instead of using `as`
casts: filter or map unsupported reasoning values out (or replace with a safe
default/undefined), ensure defaultReasoningEffort is set only if it matches the
allowed set, and set policy to undefined or a default state when policy.state is
unrecognized; keep references to sdkModels.map, inferModelGroup, and
ProviderModelInfo to locate the change.

---

Nitpick comments:
In `@apps/server/src/providers/copilot/copilot-provider.ts`:
- Around line 405-413: The emitted toolProgress event sets toolName to an empty
string because the SDK's tool.execution_progress doesn't include it; update the
handler that emits the "toolProgress" event to look up the tool name from a
stored mapping keyed by toolCallId (e.g., toolNamesByCallId or a similar map
maintained when the tool call was created) and set toolName to that value,
falling back to an empty string only if the lookup fails; ensure this change is
applied where the code calls this.emit("event", { type: "toolProgress",
threadId, toolCallId, toolName: "", elapsedSeconds }) so the UI receives a
meaningful toolName when available.

In `@apps/web/src/components/chat/ModelSelector.tsx`:
- Around line 62-69: The effect in useEffect is rerunning on any store update
because dynamicModels and dynamicLoading are in the dependency array; restrict
it to run only when the dropdown opens by removing those store-derived
dependencies and depending only on open (and fetchModels if not stable). Keep
the same loop over MODEL_PROVIDERS and the guards (p.dynamic &&
!dynamicModels[p.id]?.length && !dynamicLoading[p.id]) inside the effect, but
ensure fetchModels is memoized (or include it) so the effect executes only once
per open event and still triggers fetchModels(p.id) when needed; reference
useEffect, MODEL_PROVIDERS, dynamicModels, dynamicLoading, fetchModels, and open
when making this change.

In `@apps/web/src/stores/providerModelStore.ts`:
- Around line 51-56: The clearModels function currently resets models and errors
but leaves loading for the provider unchanged; update clearModels to also reset
loading for the provider (set loading[providerId] = false) so an in-flight or
cancelled fetch doesn't block future fetchModels calls; modify the state update
in clearModels (referencing clearModels and the models, errors, loading keys) to
include loading: { ...s.loading, [providerId]: false } in the same set call.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 3cf8a5b3-464e-4d9f-b27c-52d2fcdca82a

📥 Commits

Reviewing files that changed from the base of the PR and between e100952 and b10bc38.

📒 Files selected for processing (14)
  • apps/server/src/index.ts
  • apps/server/src/providers/copilot/copilot-provider.ts
  • apps/server/src/transport/ws-router.ts
  • apps/web/src/__tests__/mocks/transport.ts
  • apps/web/src/components/chat/ModelSelector.tsx
  • apps/web/src/components/settings/sections/ModelSection.tsx
  • apps/web/src/lib/model-registry.ts
  • apps/web/src/stores/providerModelStore.ts
  • apps/web/src/transport/types.ts
  • apps/web/src/transport/ws-transport.ts
  • packages/contracts/src/index.ts
  • packages/contracts/src/providers/interfaces.ts
  • packages/contracts/src/providers/models.ts
  • packages/contracts/src/ws/methods.ts
🚧 Files skipped from review as they are similar to previous changes (2)
  • apps/web/src/components/settings/sections/ModelSection.tsx
  • apps/web/src/lib/model-registry.ts

Comment on lines +124 to +135
return sdkModels.map((m) => ({
id: m.id,
name: m.name,
group: inferModelGroup(m.id),
contextWindow: m.capabilities?.limits?.max_context_window_tokens,
supportsVision: m.capabilities?.supports?.vision,
supportsReasoning: m.capabilities?.supports?.reasoningEffort,
supportedReasoningEfforts: m.supportedReasoningEfforts as ProviderModelInfo["supportedReasoningEfforts"],
defaultReasoningEffort: m.defaultReasoningEffort as ProviderModelInfo["defaultReasoningEffort"],
policy: m.policy ? { state: m.policy.state as "enabled" | "disabled" | "unconfigured" } : undefined,
multiplier: m.billing?.multiplier,
}));
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🌐 Web query:

@github/copilot-sdk npm package API documentation

💡 Result:

The @github/copilot-sdk npm package is the Node.js/TypeScript SDK for the GitHub Copilot CLI, enabling programmatic control via JSON-RPC. It is in public preview (version 0.2.1 as of April 2026) and may have breaking changes. Official documentation and API reference are in the GitHub repository at https://github.com/github/copilot-sdk, specifically the nodejs/README.md: https://github.com/github/copilot-sdk/blob/main/nodejs/README.md. Key API details from the README: CopilotClient constructor: new CopilotClient(options?: CopilotClientOptions) Options include: - cliPath: string (path to CLI executable) - cliArgs: string[] - cliUrl: string (connect to existing CLI server) - autoStart: boolean (default true) Methods: - start: Promise - Start CLI server and connect. - stop: Promise - Stop the CLI server. - createSession(config?: SessionConfig): Promise - Create conversation session. Session example: const session = await client.createSession({ model: "gpt-5", onPermissionRequest: approveAll, }); Supports custom providers (BYOK) like OpenAI, Azure, Anthropic, Ollama with ProviderConfig (type, baseUrl, apiKey, etc.). Requirements: Node.js >=18, GitHub Copilot CLI (bundled). Installation: npm install @github/copilot-sdk npm page: https://www.npmjs.com/package/@github/copilot-sdk GitHub Docs getting started: https://docs.github.com/en/copilot/how-tos/copilot-sdk/sdk-getting-started

Citations:


🏁 Script executed:

# First, find the structure of the codebase
git ls-files | grep -E "(copilot-provider|ws-router|ProviderModelInfo)" | head -20

Repository: Mzeey-Emipre/mcode

Length of output: 256


🏁 Script executed:

# Get context around the lines mentioned in the review
cat -n apps/server/src/providers/copilot/copilot-provider.ts | sed -n '100,150p'

Repository: Mzeey-Emipre/mcode

Length of output: 2541


🏁 Script executed:

# Search for ProviderModelInfo type definition
rg "type ProviderModelInfo|interface ProviderModelInfo" -A 20

Repository: Mzeey-Emipre/mcode

Length of output: 189


🏁 Script executed:

# Search for ws-router.ts and its validation logic
find . -name "ws-router.ts" -o -name "ws-router.js"

Repository: Mzeey-Emipre/mcode

Length of output: 102


🏁 Script executed:

# Find and inspect ProviderModelInfoSchema
cat -n packages/contracts/src/providers/models.ts

Repository: Mzeey-Emipre/mcode

Length of output: 1952


🏁 Script executed:

# Look at ws-router.ts to see how validation is done
cat -n apps/server/src/transport/ws-router.ts | head -100

Repository: Mzeey-Emipre/mcode

Length of output: 4395


🏁 Script executed:

# Search for validation of supportedReasoningEfforts and related fields
rg "supportedReasoningEfforts|defaultReasoningEffort" -B 3 -A 3

Repository: Mzeey-Emipre/mcode

Length of output: 2085


🏁 Script executed:

# Search for where listModels is called and if responses are validated
rg "listModels|providerRegistry" -B 2 -A 5 apps/server/src/transport/ws-router.ts

Repository: Mzeey-Emipre/mcode

Length of output: 713


🏁 Script executed:

# Look for WS_METHODS definition and response validation
rg "WS_METHODS|resultSchema|validate.*result" --type ts | head -30

Repository: Mzeey-Emipre/mcode

Length of output: 1068


🏁 Script executed:

# Check how the provider registry methods are called and if results are validated
cat -n apps/server/src/transport/ws-router.ts | sed -n '100,200p'

Repository: Mzeey-Emipre/mcode

Length of output: 3560


🏁 Script executed:

# Look at WS_METHODS definition to see the result schema for provider.listModels
rg "provider.listModels" packages/contracts/src/ws/methods.ts -B 5 -A 10

Repository: Mzeey-Emipre/mcode

Length of output: 456


🏁 Script executed:

# Check the WS_METHODS file to understand response validation better
cat -n packages/contracts/src/ws/methods.ts | grep -A 30 "provider"

Repository: Mzeey-Emipre/mcode

Length of output: 3362


Type casts assume SDK contract stability, but validation drift is tolerated.

The as casts on supportedReasoningEfforts, defaultReasoningEffort, and policy.state assume the SDK returns values matching the expected enums (["low", "medium", "high", "xhigh"] for reasoning effort; ["enabled", "disabled", "unconfigured"] for policy state). If the SDK introduces new values, the response validation in ws-router.ts will log a warning but still return the unvalidated result to the client (line 131 of ws-router.ts: "Still return the result - schema drift should not block responses"). Consider documenting this tolerance for schema drift or add explicit validation/filtering at the point of transformation.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/server/src/providers/copilot/copilot-provider.ts` around lines 124 -
135, The current sdkModels.map transformation in copilot-provider.ts unsafely
casts supportedReasoningEfforts, defaultReasoningEffort and policy.state to
ProviderModelInfo types; update this mapping to explicitly validate and sanitize
those fields against the allowed enums (e.g., allowed reasoning efforts
["low","medium","high","xhigh"] and policy states
["enabled","disabled","unconfigured"]) instead of using `as` casts: filter or
map unsupported reasoning values out (or replace with a safe default/undefined),
ensure defaultReasoningEffort is set only if it matches the allowed set, and set
policy to undefined or a default state when policy.state is unrecognized; keep
references to sdkModels.map, inferModelGroup, and ProviderModelInfo to locate
the change.

…ity check

- Add @github/copilot as explicit root dependency so bun hoists it from
  .bun/ to node_modules/@github/copilot (was installed as transitive dep
  of copilot-sdk but never symlinked, causing all require.resolve calls to fail)
- Replace _require.resolve('@github/copilot') with existsSync probe matching
  the SDK's own getBundledCliPath() strategy — the package has no main export
  so require.resolve always throws even when the package is present
- Show AlertCircle + RefreshCw icons in ModelSelector when dynamic model
  fetch fails; clicking the provider row clears the error and retries
Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
apps/server/src/providers/copilot/copilot-provider.ts (1)

36-43: Consider making inferModelGroup more maintainable.

The prefix matching works but may drift as vendors introduce new model naming schemes. This is acceptable for now, but consider extracting the vendor prefixes to a constant map for easier updates.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/server/src/providers/copilot/copilot-provider.ts` around lines 36 - 43,
Extract the hard-coded prefix checks in inferModelGroup into a single constant
map (e.g., MODEL_PREFIX_MAP or MODEL_VENDOR_PREFIXES) that maps vendor names
("OpenAI", "Anthropic", "Google", "xAI") to an array of prefixes, then rewrite
inferModelGroup to iterate the map and return the vendor when
modelId.startsWith(any prefix); keep function name inferModelGroup and preserve
current behavior (including undefined fallback), make the prefixes
constant/exported for easy updates, and add/update any tests that rely on these
mappings.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@apps/server/src/providers/copilot/copilot-provider.ts`:
- Around line 36-43: Extract the hard-coded prefix checks in inferModelGroup
into a single constant map (e.g., MODEL_PREFIX_MAP or MODEL_VENDOR_PREFIXES)
that maps vendor names ("OpenAI", "Anthropic", "Google", "xAI") to an array of
prefixes, then rewrite inferModelGroup to iterate the map and return the vendor
when modelId.startsWith(any prefix); keep function name inferModelGroup and
preserve current behavior (including undefined fallback), make the prefixes
constant/exported for easy updates, and add/update any tests that rely on these
mappings.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 9ad52d9a-1763-4fcf-818c-4ad1a4902cbc

📥 Commits

Reviewing files that changed from the base of the PR and between 8ae63f0 and 0265b5b.

⛔ Files ignored due to path filters (1)
  • bun.lock is excluded by !**/*.lock
📒 Files selected for processing (4)
  • apps/server/src/providers/copilot/copilot-provider.ts
  • apps/web/src/components/chat/ModelSelector.tsx
  • apps/web/src/stores/providerModelStore.ts
  • package.json
✅ Files skipped from review due to trivial changes (2)
  • package.json
  • apps/web/src/stores/providerModelStore.ts

The SDK's createSession() auto-starts the connection via autoStart,
but listModels() does not -- it throws 'Client not connected' if
start() hasn't been called. Eagerly start the client in refreshClient()
so both sendMessage and listModels work.
Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

♻️ Duplicate comments (1)
apps/server/src/providers/copilot/copilot-provider.ts (1)

137-139: ⚠️ Potential issue | 🟡 Minor

Sanitize SDK enum fields instead of force-casting them.

Line [137]–Line [139] trusts SDK values via as casts; unknown enum values can drift through to clients and break contract expectations.

What are the currently documented allowed values for `supportedReasoningEfforts`, `defaultReasoningEffort`, and `policy.state` in `@github/copilot-sdk` model metadata returned by `listModels()`?
✅ Safer mapping pattern
+ const allowedReasoning = new Set(["low", "medium", "high", "xhigh"] as const);
+ const allowedPolicy = new Set(["enabled", "disabled", "unconfigured"] as const);
...
- supportedReasoningEfforts: m.supportedReasoningEfforts as ProviderModelInfo["supportedReasoningEfforts"],
- defaultReasoningEffort: m.defaultReasoningEffort as ProviderModelInfo["defaultReasoningEffort"],
- policy: m.policy ? { state: m.policy.state as "enabled" | "disabled" | "unconfigured" } : undefined,
+ supportedReasoningEfforts: m.supportedReasoningEfforts?.filter((v) =>
+   allowedReasoning.has(v as (typeof m.supportedReasoningEfforts)[number]),
+ ) as ProviderModelInfo["supportedReasoningEfforts"] | undefined,
+ defaultReasoningEffort: allowedReasoning.has(m.defaultReasoningEffort as never)
+   ? (m.defaultReasoningEffort as ProviderModelInfo["defaultReasoningEffort"])
+   : undefined,
+ policy: m.policy && allowedPolicy.has(m.policy.state as never)
+   ? { state: m.policy.state as "enabled" | "disabled" | "unconfigured" }
+   : undefined,
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/server/src/providers/copilot/copilot-provider.ts` around lines 137 -
139, The current code force-casts SDK enum fields (supportedReasoningEfforts,
defaultReasoningEffort, and policy.state) from the Copilot SDK model metadata
(variable m) into ProviderModelInfo types, which can pass unknown values to
clients; update the mapping in copilot-provider.ts to validate and sanitize
these fields instead of using `as` casts: for supportedReasoningEfforts filter
m.supportedReasoningEfforts against the explicit allowed list (whitelist) and
map only known values, for defaultReasoningEffort check that
m.defaultReasoningEffort is one of the allowed enum values before assigning (or
set undefined/default if not), and for policy use a guarded check on
m.policy?.state to only accept "enabled" | "disabled" | "unconfigured"
(otherwise set policy undefined or a safe default); reference the fields
supportedReasoningEfforts, defaultReasoningEffort, and policy.state and
ProviderModelInfo when implementing these safe mappings.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@apps/server/src/providers/copilot/copilot-provider.ts`:
- Around line 373-381: The toolProgress events currently emit an empty toolName;
add a per-call-name map and populate it when handling
session.on("tool.execution_start") (use the same toolCallId key you store in
toolStartTimes) so you can later look up the real tool name when emitting
"toolProgress" events; update the two places that set toolName: "" (around the
current tool progress emission logic) to use toolNameMap.get(toolCallId) || ""
(or similar) and ensure you clear the entry on tool completion/stop to avoid
leaks.
- Around line 249-255: The current logic starts a new runTurn(sessionId, ...)
immediately when an existing session is present, causing overlapping runTurn
executions and duplicate listeners; instead serialize turns per session by
adding a per-session queue or "inFlight" flag on the session entry returned by
this.sessions.get(sessionId) and enqueue the new message or await the prior
runTurn to finish before calling runTurn. Concretely: extend the session
container (the object from this.sessions.get(sessionId)) with an inFlight
Promise/boolean or a queue array, update the existing branch that currently does
existing.lastUsedAt = Date.now() to push the message into that queue or await
existing.inFlight, and modify runTurn to resolve the next queued item and clear
the inFlight marker when finished so no two runTurn(sessionId, ...) execute
concurrently and duplicate listeners on existing.session are not registered.
- Around line 106-109: The code unconditionally passes shell: true into
execFileAsync when probing cliPath (using execFileAsync and cliPath), which
risks shell injection; change the probe to set shell: process.platform ===
"win32" so shell mode is only used on Windows, preserve timeout: 5000 and
existing behavior (return null on success), and apply the same platform-gated
change in the corresponding probe that uses execFileAsync for the Codex CLI
(e.g., provider.cli.codex).

---

Duplicate comments:
In `@apps/server/src/providers/copilot/copilot-provider.ts`:
- Around line 137-139: The current code force-casts SDK enum fields
(supportedReasoningEfforts, defaultReasoningEffort, and policy.state) from the
Copilot SDK model metadata (variable m) into ProviderModelInfo types, which can
pass unknown values to clients; update the mapping in copilot-provider.ts to
validate and sanitize these fields instead of using `as` casts: for
supportedReasoningEfforts filter m.supportedReasoningEfforts against the
explicit allowed list (whitelist) and map only known values, for
defaultReasoningEffort check that m.defaultReasoningEffort is one of the allowed
enum values before assigning (or set undefined/default if not), and for policy
use a guarded check on m.policy?.state to only accept "enabled" | "disabled" |
"unconfigured" (otherwise set policy undefined or a safe default); reference the
fields supportedReasoningEfforts, defaultReasoningEffort, and policy.state and
ProviderModelInfo when implementing these safe mappings.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: f61ecf90-2ae3-4474-b3ad-983261027daf

📥 Commits

Reviewing files that changed from the base of the PR and between 0265b5b and 0c8c6b7.

📒 Files selected for processing (1)
  • apps/server/src/providers/copilot/copilot-provider.ts

Comment on lines +249 to +255
const existing = this.sessions.get(sessionId);
if (existing) {
existing.lastUsedAt = Date.now();
// Abort in-flight turn by sending a new message on the existing session.
// The previous runTurn promise will resolve when session.idle fires.
void this.runTurn(sessionId, threadId, existing.session, message);
return;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Prevent overlapping runTurn() executions per sessionId.

Line [249] starts a new runTurn() even if a previous turn is still active on the same session. That can register duplicate listeners and double-emit textDelta/message/ended events.

💡 Suggested direction (serialize turns per session)
+ private inFlightTurns = new Map<string, Promise<void>>();
...
    const existing = this.sessions.get(sessionId);
    if (existing) {
      existing.lastUsedAt = Date.now();
-     void this.runTurn(sessionId, threadId, existing.session, message);
+     const prev = this.inFlightTurns.get(sessionId) ?? Promise.resolve();
+     const next = prev.finally(() =>
+       this.runTurn(sessionId, threadId, existing.session, message),
+     );
+     this.inFlightTurns.set(sessionId, next);
+     void next.finally(() => {
+       if (this.inFlightTurns.get(sessionId) === next) this.inFlightTurns.delete(sessionId);
+     });
      return;
    }
...
-   void this.runTurn(sessionId, threadId, session, message);
+   const firstTurn = this.runTurn(sessionId, threadId, session, message);
+   this.inFlightTurns.set(sessionId, firstTurn);
+   void firstTurn.finally(() => {
+     if (this.inFlightTurns.get(sessionId) === firstTurn) this.inFlightTurns.delete(sessionId);
+   });

Also applies to: 332-510

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/server/src/providers/copilot/copilot-provider.ts` around lines 249 -
255, The current logic starts a new runTurn(sessionId, ...) immediately when an
existing session is present, causing overlapping runTurn executions and
duplicate listeners; instead serialize turns per session by adding a per-session
queue or "inFlight" flag on the session entry returned by
this.sessions.get(sessionId) and enqueue the new message or await the prior
runTurn to finish before calling runTurn. Concretely: extend the session
container (the object from this.sessions.get(sessionId)) with an inFlight
Promise/boolean or a queue array, update the existing branch that currently does
existing.lastUsedAt = Date.now() to push the message into that queue or await
existing.inFlight, and modify runTurn to resolve the next queued item and clear
the inFlight marker when finished so no two runTurn(sessionId, ...) execute
concurrently and duplicate listeners on existing.session are not registered.

Comment on lines +373 to +381
session.on("tool.execution_start", (event) => {
const { toolCallId, toolName, arguments: toolArgs } = event.data;
toolStartTimes.set(toolCallId, Date.now());
this.emit("event", {
type: "toolUse",
threadId,
toolCallId,
toolName,
toolInput: toolArgs ?? {},
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Emit a real toolName in toolProgress events.

Line [419] currently sends toolName: "". Consumers correlating progress by tool name lose fidelity and may misrender progress rows.

🧭 Keep a per-tool name map for progress events
- const toolStartTimes = new Map<string, number>();
+ const toolStartTimes = new Map<string, number>();
+ const toolNames = new Map<string, string>();
...
  session.on("tool.execution_start", (event) => {
    const { toolCallId, toolName, arguments: toolArgs } = event.data;
    toolStartTimes.set(toolCallId, Date.now());
+   toolNames.set(toolCallId, toolName);
...
  session.on("tool.execution_complete", (event) => {
    const { toolCallId, success, result } = event.data;
    toolStartTimes.delete(toolCallId);
+   toolNames.delete(toolCallId);
...
  session.on("tool.execution_progress", (event) => {
    const { toolCallId } = event.data;
...
    this.emit("event", {
      type: "toolProgress",
      threadId,
      toolCallId,
-     toolName: "",
+     toolName: toolNames.get(toolCallId) ?? "unknown",
      elapsedSeconds,
    } satisfies AgentEvent);
  }),

Also applies to: 417-420

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/server/src/providers/copilot/copilot-provider.ts` around lines 373 -
381, The toolProgress events currently emit an empty toolName; add a
per-call-name map and populate it when handling
session.on("tool.execution_start") (use the same toolCallId key you store in
toolStartTimes) so you can later look up the real tool name when emitting
"toolProgress" events; update the two places that set toolName: "" (around the
current tool progress emission logic) to use toolNameMap.get(toolCallId) || ""
(or similar) and ensure you clear the entry on tool completion/stop to avoid
leaks.

…try is empty

Revert dynamic model loading (listModels RPC) and restore hardcoded
17-model Copilot registry. The dynamic path caused cascading issues:
client not connected before start(), package not hoisted by bun,
require.resolve failing on packages without a main export, and
infinite retry loops on fetch errors.

The server-side listModels implementation and transport plumbing are
retained for future use; only the UI components stop calling them.
@chuks-qua
Copy link
Copy Markdown
Contributor Author

Copilot Provider: CLI Bootstrapping Needs a Rewrite

After reading the SDK's compiled source (client.js), the current doRefreshClient() + checkCliAvailable() + resolveBundledCliPath() approach is fighting the SDK instead of using it. Here's the diagnosis and proposed fix.

Root Cause

The SDK's internal getNodeExecPath() returns process.execPath, which is electron.exe in our app. The SDK has no Electron detection. This means new CopilotClient() with default options tries:

spawn("electron.exe", ["copilot/index.js", "--headless", ...])

...which fails silently (code 0). Every fix we've applied has been a workaround for this one bug, and they keep undoing each other.

What the SDK Already Handles (that we're reimplementing)

Concern SDK built-in Our duplicate code
CLI path resolution getBundledCliPath() resolveBundledCliPath() (38-47)
CLI availability check Throws on start() if missing checkCliAvailable() (99-121)
Auth via gh CLI useLoggedInUser: true (default) gh auth token shell call (removed in working copy)
Process lifecycle start(), stop(), forceStop() Manual null checks, state checks

Proposed Rewrite: doRefreshClient()

Strip it down to ~20 lines. The SDK resolves the CLI path, manages the process, and handles auth. We only override the executor for Electron.

private async doRefreshClient(): Promise<void> {
  const settings = await this.settingsService.get();
  const configuredCliPath = settings.provider.cli.copilot || undefined;
  const state = this.client?.getState();

  if (configuredCliPath === this.lastCliPath && this.client && state === "connected") {
    return;
  }

  if (this.client) {
    await this.client.stop().catch(() => {});
  }

  this.lastCliPath = configuredCliPath;

  const opts: ConstructorParameters<typeof CopilotClient>[0] = {};

  // Only override cliPath if user set a custom one in settings
  if (configuredCliPath) {
    opts.cliPath = configuredCliPath;
  }

  // Electron fix: SDK uses process.execPath (electron.exe) to spawn .js CLIs.
  // Override to use the real Node binary.
  if (process.versions.electron) {
    const nodePath = which.sync("node", { nothrow: true });
    if (!nodePath) {
      throw new Error("Node.js not found in PATH. Required to run Copilot CLI from Electron.");
    }
    // If SDK resolved a .js CLI path, we need to pass node as the executor.
    // If user set a custom binary path, they handle their own executor.
    if (!configuredCliPath) {
      opts.cliPath = nodePath;
      // Let SDK resolve the CLI script path, pass it as an arg
      opts.cliArgs = [resolveBundledCliPath() ?? ""];
    }
  }

  // Get gh token for explicit auth (avoids silent code-0 when gh CLI
  // auth isn't picked up by the headless CLI subprocess)
  try {
    const { stdout } = await execFileAsync("gh", ["auth", "token"], {
      timeout: 5000, shell: true,
    });
    const token = stdout.trim();
    if (token) opts.githubToken = token;
  } catch {
    // gh not installed or not authed; SDK falls back to useLoggedInUser
  }

  this.client = new CopilotClient(opts);
  await this.client.start();
}

What to Delete

  • resolveBundledCliPath() -- keep only as fallback for the Electron cliArgs case, or inline it
  • checkCliAvailable() -- let client.start() throw; catch and translate in sendMessage()
  • The execFile(cliPath, ["--version"]) probe -- this checked a binary that doesn't exist (the CLI is a JS file, not an executable)
  • createRequire / existsSync / join imports if no longer needed

What to Keep

  • refreshClient() mutex (clientStartLock) -- still needed
  • inferModelGroup() -- used by listModels()
  • Session management, event mapping, idle eviction -- all correct
  • Error translation in sendMessage() catch block -- still needed but simplify the messages

Auth Flow

The githubToken option is the reliable path. When provided, the SDK:

  1. Sets COPILOT_SDK_AUTH_TOKEN env on the spawned CLI
  2. Passes --auth-token-env COPILOT_SDK_AUTH_TOKEN flag
  3. Auto-sets useLoggedInUser: false

Without it, the CLI tries its own gh auth token internally, which may fail in the headless subprocess environment (no TTY, different env). Getting the token ourselves from gh auth token and passing it explicitly avoids this.

Summary

The fix is: stop reimplementing SDK internals, only patch the Electron executor bug, and always pass githubToken explicitly.

Stop reimplementing SDK internals (CLI resolution, availability checks).
Patch the root cause: process.execPath is electron.exe inside
utilityProcess.fork, so temporarily override it with the real node binary
during client construction. Pass githubToken explicitly from gh auth
token for reliable headless auth. Let client.start() surface errors
rather than probing with a bespoke checkCliAvailable() check.
- Remove shell: true from gh auth token call (injection risk, Windows
  cmd.exe can corrupt tokens with metacharacters)
- Assign this.client only after start() succeeds so a failed startup
  never leaves a stale non-started client
- Move this.lastCliPath assignment after start() succeeds
- Replace this.client! with null guard in doSendMessage
- Cache which("node") result to avoid re-probing PATH on every rebuild
- Replace process.execPath override with a local savedExecPath variable
  instead of attaching _mcodeOrigExecPath to the global process object
- Use typed opts interface instead of Record<string, unknown>
- Update stale docstring on execFileAsync
- Log debug message when gh auth token is unavailable
- Import Mock type in test file
Show OpenAI/Anthropic/Google/xAI labels inline on each model row in the
Copilot fly-out so users can identify the vendor at a glance. Also bump
the submenu max-height from a fixed 280px to min(480px, 100vh-8rem) so
the xAI group is no longer cut off on typical screen sizes.
…ed view

The per-row vendor badge was redundant when a section header already
labels the group. Remove it. Extract renderGroupedModels so both the
fly-out submenu and the provider-locked flat list share the same grouped
rendering - users can now see vendor sections in both contexts.
Both the new-thread submenu and the provider-locked (existing thread)
view now call listProviderModels() to get the live model list from the
server instead of relying solely on the static registry. The fetch is
lazy on hover for new threads and immediate on mount for locked threads.
Falls back to the static registry if the RPC fails.
Add supportsModelListing flag to ModelProvider and set it on Copilot.
Gate fetchProviderModels calls behind this flag so Claude, Codex, and
other providers that lack listModels() do not trigger a server error.
Submenu was anchored at top-0, extending downward and overflowing the
viewport because the main picker opens upward (bottom-full). Changed
to bottom-0 so the submenu grows upward, consistent with the main menu.
- Merge prDraftService, threadRepo, workspaceRepo into ws-router and index
- Merge PrDraft/CreatePrResult imports alongside ProviderModelInfo in transport types
- Merge isCompletionCapable export alongside ProviderModelInfo exports in contracts
- Merge prDraftProvider/prDraftModel settings alongside copilotCliPath in ModelSection
- Add supportsCompletion = false to CopilotProvider to satisfy updated IAgentProvider
Show multiplier badge on every model regardless of value (including 1x)
so the billing rate is always visible. Replace the static-then-live
flicker with a centered spinner while listProviderModels is in flight,
switching to the live list once the fetch resolves.
Add @ts-expect-error for the fs import since @types/node is not in the
web package tsconfig. The test runs under vitest-environment node which
provides fs at runtime.
The live listProviderModels() fetch is the source of truth. The static
list now holds only GPT-4.1, Claude Sonnet 4.6, and Gemini 2.5 Pro as
a minimal fallback while the spinner loads or if the fetch fails.
…on.error hang

Instead of mutating the global process.execPath (fragile, affects the
entire process), prepend the real node binary directory to PATH in the
env option passed to CopilotClient.

Also fix session.error handler to call resolve() so the turnPromise
settles, the finally block runs cleanup, and the thread exits the
'running' state in the UI.
…rror

The test uses Node's fs module with @vitest-environment node, but the web
tsconfig doesn't include @types/node. In CI, @types/node resolves from the
monorepo root causing the @ts-expect-error directive to be unused (itself
a TS error). Exclude the file from tsconfig instead so both local and CI
environments behave consistently.
- Evict dead session on session.error to prevent reuse of errored SDK session
- Add double-checked locking comment after refreshClient() await to prevent
  concurrent sendMessage calls from creating duplicate SDK sessions
- Extract toThreadId() helper to deduplicate prefix-stripping logic
- Add TODO noting approveAll bypasses permissionMode (SDK limitation)
- Fix inferModelGroup to match exact o1/o3/o4 IDs, not just any id starting with those chars
- Fix fetchProviderModels: record failed fetches to prevent retry spam on hover
- Fix fetchProviderModels: reuse Map instance to avoid double-copy on success
- Fix shortLabel: guard against undefined displayProvider to avoid 'undefined ' prefix
- Precompute sorted flat model list in model-registry to avoid per-call allocation
- Expand ProviderId union to include cursor and opencode (comingSoon placeholders)
- Replace Function type and any casts in test with proper typed signatures
- Fix duplicate semicolon in test assertion
@chuks-qua chuks-qua merged commit 33360df into main Apr 13, 2026
7 checks passed
@chuks-qua chuks-qua deleted the feat/gh-copilot branch April 13, 2026 10:52
@mze-bot mze-bot bot mentioned this pull request Apr 15, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat: multi-provider support with mid-conversation model handoff

1 participant