Skip to content
Merged
5 changes: 5 additions & 0 deletions .changeset/fix-agent-manager-session-disappears.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"kilo-code": patch
---

Fix Agent Manager session disappearing immediately after starting due to gitUrl race condition
5 changes: 5 additions & 0 deletions .changeset/model-picker-sections.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"kilo-code": patch
---

Add section headers to model selection dropdowns for "Recommended models" and "All models"
25 changes: 25 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,31 @@

Kilo Code is an open source AI coding agent for VS Code that generates code from natural language, automates tasks, and supports 500+ AI models.

## Project Structure

This is a pnpm monorepo using Turbo for task orchestration:

- **`src/`** - VSCode extension (core logic, API providers, tools)
- **`webview-ui/`** - React frontend (chat UI, settings)
- **`cli/`** - Standalone CLI package
- **`packages/`** - Shared packages (`types`, `ipc`, `telemetry`, `cloud`)
- **`jetbrains/`** - JetBrains plugin (Kotlin + Node.js host)
- **`apps/`** - E2E tests, Storybook, docs

Key source directories:
- `src/api/providers/` - AI provider implementations (50+ providers)
- `src/core/tools/` - Tool implementations (ReadFile, ApplyDiff, ExecuteCommand, etc.)
- `src/services/` - Services (MCP, browser, checkpoints, code-index)

## Build Commands

```bash
pnpm install # Install all dependencies
pnpm build # Build extension (.vsix)
pnpm lint # Run ESLint
pnpm check-types # TypeScript type checking
```

## Skills

- **Translation**: `.kilocode/skills/translation/SKILL.md` - Translation and localization guidelines
Expand Down
6 changes: 6 additions & 0 deletions src/core/kilocode/agent-manager/AgentManagerProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -473,6 +473,12 @@ export class AgentManagerProvider implements vscode.Disposable {
if (workspaceFolder) {
try {
gitUrl = normalizeGitUrl(await getRemoteUrl(workspaceFolder))
// Update currentGitUrl to ensure consistency between session gitUrl and filter
// This fixes a race condition where initializeCurrentGitUrl() hasn't completed yet
if (gitUrl && !this.currentGitUrl) {
this.currentGitUrl = gitUrl
this.outputChannel.appendLine(`[AgentManager] Updated current git URL: ${gitUrl}`)
}
} catch (error) {
this.outputChannel.appendLine(
`[AgentManager] Could not get git URL: ${error instanceof Error ? error.message : String(error)}`,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -842,6 +842,28 @@ describe("AgentManagerProvider gitUrl filtering", () => {
expect(state.sessions[0].sessionId).toBe("session-2")
})

it("updates currentGitUrl when starting a session if not already set (race condition fix)", async () => {
// Simulate the race condition: currentGitUrl is undefined because initializeCurrentGitUrl hasn't completed
;(provider as any).currentGitUrl = undefined

// Start a session - this should update currentGitUrl
await (provider as any).startAgentSession("test prompt")

// currentGitUrl should now be set from the session's gitUrl
expect((provider as any).currentGitUrl).toBe("https://github.com/org/repo.git")
})

it("does not overwrite currentGitUrl if already set", async () => {
// Set a different currentGitUrl
;(provider as any).currentGitUrl = "https://github.com/org/other-repo.git"

// Start a session
await (provider as any).startAgentSession("test prompt")

// currentGitUrl should NOT be overwritten
expect((provider as any).currentGitUrl).toBe("https://github.com/org/other-repo.git")
})

describe("filterRemoteSessionsByGitUrl", () => {
it("returns only sessions with matching git_url when currentGitUrl is set", () => {
const remoteSessions = [
Expand Down
71 changes: 61 additions & 10 deletions webview-ui/src/components/kilocode/chat/ModelSelector.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
import { useMemo } from "react"
import { SelectDropdown, DropdownOptionType } from "@/components/ui"
import { SelectDropdown, DropdownOptionType, type DropdownOption } from "@/components/ui"
import { OPENROUTER_DEFAULT_PROVIDER_NAME, type ProviderSettings } from "@roo-code/types"
import { vscode } from "@src/utils/vscode"
import { useAppTranslation } from "@src/i18n/TranslationContext"
import { cn } from "@src/lib/utils"
import { prettyModelName } from "../../../utils/prettyModelName"
import { useProviderModels } from "../hooks/useProviderModels"
import { getModelIdKey, getSelectedModelId } from "../hooks/useSelectedModel"
import { usePreferredModels } from "@/components/ui/hooks/kilocode/usePreferredModels"
import { useGroupedModelIds } from "@/components/ui/hooks/kilocode/usePreferredModels"

interface ModelSelectorProps {
currentApiConfigName?: string
Expand All @@ -32,15 +32,66 @@ export const ModelSelector = ({
const modelIdKey = getModelIdKey({ provider })
const isAutocomplete = apiConfiguration.profileType === "autocomplete"

const modelsIds = usePreferredModels(providerModels)
const { preferredModelIds, restModelIds } = useGroupedModelIds(providerModels)
const options = useMemo(() => {
const missingModelIds = modelsIds.indexOf(selectedModelId) >= 0 ? [] : [selectedModelId]
return missingModelIds.concat(modelsIds).map((modelId) => ({
value: modelId,
label: providerModels[modelId]?.displayName ?? prettyModelName(modelId),
type: DropdownOptionType.ITEM,
}))
}, [modelsIds, providerModels, selectedModelId])
const result: DropdownOption[] = []

// Check if selected model is missing from the lists
const allModelIds = [...preferredModelIds, ...restModelIds]
const isMissingSelectedModel = selectedModelId && !allModelIds.includes(selectedModelId)

// Add "Recommended models" section if there are preferred models
if (preferredModelIds.length > 0) {
result.push({
value: "__label_recommended__",
label: t("settings:modelPicker.recommendedModels"),
type: DropdownOptionType.LABEL,
})

preferredModelIds.forEach((modelId) => {
result.push({
value: modelId,
label: providerModels[modelId]?.displayName ?? prettyModelName(modelId),
type: DropdownOptionType.ITEM,
})
})
}

// Add "All models" section
if (restModelIds.length > 0) {
result.push({
value: "__label_all__",
label: t("settings:modelPicker.allModels"),
type: DropdownOptionType.LABEL,
})

// Add missing selected model at the top of "All models" if not in any list
if (isMissingSelectedModel) {
result.push({
value: selectedModelId,
label: providerModels[selectedModelId]?.displayName ?? prettyModelName(selectedModelId),
type: DropdownOptionType.ITEM,
})
}

restModelIds.forEach((modelId) => {
result.push({
value: modelId,
label: providerModels[modelId]?.displayName ?? prettyModelName(modelId),
type: DropdownOptionType.ITEM,
})
})
} else if (isMissingSelectedModel) {
// If there are no rest models but we have a missing selected model, add it
result.push({
value: selectedModelId,
label: providerModels[selectedModelId]?.displayName ?? prettyModelName(selectedModelId),
type: DropdownOptionType.ITEM,
})
}

return result
}, [preferredModelIds, restModelIds, providerModels, selectedModelId, t])

const disabled = isLoading || isError || isAutocomplete

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,11 @@ vi.mock("@/i18n/TranslationContext", () => ({
}),
}))

// Create a mock function that can be controlled per test
const mockUseGroupedModelIds = vi.fn()

vi.mock("@/components/ui/hooks/kilocode/usePreferredModels", () => ({
usePreferredModels: () => ["model-1", "model-2"],
useGroupedModelIds: () => mockUseGroupedModelIds(),
}))

// Create a mock function that can be controlled per test
Expand All @@ -37,9 +40,17 @@ describe("ModelSelector", () => {
}

beforeEach(() => {
// Reset mock before each test
// Reset mocks before each test
mockUseProviderModels.mockReset()
// Default mock implementation
mockUseGroupedModelIds.mockReset()

// Default mock implementation for useGroupedModelIds (no preferred models)
mockUseGroupedModelIds.mockReturnValue({
preferredModelIds: [],
restModelIds: ["model-1", "model-2"],
})

// Default mock implementation for useProviderModels
mockUseProviderModels.mockReturnValue({
provider: "openai",
providerModels: {
Expand Down Expand Up @@ -188,4 +199,98 @@ describe("ModelSelector", () => {
const dropdownTrigger = screen.queryByTestId("dropdown-trigger")
expect(dropdownTrigger).not.toBeInTheDocument()
})

describe("preferred models sections", () => {
test("builds options with section headers when preferred models exist", () => {
// Setup mock to return preferred models
mockUseGroupedModelIds.mockReturnValue({
preferredModelIds: ["preferred-1", "preferred-2"],
restModelIds: ["model-1", "model-2"],
})

mockUseProviderModels.mockReturnValue({
provider: "openai",
providerModels: {
"preferred-1": { displayName: "Preferred Model 1", preferredIndex: 0 },
"preferred-2": { displayName: "Preferred Model 2", preferredIndex: 1 },
"model-1": { displayName: "Model 1" },
"model-2": { displayName: "Model 2" },
},
providerDefaultModel: "model-1",
isLoading: false,
isError: false,
})

render(
<ModelSelector
currentApiConfigName="test-profile"
apiConfiguration={{
apiProvider: "openai",
apiModelId: "model-1",
}}
fallbackText="Select a model"
/>,
)

// Should render the dropdown
const dropdownTrigger = screen.getByTestId("dropdown-trigger")
expect(dropdownTrigger).toBeInTheDocument()
})

test("does not add section headers when no preferred models exist", () => {
// Setup mock with no preferred models
mockUseGroupedModelIds.mockReturnValue({
preferredModelIds: [],
restModelIds: ["model-1", "model-2"],
})

render(
<ModelSelector
currentApiConfigName="test-profile"
apiConfiguration={{
apiProvider: "openai",
apiModelId: "model-1",
}}
fallbackText="Select a model"
/>,
)

// Should render the dropdown
const dropdownTrigger = screen.getByTestId("dropdown-trigger")
expect(dropdownTrigger).toBeInTheDocument()
})

test("handles only preferred models without rest models", () => {
// Setup mock with only preferred models (edge case)
mockUseGroupedModelIds.mockReturnValue({
preferredModelIds: ["preferred-1"],
restModelIds: [],
})

mockUseProviderModels.mockReturnValue({
provider: "openai",
providerModels: {
"preferred-1": { displayName: "Preferred Model 1", preferredIndex: 0 },
},
providerDefaultModel: "preferred-1",
isLoading: false,
isError: false,
})

render(
<ModelSelector
currentApiConfigName="test-profile"
apiConfiguration={{
apiProvider: "openai",
apiModelId: "preferred-1",
}}
fallbackText="Select a model"
/>,
)

// Should render the dropdown with only preferred models section
const dropdownTrigger = screen.getByTestId("dropdown-trigger")
expect(dropdownTrigger).toBeInTheDocument()
})
})
})
Loading