mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-08-29 03:44:06 +08:00
Merge branch 'main' into mark/disable-issue-triage
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@kilocode/cli": patch
|
||||
"@kilocode/kilo-indexing": patch
|
||||
"kilo-code": patch
|
||||
---
|
||||
|
||||
Support unauthenticated OpenAI-compatible endpoints for codebase indexing without requiring a placeholder API key.
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
"kilo-code": patch
|
||||
"@kilocode/cli": patch
|
||||
---
|
||||
|
||||
Add an instant/thinking reasoning toggle for MiniMax M-series models, matching the existing glm/kimi/qwen behavior.
|
||||
@@ -0,0 +1,33 @@
|
||||
---
|
||||
"@kilocode/cli": patch
|
||||
"kilo-code": patch
|
||||
---
|
||||
|
||||
Changes from opencode v1.14.51 to v1.15.4 upstream:
|
||||
|
||||
- Core Improvements: Clarified how to recover when the npm package is installed without its native binary.
|
||||
- Core Improvements: Reduced unnecessary prompting around shell, task, and todo flows.
|
||||
- Core Bugfixes: Ignored invalid exports in custom tool modules instead of failing tool loading.
|
||||
- Core Bugfixes: Ignored project instruction lookup errors so sessions keep loading when project instruction discovery fails.
|
||||
- Core Bugfixes: Fixed versioned event projector lookups so event replay uses the right handlers.
|
||||
- Core Bugfixes: Avoid duplicate consecutive entries in prompt history.
|
||||
- Core Bugfixes: Show full config validation errors during TUI startup instead of a generic failure.
|
||||
- Core Bugfixes: Fixed npm installs so the CLI can recover and fetch the right native binary on more setups.
|
||||
- Core Bugfixes: Fixed multiline `@` mentions in prompts.
|
||||
- Core Bugfixes: Preserved custom tool metadata from Zod schemas.
|
||||
- Core Bugfixes: Preserved custom tool argument descriptions in generated schemas.
|
||||
- Core Bugfixes: Fixed file watching in repos where `.git` is a symlink. (@kagura-agent)
|
||||
- Core Bugfixes: Fixed sync events not reaching project-scoped subscribers in injected instances.
|
||||
- Core Bugfixes: Reduced wasted work when reading very large files after output truncation.
|
||||
- Core Bugfixes: Fixed project-scoped bus events so file watcher and update notifications reach the right instance.
|
||||
- Core Bugfixes: Fixed custom LSP servers not sending refresh events after they initialize.
|
||||
- Core Bugfixes: Hid background subagent task instructions unless experimental background mode is enabled.
|
||||
- TUI Improvements: Added a collapsed thinking view that can be expanded inline.
|
||||
- TUI Improvements: Added pinned sessions with quick-switch slots in the session picker.
|
||||
- TUI Improvements: Newly pinned sessions now stay at the end of the pinned list instead of jumping to the top.
|
||||
- TUI Improvements: Made Markdown H1 headings easier to distinguish.
|
||||
- TUI Bugfixes: Fixed thinking mode defaults so reasoning starts collapsed consistently.
|
||||
- TUI Bugfixes: Limited session quick-switching to pinned sessions.
|
||||
- TUI Bugfixes: Fixed Markdown table rendering in chat output.
|
||||
- TUI Bugfixes: Fixed `kilo run --agent` resolving project-local agents.
|
||||
- TUI Bugfixes: Fixed async commands losing the active instance context, which could break agent generation and GitHub-driven runs.
|
||||
@@ -136,6 +136,12 @@ Imported work stays associated with its branch or worktree and can be continued
|
||||
- Use session history to reopen local sessions or preview cloud sessions
|
||||
- Continue a cloud session locally from Agent Manager using the same extension sign-in and provider settings
|
||||
|
||||
### Renaming Worktrees
|
||||
|
||||
Double-click a worktree name to edit its label inline. You can also right-click the worktree and choose **Rename**. Press `Enter` or click outside the field to save, or press `Escape` to cancel.
|
||||
|
||||
Renaming a worktree changes only the label shown in Agent Manager. It does not rename the underlying git branch.
|
||||
|
||||
## Starting Sessions From Chat
|
||||
|
||||
Kilo can start Agent Manager sessions from chat with the `agent_manager` tool. It is available by default only in the VS Code extension because Agent Manager is an extension feature.
|
||||
@@ -168,7 +174,7 @@ Sections let you group worktrees into collapsible, color-coded folders in the si
|
||||
|
||||
Multi-version worktrees (created via Multi-Version Mode) are moved together — assigning one version to a section moves all versions in the group.
|
||||
|
||||
### Renaming
|
||||
### Renaming Sections
|
||||
|
||||
Right-click the section header and select **Rename Section**. An inline text field appears — type the new name and press `Enter` to confirm or `Escape` to cancel.
|
||||
|
||||
|
||||
@@ -82,6 +82,12 @@ Run `/export` in chat, or open a local session's **History** context menu and ch
|
||||
|
||||
Kilo builds the export from the complete local session history, not only the messages currently loaded in the chat view.
|
||||
|
||||
**Renaming sessions:**
|
||||
|
||||
Double-click the current session title at the top of the chat to edit it inline. Press `Enter` or click outside the field to save, or press `Escape` to cancel.
|
||||
|
||||
You can also rename local sessions from **History** using the edit button or the session's context menu.
|
||||
|
||||
{% /tab %}
|
||||
{% tab label="CLI" %}
|
||||
|
||||
|
||||
@@ -360,7 +360,7 @@ Operations are queued to prevent concurrent Git operations that might corrupt re
|
||||
|
||||
## Git Installation
|
||||
|
||||
Checkpoints require Git to be installed on your system.
|
||||
Checkpoints require Git to be installed on your system. If Git is unavailable or the workspace is not a Git repository, Kilo skips checkpoints automatically; you do not need to disable them manually.
|
||||
|
||||
### macOS
|
||||
|
||||
|
||||
@@ -64,7 +64,7 @@ You can also edit the `indexing` section in `kilo.jsonc` directly:
|
||||
|---|---|---|
|
||||
| **OpenAI** | API key | Default model: `text-embedding-3-small`. `text-embedding-3-large` for higher accuracy. |
|
||||
| **Ollama** | Local base URL | No API costs. Runs fully offline. |
|
||||
| **OpenAI-Compatible** | Base URL + API key | For self-hosted or third-party OpenAI-compatible endpoints. |
|
||||
| **OpenAI-Compatible** | Base URL + optional API key | For self-hosted or third-party OpenAI-compatible endpoints, including unauthenticated local servers. |
|
||||
| **Gemini** | Google AI API key | Supports `gemini-embedding-001` and other Gemini embedding models. |
|
||||
| **Mistral** | API key from [La Plateforme](https://console.mistral.ai/api-keys/) | Use a standard Mistral API key. The Codestral-specific keys from the [Mistral autocomplete setup guide](/docs/code-with-ai/features/autocomplete/mistral-setup) are **not** interchangeable — those only work for completion. |
|
||||
| **Vercel AI Gateway** | API key | Routes requests through [Vercel AI Gateway](https://vercel.com/docs/ai-gateway). |
|
||||
@@ -143,7 +143,7 @@ You can also edit the `indexing` section directly. This is the full shape of the
|
||||
|---|---|---|---|
|
||||
| **OpenAI** | `openai` | `{ apiKey }` | Default: `text-embedding-3-small`. |
|
||||
| **Ollama** | `ollama` | `{ baseUrl }` | No API costs. Runs fully offline. |
|
||||
| **OpenAI-Compatible** | `openai-compatible` | `{ baseUrl, apiKey }` | For self-hosted or third-party endpoints. |
|
||||
| **OpenAI-Compatible** | `openai-compatible` | `{ baseUrl, apiKey? }` | For self-hosted or third-party endpoints, including unauthenticated local servers. |
|
||||
| **Gemini** | `gemini` | `{ apiKey }` | Supports `gemini-embedding-001`. |
|
||||
| **Mistral** | `mistral` | `{ apiKey }` | Use a [La Plateforme](https://console.mistral.ai/api-keys/) key — the Codestral-specific keys from the [autocomplete setup guide](/docs/code-with-ai/features/autocomplete/mistral-setup) don't work for embeddings. |
|
||||
| **Vercel AI Gateway** | `vercel-ai-gateway` | `{ apiKey }` | Routes through [Vercel AI Gateway](https://vercel.com/docs/ai-gateway). |
|
||||
|
||||
@@ -65,7 +65,7 @@ By default, autocomplete routes through the Kilo provider and uses credits. If y
|
||||
|
||||
### How to Get It Free
|
||||
|
||||
Add your own Mistral AI (Codestral) API key via **BYOK (Bring Your Own Key)** on the Kilo Gateway. Mistral offers a free tier for Codestral. When you configure a BYOK key, autocomplete requests use your key directly — at no cost on your Kilo balance.
|
||||
Add your own Mistral AI API key via **BYOK (Bring Your Own Key)** on the Kilo Gateway. Mistral offers a free tier for Codestral. When you configure a BYOK key, autocomplete requests use your key directly — at no cost on your Kilo balance.
|
||||
|
||||
See the [Mistral Setup Guide](/docs/code-with-ai/features/autocomplete/mistral-setup) for step-by-step instructions.
|
||||
|
||||
|
||||
@@ -57,7 +57,7 @@ export class CodeIndexConfigManager {
|
||||
private kiloOptions?: { apiKey: string; baseUrl?: string; organizationId?: string }
|
||||
private openAiOptions?: { apiKey: string }
|
||||
private ollamaOptions?: { baseUrl: string; modelId?: string }
|
||||
private openAiCompatibleOptions?: { baseUrl: string; apiKey: string }
|
||||
private openAiCompatibleOptions?: { baseUrl: string; apiKey?: string }
|
||||
private geminiOptions?: { apiKey: string }
|
||||
private mistralOptions?: { apiKey: string }
|
||||
private vercelAiGatewayOptions?: { apiKey: string }
|
||||
@@ -112,10 +112,9 @@ export class CodeIndexConfigManager {
|
||||
this.openAiOptions = input.openAiKey ? { apiKey: input.openAiKey } : undefined
|
||||
const url = input.ollamaBaseUrl ?? (input.embedderProvider === "ollama" ? "http://localhost:11434" : undefined)
|
||||
this.ollamaOptions = url ? { baseUrl: url, modelId: input.modelId } : undefined
|
||||
this.openAiCompatibleOptions =
|
||||
input.openAiCompatibleBaseUrl && input.openAiCompatibleApiKey
|
||||
? { baseUrl: input.openAiCompatibleBaseUrl, apiKey: input.openAiCompatibleApiKey }
|
||||
: undefined
|
||||
this.openAiCompatibleOptions = input.openAiCompatibleBaseUrl
|
||||
? { baseUrl: input.openAiCompatibleBaseUrl, apiKey: input.openAiCompatibleApiKey?.trim() || undefined }
|
||||
: undefined
|
||||
this.geminiOptions = input.geminiApiKey ? { apiKey: input.geminiApiKey } : undefined
|
||||
this.mistralOptions = input.mistralApiKey ? { apiKey: input.mistralApiKey } : undefined
|
||||
this.vercelAiGatewayOptions = input.vercelAiGatewayApiKey ? { apiKey: input.vercelAiGatewayApiKey } : undefined
|
||||
@@ -168,8 +167,7 @@ export class CodeIndexConfigManager {
|
||||
return !!(this.kiloOptions?.apiKey && this.modelId && this.currentModelDimension && hasStore)
|
||||
if (provider === "openai") return !!(this.openAiOptions?.apiKey && hasStore)
|
||||
if (provider === "ollama") return !!(this.ollamaOptions?.baseUrl && hasStore)
|
||||
if (provider === "openai-compatible")
|
||||
return !!(this.openAiCompatibleOptions?.baseUrl && this.openAiCompatibleOptions?.apiKey && hasStore)
|
||||
if (provider === "openai-compatible") return !!(this.openAiCompatibleOptions?.baseUrl && hasStore)
|
||||
if (provider === "gemini") return !!(this.geminiOptions?.apiKey && hasStore)
|
||||
if (provider === "mistral") return !!(this.mistralOptions?.apiKey && hasStore)
|
||||
if (provider === "vercel-ai-gateway") return !!(this.vercelAiGatewayOptions?.apiKey && hasStore)
|
||||
|
||||
@@ -42,7 +42,7 @@ export class OpenAICompatibleEmbedder implements IEmbedder {
|
||||
private embeddingsClient: OpenAI
|
||||
private readonly defaultModelId: string
|
||||
private readonly baseUrl: string
|
||||
private readonly apiKey: string
|
||||
private readonly apiKey?: string
|
||||
private readonly isFullUrl: boolean
|
||||
private readonly maxItemTokens: number
|
||||
private readonly headers: Record<string, string>
|
||||
@@ -61,13 +61,13 @@ export class OpenAICompatibleEmbedder implements IEmbedder {
|
||||
/**
|
||||
* Creates a new OpenAI Compatible embedder
|
||||
* @param baseUrl The base URL for the OpenAI-compatible API endpoint
|
||||
* @param apiKey The API key for authentication
|
||||
* @param apiKey Optional API key for authentication
|
||||
* @param modelId Optional model identifier (defaults to "text-embedding-3-small")
|
||||
* @param maxItemTokens Optional maximum tokens per item (defaults to MAX_ITEM_TOKENS)
|
||||
*/
|
||||
constructor(
|
||||
baseUrl: string,
|
||||
apiKey: string,
|
||||
apiKey?: string,
|
||||
modelId?: string,
|
||||
maxItemTokens?: number,
|
||||
options: OpenAICompatibleOptions = {},
|
||||
@@ -75,22 +75,24 @@ export class OpenAICompatibleEmbedder implements IEmbedder {
|
||||
if (!baseUrl) {
|
||||
throw new Error("Base URL is required for OpenAI-compatible embedder")
|
||||
}
|
||||
if (!apiKey) {
|
||||
throw new Error("API key is required for OpenAI-compatible embedder")
|
||||
}
|
||||
|
||||
this.baseUrl = baseUrl
|
||||
this.apiKey = apiKey
|
||||
this.apiKey = apiKey?.trim() || undefined
|
||||
|
||||
try {
|
||||
this.embeddingsClient = new OpenAI({
|
||||
baseURL: baseUrl,
|
||||
apiKey: apiKey,
|
||||
defaultHeaders: options.headers,
|
||||
})
|
||||
} catch (error) {
|
||||
throw error instanceof Error ? error : new Error(String(error))
|
||||
}
|
||||
const defaults = new Headers(options.headers)
|
||||
this.embeddingsClient = new OpenAI({
|
||||
baseURL: baseUrl,
|
||||
apiKey: this.apiKey ?? "EMPTY",
|
||||
defaultHeaders: options.headers,
|
||||
fetch: this.apiKey
|
||||
? undefined
|
||||
: async (input, init) => {
|
||||
const headers = new Headers(init?.headers)
|
||||
if (!defaults.has("authorization")) headers.delete("authorization")
|
||||
if (!defaults.has("api-key")) headers.delete("api-key")
|
||||
return globalThis.fetch(input, { ...init, headers: Object.fromEntries(headers) })
|
||||
},
|
||||
})
|
||||
|
||||
this.defaultModelId = modelId || getDefaultModelId("openai-compatible")
|
||||
// Cache the URL type check for performance
|
||||
@@ -213,10 +215,12 @@ export class OpenAICompatibleEmbedder implements IEmbedder {
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...this.headers,
|
||||
// Azure OpenAI uses 'api-key' header, while OpenAI uses 'Authorization'
|
||||
// We'll try 'api-key' first for Azure compatibility
|
||||
"api-key": this.apiKey,
|
||||
Authorization: `Bearer ${this.apiKey}`,
|
||||
...(this.apiKey
|
||||
? {
|
||||
"api-key": this.apiKey,
|
||||
Authorization: `Bearer ${this.apiKey}`,
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
body: JSON.stringify({
|
||||
input: batchTexts,
|
||||
|
||||
@@ -17,7 +17,7 @@ export interface CodeIndexConfig {
|
||||
kiloOptions?: { apiKey: string; baseUrl?: string; organizationId?: string }
|
||||
openAiOptions?: { apiKey: string }
|
||||
ollamaOptions?: { baseUrl: string; modelId?: string }
|
||||
openAiCompatibleOptions?: { baseUrl: string; apiKey: string }
|
||||
openAiCompatibleOptions?: { baseUrl: string; apiKey?: string }
|
||||
geminiOptions?: { apiKey: string }
|
||||
mistralOptions?: { apiKey: string }
|
||||
vercelAiGatewayOptions?: { apiKey: string }
|
||||
|
||||
@@ -85,8 +85,7 @@ export class CodeIndexServiceFactory {
|
||||
return new CodeIndexOllamaEmbedder(config.ollamaOptions.baseUrl, config.modelId, config.modelDimension)
|
||||
}
|
||||
if (provider === "openai-compatible") {
|
||||
if (!config.openAiCompatibleOptions?.baseUrl || !config.openAiCompatibleOptions?.apiKey)
|
||||
throw new Error("OpenAI-compatible base URL and API key are required.")
|
||||
if (!config.openAiCompatibleOptions?.baseUrl) throw new Error("OpenAI-compatible base URL is required.")
|
||||
return new OpenAICompatibleEmbedder(
|
||||
config.openAiCompatibleOptions.baseUrl,
|
||||
config.openAiCompatibleOptions.apiKey,
|
||||
|
||||
@@ -26,6 +26,34 @@ describe("CodeIndexConfigManager", () => {
|
||||
expect(cfg.getConfig().ollamaOptions?.baseUrl).toBe("http://localhost:11434")
|
||||
})
|
||||
|
||||
test("configures an OpenAI-compatible endpoint without an API key", () => {
|
||||
const cfg = new CodeIndexConfigManager(
|
||||
createInput({
|
||||
embedderProvider: "openai-compatible",
|
||||
openAiKey: undefined,
|
||||
openAiCompatibleBaseUrl: "http://localhost:1234/v1",
|
||||
}),
|
||||
)
|
||||
|
||||
expect(cfg.isFeatureConfigured).toBe(true)
|
||||
expect(cfg.getConfig().openAiCompatibleOptions).toEqual({
|
||||
baseUrl: "http://localhost:1234/v1",
|
||||
apiKey: undefined,
|
||||
})
|
||||
})
|
||||
|
||||
test("requires a base URL for an OpenAI-compatible endpoint", () => {
|
||||
const cfg = new CodeIndexConfigManager(
|
||||
createInput({
|
||||
embedderProvider: "openai-compatible",
|
||||
openAiKey: undefined,
|
||||
openAiCompatibleApiKey: "sk-test",
|
||||
}),
|
||||
)
|
||||
|
||||
expect(cfg.isFeatureConfigured).toBe(false)
|
||||
})
|
||||
|
||||
test("defaults vector store to LanceDB when omitted", () => {
|
||||
const cfg = new CodeIndexConfigManager(createInput({ vectorStoreProvider: undefined }))
|
||||
|
||||
@@ -142,6 +170,19 @@ describe("CodeIndexConfigManager", () => {
|
||||
expect(result.requiresRestart).toBe(true)
|
||||
})
|
||||
|
||||
test("requires restart when OpenAI-compatible auth is added or removed", () => {
|
||||
const input = createInput({
|
||||
embedderProvider: "openai-compatible",
|
||||
openAiKey: undefined,
|
||||
openAiCompatibleBaseUrl: "http://localhost:1234/v1",
|
||||
})
|
||||
const cfg = new CodeIndexConfigManager(input)
|
||||
|
||||
expect(cfg.loadConfiguration({ ...input, openAiCompatibleApiKey: "sk-test" }).requiresRestart).toBe(true)
|
||||
expect(cfg.loadConfiguration(input).requiresRestart).toBe(true)
|
||||
expect(cfg.loadConfiguration(input).requiresRestart).toBe(false)
|
||||
})
|
||||
|
||||
test("requires restart when Kilo auth changes", () => {
|
||||
const cfg = new CodeIndexConfigManager(
|
||||
createInput({
|
||||
|
||||
@@ -63,10 +63,10 @@ describe("OpenAICompatibleEmbedder", () => {
|
||||
)
|
||||
})
|
||||
|
||||
test("should throw error when apiKey is missing", () => {
|
||||
expect(() => new OpenAICompatibleEmbedder(testBaseUrl, "", testModelId)).toThrow(
|
||||
"API key is required for OpenAI-compatible embedder",
|
||||
)
|
||||
test("should create embedder without an API key", () => {
|
||||
embedder = new OpenAICompatibleEmbedder(testBaseUrl, "", testModelId)
|
||||
|
||||
expect(embedder).toBeDefined()
|
||||
})
|
||||
|
||||
test("should throw error when both baseUrl and apiKey are missing", () => {
|
||||
@@ -649,6 +649,69 @@ describe("OpenAICompatibleEmbedder", () => {
|
||||
expect(baseResult.embeddings[0]).toEqual([0.4, 0.5, 0.6])
|
||||
})
|
||||
|
||||
test("should omit auth headers for a keyless full endpoint", async () => {
|
||||
const embedder = new OpenAICompatibleEmbedder(azureUrl, undefined, testModelId)
|
||||
const base64String = createBase64Embedding([0.1, 0.2, 0.3])
|
||||
mockFetch.mockResolvedValue(
|
||||
createMockResponse({
|
||||
data: [{ embedding: base64String }],
|
||||
usage: { prompt_tokens: 1, total_tokens: 1 },
|
||||
}) as any,
|
||||
)
|
||||
|
||||
await embedder.createEmbeddings(["test"])
|
||||
|
||||
const init = mockFetch.mock.calls[0]?.[1] as RequestInit | undefined
|
||||
const headers = new Headers(init?.headers)
|
||||
expect(headers.get("authorization")).toBeNull()
|
||||
expect(headers.get("api-key")).toBeNull()
|
||||
})
|
||||
|
||||
test("should preserve custom headers for a keyless full endpoint", async () => {
|
||||
const embedder = new OpenAICompatibleEmbedder(azureUrl, undefined, testModelId, undefined, {
|
||||
headers: { Authorization: "Custom token", "x-fixture": "present" },
|
||||
})
|
||||
const base64String = createBase64Embedding([0.1, 0.2, 0.3])
|
||||
mockFetch.mockResolvedValue(
|
||||
createMockResponse({
|
||||
data: [{ embedding: base64String }],
|
||||
usage: { prompt_tokens: 1, total_tokens: 1 },
|
||||
}) as any,
|
||||
)
|
||||
|
||||
await embedder.createEmbeddings(["test"])
|
||||
|
||||
const init = mockFetch.mock.calls[0]?.[1] as RequestInit | undefined
|
||||
const headers = new Headers(init?.headers)
|
||||
expect(headers.get("authorization")).toBe("Custom token")
|
||||
expect(headers.get("x-fixture")).toBe("present")
|
||||
})
|
||||
|
||||
test("should omit generated SDK auth headers when no key is configured", async () => {
|
||||
let request: ((input: string | URL | Request, init?: RequestInit) => Promise<Response>) | undefined
|
||||
setOpenAIConstructorHook((config) => {
|
||||
request = config.fetch
|
||||
})
|
||||
new OpenAICompatibleEmbedder(baseUrl, undefined, testModelId)
|
||||
mockFetch.mockResolvedValue(new Response())
|
||||
|
||||
if (!request) throw new Error("Missing OpenAI-compatible fetch adapter")
|
||||
await request("https://api.example.com/v1/embeddings", {
|
||||
headers: {
|
||||
Authorization: "Bearer EMPTY",
|
||||
"api-key": "EMPTY",
|
||||
"x-fixture": "present",
|
||||
},
|
||||
})
|
||||
|
||||
const init = mockFetch.mock.calls[0]?.[1] as RequestInit | undefined
|
||||
const headers = new Headers(init?.headers)
|
||||
expect(init?.headers).not.toBeInstanceOf(Headers)
|
||||
expect(headers.get("authorization")).toBeNull()
|
||||
expect(headers.get("api-key")).toBeNull()
|
||||
expect(headers.get("x-fixture")).toBe("present")
|
||||
})
|
||||
|
||||
test.each([
|
||||
[401, "Authentication failed. Please check your API key."],
|
||||
[500, "Embedding request failed after 3 attempts"],
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { mkdtemp, rm } from "node:fs/promises"
|
||||
import { tmpdir } from "node:os"
|
||||
import { join } from "node:path"
|
||||
import { CodeIndexManager } from "../../../src/indexing/manager"
|
||||
import type { IndexingConfigInput } from "../../../src/indexing/config-manager"
|
||||
import type { IndexingTelemetryEvent, IndexingTelemetryTrigger } from "../../../src/indexing/interfaces/telemetry"
|
||||
@@ -100,6 +103,73 @@ describe("CodeIndexManager", () => {
|
||||
expect(mgr.getCurrentStatus().message).toContain("not configured")
|
||||
})
|
||||
|
||||
test("initializes an unauthenticated OpenAI-compatible endpoint without auth headers", async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "kilo-keyless-indexing-"))
|
||||
const workspace = join(root, "workspace")
|
||||
const cache = join(root, "cache")
|
||||
const requests: Array<{ authorization: string | null; apiKey: string | null }> = []
|
||||
await Bun.write(join(workspace, ".gitkeep"), "")
|
||||
const server = Bun.serve({
|
||||
hostname: "127.0.0.1",
|
||||
port: 0,
|
||||
fetch(req) {
|
||||
requests.push({
|
||||
authorization: req.headers.get("authorization"),
|
||||
apiKey: req.headers.get("api-key"),
|
||||
})
|
||||
return Response.json({
|
||||
object: "list",
|
||||
data: [{ object: "embedding", index: 0, embedding: [0.1, 0.2, 0.3] }],
|
||||
model: "fixture-model",
|
||||
usage: { prompt_tokens: 1, total_tokens: 1 },
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
try {
|
||||
const script = `
|
||||
import { CodeIndexManager } from "./src/indexing/manager.ts"
|
||||
import { normalizeIndexingStatus } from "./src/status.ts"
|
||||
const mgr = new CodeIndexManager(${JSON.stringify(workspace)}, ${JSON.stringify(cache)})
|
||||
try {
|
||||
await mgr.initialize({
|
||||
enabled: true,
|
||||
embedderProvider: "openai-compatible",
|
||||
vectorStoreProvider: "lancedb",
|
||||
modelId: "fixture-model",
|
||||
modelDimension: 3,
|
||||
openAiCompatibleBaseUrl: ${JSON.stringify(`http://127.0.0.1:${server.port}/v1`)},
|
||||
})
|
||||
if (!mgr.isInitialized) throw new Error("Manager did not initialize")
|
||||
if (normalizeIndexingStatus(mgr).state === "Disabled") throw new Error("Indexing remained disabled")
|
||||
} finally {
|
||||
await mgr.dispose()
|
||||
}
|
||||
`
|
||||
const child = Bun.spawn([process.execPath, "-e", script], {
|
||||
cwd: join(import.meta.dir, "../../.."),
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
windowsHide: true,
|
||||
})
|
||||
const gate = Promise.withResolvers<never>()
|
||||
const timeout = setTimeout(() => {
|
||||
child.kill()
|
||||
gate.reject(new Error("Indexing subprocess timed out"))
|
||||
}, 10_000)
|
||||
const [exit, stderr] = await Promise.race([
|
||||
Promise.all([child.exited, new Response(child.stderr).text()]),
|
||||
gate.promise,
|
||||
]).finally(() => clearTimeout(timeout))
|
||||
if (exit !== 0) throw new Error(stderr)
|
||||
|
||||
expect(requests).toEqual([{ authorization: null, apiKey: null }])
|
||||
} finally {
|
||||
server.stop(true)
|
||||
await rm(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test("cancels active indexing when configuration is removed", async () => {
|
||||
const mgr = new CodeIndexManager("/tmp/ws", "/tmp/cache")
|
||||
let stop = 0
|
||||
|
||||
@@ -29,6 +29,16 @@ describe("CodeIndexServiceFactory", () => {
|
||||
setOpenAIConstructorHook(undefined)
|
||||
})
|
||||
|
||||
test("creates an OpenAI-compatible embedder without an API key", () => {
|
||||
const factory = createFactory({
|
||||
embedderProvider: "openai-compatible",
|
||||
openAiKey: undefined,
|
||||
openAiCompatibleBaseUrl: "http://localhost:1234/v1",
|
||||
})
|
||||
|
||||
expect(factory.createEmbedder().embedderInfo).toEqual({ name: "openai-compatible" })
|
||||
})
|
||||
|
||||
test("uses default LanceDB directory when config is unset", () => {
|
||||
const factory = createFactory({ vectorStoreProvider: "lancedb", lancedbVectorStoreDirectory: undefined })
|
||||
|
||||
|
||||
+18
-16
@@ -39,9 +39,11 @@ import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import java.awt.event.ActionEvent
|
||||
import javax.swing.JLabel
|
||||
import javax.swing.JComponent
|
||||
import javax.swing.JPanel
|
||||
import javax.swing.Timer
|
||||
|
||||
@Suppress("UnstableApiUsage")
|
||||
class SessionSidePanelManagerTest : BasePlatformTestCase() {
|
||||
@@ -221,7 +223,7 @@ class SessionSidePanelManagerTest : BasePlatformTestCase() {
|
||||
manager.openSession(session("ses_1"))
|
||||
val first = active(manager)
|
||||
manager.openSession(session("ses_2"))
|
||||
settle { first !in ui }
|
||||
expire(manager, first)
|
||||
|
||||
assertFalse(ui.contains(first))
|
||||
assertEquals(listOf("/test" to "ses_1", "/test" to "ses_2"), created)
|
||||
@@ -234,7 +236,7 @@ class SessionSidePanelManagerTest : BasePlatformTestCase() {
|
||||
manager.openSession(session("ses_1"))
|
||||
val first = active(manager)
|
||||
manager.openSession(session("ses_2"))
|
||||
settle { first !in ui }
|
||||
expire(manager, first)
|
||||
manager.openSession(session("ses_1"))
|
||||
|
||||
assertNotSame(first, active(manager))
|
||||
@@ -255,7 +257,7 @@ class SessionSidePanelManagerTest : BasePlatformTestCase() {
|
||||
flow.emit(ChatEventDto.MessageUpdated("ses_1", msg("msg_hidden", "ses_1", "assistant")))
|
||||
flow.emit(ChatEventDto.PartDelta("ses_1", "msg_hidden", "txt_hidden", "text", "stale"))
|
||||
}
|
||||
settle { first !in ui }
|
||||
expire(manager, first)
|
||||
manager.openSession(session("ses_1"))
|
||||
val second = active(manager)
|
||||
settle()
|
||||
@@ -588,7 +590,7 @@ class SessionSidePanelManagerTest : BasePlatformTestCase() {
|
||||
first.controller().model.setState(SessionState.AwaitingPermission(permission("ses_1")))
|
||||
}
|
||||
manager.openSession(session("ses_2"))
|
||||
settle { first !in ui }
|
||||
expire(manager, first)
|
||||
|
||||
assertFalse(ui.contains(first))
|
||||
assertEquals(emptyMap<String, SessionActivityKind>(), manager.activity())
|
||||
@@ -603,7 +605,7 @@ class SessionSidePanelManagerTest : BasePlatformTestCase() {
|
||||
first.controller().model.setState(SessionState.Busy("running"))
|
||||
}
|
||||
manager.openSession(session("ses_2"))
|
||||
settle { first !in ui }
|
||||
expire(manager, first)
|
||||
|
||||
assertFalse(ui.contains(first))
|
||||
}
|
||||
@@ -614,7 +616,7 @@ class SessionSidePanelManagerTest : BasePlatformTestCase() {
|
||||
manager.openSession(session("ses_1"))
|
||||
val first = active(manager)
|
||||
manager.openSession(session("ses_2"))
|
||||
settle { first !in ui }
|
||||
expire(manager, first)
|
||||
|
||||
assertFalse(ui.contains(first))
|
||||
assertEquals(emptyMap<String, SessionActivityKind>(), manager.activity())
|
||||
@@ -698,6 +700,16 @@ class SessionSidePanelManagerTest : BasePlatformTestCase() {
|
||||
method.invoke(manager)
|
||||
}
|
||||
|
||||
private fun expire(manager: SessionSidePanelManager, ui: JPanel) {
|
||||
val field = SessionSidePanelManager::class.java.getDeclaredField("timers")
|
||||
field.isAccessible = true
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
val timers = field.get(manager) as Map<JPanel, Timer>
|
||||
val timer = requireNotNull(timers[ui]) { "Expected an inactive session disposal timer" }
|
||||
timer.stop()
|
||||
timer.actionListeners.single().actionPerformed(ActionEvent(timer, ActionEvent.ACTION_PERFORMED, "expire"))
|
||||
}
|
||||
|
||||
private fun settle() = kotlinx.coroutines.runBlocking {
|
||||
repeat(5) {
|
||||
kotlinx.coroutines.delay(100)
|
||||
@@ -707,16 +719,6 @@ class SessionSidePanelManagerTest : BasePlatformTestCase() {
|
||||
}
|
||||
}
|
||||
|
||||
private fun settle(done: () -> Boolean) = kotlinx.coroutines.runBlocking {
|
||||
repeat(50) {
|
||||
if (done()) return@runBlocking
|
||||
kotlinx.coroutines.delay(20)
|
||||
com.intellij.openapi.application.ApplicationManager.getApplication().invokeAndWait {
|
||||
com.intellij.util.ui.UIUtil.dispatchAllInvocationEvents()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun session(id: String) = session(id, "/test")
|
||||
|
||||
private fun session(id: String, dir: String, title: String = "Session $id") = SessionDto(
|
||||
|
||||
@@ -67,6 +67,7 @@ export enum TelemetryEventName {
|
||||
// Kilo-specific
|
||||
COMMIT_MSG_GENERATED = "Commit Message Generated",
|
||||
AGENT_MANAGER_OPENED = "Agent Manager Opened",
|
||||
AGENT_MANAGER_BUTTON_CLICKED = "Agent Manager Button Clicked",
|
||||
AGENT_MANAGER_SESSION_STARTED = "Agent Manager Session Started",
|
||||
AGENT_MANAGER_SESSION_COMPLETED = "Agent Manager Session Completed",
|
||||
AGENT_MANAGER_SESSION_STOPPED = "Agent Manager Session Stopped",
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import { describe, expect, it } from "bun:test"
|
||||
import type { TelemetryRequest } from "../../webview-ui/src/types/messages/webview-messages"
|
||||
import { capture, tracker } from "../../webview-ui/agent-manager/telemetry"
|
||||
import { TelemetryEventName } from "../../src/services/telemetry/types"
|
||||
|
||||
describe("Agent Manager telemetry", () => {
|
||||
it("uses one stable event with low-cardinality button metadata", () => {
|
||||
const messages: TelemetryRequest[] = []
|
||||
|
||||
capture({ postMessage: (message) => messages.push(message) }, "fullscreen_review", "tab_toolbar", {
|
||||
action: "open",
|
||||
fileCount: 3,
|
||||
})
|
||||
|
||||
expect(messages).toEqual([
|
||||
{
|
||||
type: "telemetry",
|
||||
event: TelemetryEventName.AGENT_MANAGER_BUTTON_CLICKED,
|
||||
properties: {
|
||||
action: "open",
|
||||
fileCount: 3,
|
||||
source: "agent-manager",
|
||||
button: "fullscreen_review",
|
||||
surface: "tab_toolbar",
|
||||
},
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it("does not allow callers to override event dimensions", () => {
|
||||
const messages: TelemetryRequest[] = []
|
||||
|
||||
capture({ postMessage: (message) => messages.push(message) }, "apply_to_local", "apply_dialog", {
|
||||
source: "other",
|
||||
button: "other",
|
||||
surface: "other",
|
||||
})
|
||||
|
||||
expect(messages[0]?.properties).toMatchObject({
|
||||
source: "agent-manager",
|
||||
button: "apply_to_local",
|
||||
surface: "apply_dialog",
|
||||
})
|
||||
})
|
||||
|
||||
it("resolves current properties before running wrapped actions", () => {
|
||||
const messages: TelemetryRequest[] = []
|
||||
const order: string[] = []
|
||||
const metrics = tracker({
|
||||
postMessage: (message) => {
|
||||
messages.push(message)
|
||||
order.push("telemetry")
|
||||
},
|
||||
})
|
||||
const state = { action: "run" }
|
||||
const click = metrics.click(
|
||||
"run_script",
|
||||
"tab_toolbar",
|
||||
() => order.push("action"),
|
||||
() => state,
|
||||
)
|
||||
state.action = "stop"
|
||||
|
||||
click()
|
||||
|
||||
expect(messages[0]?.properties?.action).toBe("stop")
|
||||
expect(order).toEqual(["telemetry", "action"])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,20 @@
|
||||
import { describe, expect, it } from "bun:test"
|
||||
import { tracksElapsed } from "../../webview-ui/src/components/shared/working-indicator-utils"
|
||||
|
||||
describe("tracksElapsed", () => {
|
||||
it("tracks pending submissions before backend status arrives", () => {
|
||||
expect(tracksElapsed("idle", true, 1)).toBe(true)
|
||||
})
|
||||
|
||||
it("tracks active backend statuses", () => {
|
||||
expect(tracksElapsed("busy", false, 1)).toBe(true)
|
||||
expect(tracksElapsed("retry", false, 1)).toBe(true)
|
||||
expect(tracksElapsed("offline", false, 1)).toBe(true)
|
||||
})
|
||||
|
||||
it("stops for idle sessions and missing start times", () => {
|
||||
expect(tracksElapsed("idle", false, 1)).toBe(false)
|
||||
expect(tracksElapsed("busy", false, undefined)).toBe(false)
|
||||
expect(tracksElapsed("idle", true, undefined)).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -134,6 +134,7 @@ import { createSidebarCollapse } from "./sidebar-collapse"
|
||||
import { SidebarToggleButton } from "./SidebarToggleButton"
|
||||
import { setTabWidths } from "./tab-widths"
|
||||
import { buildShortcutCategories } from "./shortcuts"
|
||||
import { tracker } from "./telemetry"
|
||||
import "./agent-manager.css"
|
||||
import "./agent-manager-review.css"
|
||||
const REVIEW_TAB_ID = "review"
|
||||
@@ -203,6 +204,7 @@ const AgentManagerContent: Component = () => {
|
||||
const [worktrees, setWorktrees] = createSignal<WorktreeState[]>([])
|
||||
const [managedSessions, setManagedSessions] = createSignal<ManagedSessionState[]>([])
|
||||
const [selection, setSelection] = createSignal<SidebarSelection>(LOCAL)
|
||||
const metrics = tracker(vscode)
|
||||
const [repoBranch, setRepoBranch] = createSignal<string | undefined>()
|
||||
const [busyWorktrees, setBusyWorktrees] = createSignal<Map<string, WorktreeBusyState>>(new Map())
|
||||
const [staleWorktreeIds, setStaleWorktreeIds] = createSignal<Set<string>>(new Set())
|
||||
@@ -447,6 +449,7 @@ const AgentManagerContent: Component = () => {
|
||||
if (!target) return
|
||||
if (!applyHasSelection()) return
|
||||
if (applyBusyForTarget()) return
|
||||
metrics.track("apply_to_local", "apply_dialog", { fileCount: applySelectedFiles().length })
|
||||
applyToLocal(target, applySelectedFiles())
|
||||
}
|
||||
|
||||
@@ -497,6 +500,7 @@ const AgentManagerContent: Component = () => {
|
||||
if (!sel || sel === LOCAL) return
|
||||
vscode.postMessage({ type: "agentManager.openWorktree", worktreeId: sel })
|
||||
}
|
||||
const openWindow = metrics.click("open_worktree_window", "tab_toolbar", openWorktreeDirectory)
|
||||
|
||||
const runWorktree = (id: string) => {
|
||||
const state = runStatuses()[id]?.state ?? "idle"
|
||||
@@ -1600,6 +1604,7 @@ const AgentManagerContent: Component = () => {
|
||||
const handleConfigureSetupScript = () => {
|
||||
vscode.postMessage({ type: "agentManager.configureSetupScript" })
|
||||
}
|
||||
const setupScript = metrics.click("configure_setup_script", "worktree_settings", handleConfigureSetupScript)
|
||||
|
||||
const handleChangeDefaultBaseBranch = () => {
|
||||
const [search, setSearch] = createSignal("")
|
||||
@@ -1735,6 +1740,7 @@ const AgentManagerContent: Component = () => {
|
||||
expandSidebar()
|
||||
vscode.postMessage({ type: "agentManager.createWorktree" })
|
||||
}
|
||||
const createWorktree = metrics.click("new_worktree", "worktrees", handleCreateWorktree)
|
||||
|
||||
// Advanced worktree dialog — opens a full dialog with prompt, versions, model, mode
|
||||
const showAdvancedWorktreeDialog = () => {
|
||||
@@ -1829,6 +1835,7 @@ const AgentManagerContent: Component = () => {
|
||||
const handlePromote = (sessionId: string, e: MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
if (!loaded()) return
|
||||
metrics.track("promote_session", "unassigned_session")
|
||||
vscode.postMessage({ type: "agentManager.promoteSession", sessionId })
|
||||
}
|
||||
|
||||
@@ -2204,7 +2211,7 @@ const AgentManagerContent: Component = () => {
|
||||
size="small"
|
||||
variant="ghost"
|
||||
label={t("agentManager.worktree.new")}
|
||||
onClick={handleCreateWorktree}
|
||||
onClick={createWorktree}
|
||||
disabled={!loaded()}
|
||||
/>
|
||||
<DropdownMenu gutter={4} placement="bottom-end">
|
||||
@@ -2217,7 +2224,7 @@ const AgentManagerContent: Component = () => {
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Portal>
|
||||
<DropdownMenu.Content class="am-split-menu">
|
||||
<DropdownMenu.Item onSelect={handleCreateWorktree}>
|
||||
<DropdownMenu.Item onSelect={createWorktree}>
|
||||
<span class="am-worktree-menu-gap" aria-hidden="true" />
|
||||
<DropdownMenu.ItemLabel class="am-worktree-menu-label">
|
||||
<span>{t("sidebar.session.newWorktree.from")}</span>
|
||||
@@ -2260,7 +2267,7 @@ const AgentManagerContent: Component = () => {
|
||||
size="small"
|
||||
variant="ghost"
|
||||
label={t("agentManager.shortcuts.title")}
|
||||
onClick={handleShowKeyboardShortcuts}
|
||||
onClick={metrics.click("keyboard_shortcuts", "worktrees_header", handleShowKeyboardShortcuts)}
|
||||
/>
|
||||
</TooltipKeybind>
|
||||
<DropdownMenu gutter={4} placement="bottom-end">
|
||||
@@ -2273,7 +2280,7 @@ const AgentManagerContent: Component = () => {
|
||||
/>
|
||||
<DropdownMenu.Portal>
|
||||
<DropdownMenu.Content class="am-split-menu">
|
||||
<DropdownMenu.Item onSelect={handleConfigureSetupScript}>
|
||||
<DropdownMenu.Item onSelect={setupScript}>
|
||||
<DropdownMenu.ItemLabel>{t("agentManager.worktree.setupScript")}</DropdownMenu.ItemLabel>
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Separator />
|
||||
@@ -2427,13 +2434,13 @@ const AgentManagerContent: Component = () => {
|
||||
prStatuses()[wt.id] !== undefined ? (prStatuses()[wt.id] ?? undefined) : undefined
|
||||
}
|
||||
runStatus={runStatuses()[wt.id]}
|
||||
onOpenPR={() =>
|
||||
vscode.postMessage({ type: "agentManager.openPR", worktreeId: wt.id })
|
||||
}
|
||||
onOpenPR={metrics.click("open_pull_request", "worktree_menu", () =>
|
||||
vscode.postMessage({ type: "agentManager.openPR", worktreeId: wt.id }),
|
||||
)}
|
||||
sections={sections()}
|
||||
currentSectionId={wt.sectionId}
|
||||
onMoveToSection={(secId) => moveToSection([wt.id], secId)}
|
||||
onMoveToNewSection={() => newSection()}
|
||||
onMoveToNewSection={metrics.click("new_section", "worktree_menu", () => newSection())}
|
||||
onClick={() => {
|
||||
if (pendingDelete() === wt.id) {
|
||||
confirmDeleteWorktree(wt.id)
|
||||
@@ -2448,9 +2455,9 @@ const AgentManagerContent: Component = () => {
|
||||
onCancelRename={cancelRename}
|
||||
onRemoveStale={() => confirmRemoveStaleWorktree(wt.id)}
|
||||
onCopyPath={() => navigator.clipboard.writeText(wt.path)}
|
||||
onOpen={() =>
|
||||
vscode.postMessage({ type: "agentManager.openWorktree", worktreeId: wt.id })
|
||||
}
|
||||
onOpen={metrics.click("open_worktree_window", "worktree_menu", () =>
|
||||
vscode.postMessage({ type: "agentManager.openWorktree", worktreeId: wt.id }),
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
@@ -2518,7 +2525,7 @@ const AgentManagerContent: Component = () => {
|
||||
)
|
||||
})()}
|
||||
<Show when={worktrees().length === 0}>
|
||||
<button class="am-worktree-create" onClick={handleCreateWorktree}>
|
||||
<button class="am-worktree-create" onClick={createWorktree}>
|
||||
<Icon name="plus" size="small" />
|
||||
<span>{t("agentManager.worktree.new")}</span>
|
||||
</button>
|
||||
@@ -2607,7 +2614,11 @@ const AgentManagerContent: Component = () => {
|
||||
<Icon name="branch" size="small" />
|
||||
<ContextMenu.ItemLabel>{t("agentManager.session.openInWorktree")}</ContextMenu.ItemLabel>
|
||||
</ContextMenu.Item>
|
||||
<ContextMenu.Item onSelect={() => openLocally(s.id)}>
|
||||
<ContextMenu.Item
|
||||
onSelect={metrics.click("open_session_locally", "unassigned_session_menu", () =>
|
||||
openLocally(s.id),
|
||||
)}
|
||||
>
|
||||
<Icon name="folder" size="small" />
|
||||
<ContextMenu.ItemLabel>{t("agentManager.session.openLocally")}</ContextMenu.ItemLabel>
|
||||
</ContextMenu.Item>
|
||||
@@ -2711,8 +2722,8 @@ const AgentManagerContent: Component = () => {
|
||||
newTerminalLabel: t("agentManager.terminal.new"),
|
||||
newSessionMenuLabel: t("agentManager.session.newSession"),
|
||||
moreOptionsLabel: t("agentManager.tab.newOptions"),
|
||||
onNewSession: handleAddSession,
|
||||
onNewTerminal: () => termHandlers.requestNew(),
|
||||
onNewSession: metrics.click("new_session", "tab_bar", handleAddSession),
|
||||
onNewTerminal: metrics.click("embedded_terminal", "new_tab_menu", () => termHandlers.requestNew()),
|
||||
})}
|
||||
</div>
|
||||
</Show>
|
||||
@@ -2738,7 +2749,7 @@ const AgentManagerContent: Component = () => {
|
||||
<Show when={isWorktree()}>
|
||||
<>
|
||||
<Tooltip value={t("agentManager.open.tooltip")} placement="bottom">
|
||||
<Button size="small" variant="ghost" icon="folder" onClick={openWorktreeDirectory}>
|
||||
<Button size="small" variant="ghost" icon="folder" onClick={openWindow}>
|
||||
{t("agentManager.open.button")}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
@@ -2774,7 +2785,14 @@ const AgentManagerContent: Component = () => {
|
||||
variant="ghost"
|
||||
icon={active() ? "stop" : "play"}
|
||||
disabled={rs()?.state === "stopping"}
|
||||
onClick={() => runWorktree(rid())}
|
||||
onClick={metrics.click(
|
||||
"run_script",
|
||||
"tab_toolbar",
|
||||
() => runWorktree(rid()),
|
||||
() => ({
|
||||
action: active() ? "stop" : configured() ? "run" : "configure",
|
||||
}),
|
||||
)}
|
||||
>
|
||||
{active() ? "Stop" : "Run"}
|
||||
</Button>
|
||||
@@ -2794,7 +2812,9 @@ const AgentManagerContent: Component = () => {
|
||||
/>
|
||||
<DropdownMenu.Portal>
|
||||
<DropdownMenu.Content class="am-split-menu">
|
||||
<DropdownMenu.Item onSelect={configureRunScript}>
|
||||
<DropdownMenu.Item
|
||||
onSelect={metrics.click("configure_run_script", "run_menu", configureRunScript)}
|
||||
>
|
||||
<Icon name="settings-gear" size="small" />
|
||||
<DropdownMenu.ItemLabel>{t("agentManager.run.configure")}</DropdownMenu.ItemLabel>
|
||||
</DropdownMenu.Item>
|
||||
@@ -2813,6 +2833,9 @@ const AgentManagerContent: Component = () => {
|
||||
<button
|
||||
class={`am-diff-toggle-btn ${diffOpen() && !reviewActive() ? "am-tab-diff-btn-active" : ""} ${hasChanges() ? "am-diff-toggle-has-changes" : ""}`}
|
||||
onClick={() => {
|
||||
metrics.track("side_review", "tab_toolbar", {
|
||||
action: diffOpen() && !reviewActive() ? "close" : "open",
|
||||
})
|
||||
if (reviewActive()) {
|
||||
closeReviewTab()
|
||||
setSidePanel("diff")
|
||||
@@ -2845,7 +2868,7 @@ const AgentManagerContent: Component = () => {
|
||||
variant="ghost"
|
||||
label={t("command.review.toggle")}
|
||||
class={reviewActive() ? "am-tab-diff-btn-active" : ""}
|
||||
onClick={toggleReviewTab}
|
||||
onClick={metrics.click("fullscreen_review", "tab_toolbar", toggleReviewTab)}
|
||||
/>
|
||||
</Tooltip>
|
||||
</Show>
|
||||
@@ -2864,6 +2887,7 @@ const AgentManagerContent: Component = () => {
|
||||
variant="ghost"
|
||||
label={t("agentManager.tab.openTerminal")}
|
||||
onClick={() => {
|
||||
metrics.track("vscode_terminal", "tab_toolbar")
|
||||
const id = session.currentSessionID()
|
||||
if (id) vscode.postMessage({ type: "agentManager.showTerminal", sessionId: id })
|
||||
else if (selection() === LOCAL) vscode.postMessage({ type: "agentManager.showLocalTerminal" })
|
||||
@@ -3020,6 +3044,7 @@ const AgentManagerContent: Component = () => {
|
||||
if (!loaded()) return
|
||||
const sid = session.currentSessionID()
|
||||
if (!sid) return
|
||||
metrics.track("open_session_locally", "readonly_banner")
|
||||
openLocally(sid)
|
||||
}}
|
||||
>
|
||||
@@ -3031,7 +3056,9 @@ const AgentManagerContent: Component = () => {
|
||||
onClick={() => {
|
||||
if (!loaded()) return
|
||||
const sid = session.currentSessionID()
|
||||
if (sid) vscode.postMessage({ type: "agentManager.promoteSession", sessionId: sid })
|
||||
if (!sid) return
|
||||
metrics.track("promote_session", "readonly_banner")
|
||||
vscode.postMessage({ type: "agentManager.promoteSession", sessionId: sid })
|
||||
}}
|
||||
>
|
||||
{t("agentManager.session.openInWorktree")}
|
||||
@@ -3073,8 +3100,13 @@ const AgentManagerContent: Component = () => {
|
||||
comments={reviewComments()}
|
||||
onCommentsChange={setReviewCommentsForSelection}
|
||||
composer={reviewComposer}
|
||||
onClose={() => setSidePanel(null)}
|
||||
onExpand={selection() !== null ? openReviewTab : undefined}
|
||||
onSendClick={() => metrics.track("send_review_comments", "side_review")}
|
||||
onClose={metrics.click("side_review_close", "side_review", () => setSidePanel(null))}
|
||||
onExpand={
|
||||
selection() !== null
|
||||
? metrics.click("fullscreen_review", "side_review", openReviewTab, { action: "open" })
|
||||
: undefined
|
||||
}
|
||||
onRequestDiff={requestDiffFile}
|
||||
onOpenFile={(file, line) => {
|
||||
const id = currentDiffSessionId()
|
||||
@@ -3082,7 +3114,7 @@ const AgentManagerContent: Component = () => {
|
||||
vscode.postMessage({ type: "agentManager.openFile", sessionId: id, filePath: file, line })
|
||||
else if (selection() === LOCAL) vscode.postMessage({ type: "openFile", filePath: file, line })
|
||||
}}
|
||||
onRevertFile={revertCtl.revert}
|
||||
onRevertFile={metrics.use("revert_file", "side_review", revertCtl.revert)}
|
||||
revertingFiles={revertCtl.reverting()}
|
||||
activeTerminalId={terms.activeId()}
|
||||
/>
|
||||
@@ -3104,6 +3136,7 @@ const AgentManagerContent: Component = () => {
|
||||
onCommentsChange={setReviewCommentsForSelection}
|
||||
composer={reviewComposer}
|
||||
onSendAll={closeReviewTab}
|
||||
onSendClick={() => metrics.track("send_review_comments", "fullscreen_review")}
|
||||
diffStyle={reviewDiffStyle()}
|
||||
onDiffStyleChange={setSharedDiffStyle}
|
||||
markdownRender={markdown.render()}
|
||||
@@ -3114,10 +3147,10 @@ const AgentManagerContent: Component = () => {
|
||||
if (id) vscode.postMessage({ type: "agentManager.openFile", sessionId: id, filePath: file, line })
|
||||
else if (selection() === LOCAL) vscode.postMessage({ type: "openFile", filePath: file, line })
|
||||
}}
|
||||
onRevertFile={revertCtl.revert}
|
||||
onRevertFile={metrics.use("revert_file", "fullscreen_review", revertCtl.revert)}
|
||||
revertingFiles={revertCtl.reverting()}
|
||||
activeTerminalId={terms.activeId()}
|
||||
onClose={closeReviewTab}
|
||||
onClose={metrics.click("fullscreen_review", "fullscreen_review", closeReviewTab, { action: "close" })}
|
||||
/>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
@@ -75,6 +75,7 @@ interface DiffPanelProps {
|
||||
onCommentsChange: (comments: ReviewComment[]) => void
|
||||
composer?: ReviewComposer
|
||||
onSendAll?: () => void
|
||||
onSendClick?: () => void
|
||||
onClose: () => void
|
||||
onExpand?: () => void
|
||||
onRequestDiff?: (file: string) => void
|
||||
@@ -439,6 +440,11 @@ export const DiffPanel: Component<DiffPanelProps> = (props) => {
|
||||
props.onSendAll?.()
|
||||
}
|
||||
|
||||
const sendAllClick = () => {
|
||||
props.onSendClick?.()
|
||||
sendAllToChat()
|
||||
}
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key !== "Enter") return
|
||||
if (!(e.metaKey || e.ctrlKey)) return
|
||||
@@ -731,7 +737,7 @@ export const DiffPanel: Component<DiffPanelProps> = (props) => {
|
||||
{comments().length} comment{comments().length !== 1 ? "s" : ""}
|
||||
</span>
|
||||
<TooltipKeybind title={t("agentManager.review.sendAllToChat")} keybind={sendAllKeybind()} placement="top">
|
||||
<Button variant="primary" size="small" onClick={sendAllToChat}>
|
||||
<Button variant="primary" size="small" onClick={sendAllClick}>
|
||||
{t("agentManager.review.sendAllToChat")}
|
||||
</Button>
|
||||
</TooltipKeybind>
|
||||
|
||||
@@ -34,6 +34,7 @@ import { useSpeechToText } from "../src/components/speech-to-text/useSpeechToTex
|
||||
import { convertToMentionPath } from "../src/utils/path-mentions"
|
||||
import { insertSpacedText } from "../src/components/chat/prompt-input-utils"
|
||||
import { BranchSelect, BranchSelectPopover } from "../src/components/shared/BranchSelect"
|
||||
import { tracker } from "./telemetry"
|
||||
|
||||
type VersionCount = 1 | 2 | 3 | 4
|
||||
const VERSION_OPTIONS: VersionCount[] = [1, 2, 3, 4]
|
||||
@@ -71,6 +72,10 @@ export const NewWorktreeDialog: Component<{ onClose: () => void; defaultBaseBran
|
||||
const session = useSession()
|
||||
const provider = useProvider()
|
||||
const { config } = useConfig()
|
||||
const metrics = tracker(vscode)
|
||||
const track = (button: string, properties?: Record<string, string | number | boolean | undefined>) =>
|
||||
metrics.track(button, "configure_worktree_dialog", properties)
|
||||
const click = metrics.click
|
||||
|
||||
const [tab, setTab] = createSignal<DialogTab>("new")
|
||||
|
||||
@@ -209,6 +214,8 @@ export const NewWorktreeDialog: Component<{ onClose: () => void; defaultBaseBran
|
||||
if (compareMode() && totalAllocations(modelAllocations()) === 0) return false
|
||||
return true
|
||||
}
|
||||
const total = () => (compareMode() ? totalAllocations(modelAllocations()) : versions())
|
||||
const mode = () => (compareMode() ? "compare_models" : versions() > 1 ? "multiple_versions" : "single")
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!canSubmit()) return
|
||||
@@ -224,7 +231,7 @@ export const NewWorktreeDialog: Component<{ onClose: () => void; defaultBaseBran
|
||||
|
||||
const isCompare = compareMode()
|
||||
const allocations = isCompare ? allocationsToArray(modelAllocations()) : undefined
|
||||
const count = isCompare ? totalAllocations(modelAllocations()) : versions()
|
||||
const count = total()
|
||||
const sel = isCompare ? null : model()
|
||||
|
||||
vscode.postMessage({
|
||||
@@ -320,6 +327,7 @@ export const NewWorktreeDialog: Component<{ onClose: () => void; defaultBaseBran
|
||||
|
||||
const handleBranchSelect = (name: string) => {
|
||||
if (isPending()) return
|
||||
track("import_branch")
|
||||
setImportPending(true)
|
||||
setBranchOpen(false)
|
||||
setBranchSearch("")
|
||||
@@ -333,7 +341,7 @@ export const NewWorktreeDialog: Component<{ onClose: () => void; defaultBaseBran
|
||||
<button
|
||||
class="am-tab-switcher-pill"
|
||||
classList={{ "am-tab-switcher-pill-active": tab() === "new" }}
|
||||
onClick={() => setTab("new")}
|
||||
onClick={click("switch_dialog_tab", "configure_worktree_dialog", () => setTab("new"), { tab: "new" })}
|
||||
type="button"
|
||||
>
|
||||
{t("agentManager.dialog.tab.new")}
|
||||
@@ -341,7 +349,7 @@ export const NewWorktreeDialog: Component<{ onClose: () => void; defaultBaseBran
|
||||
<button
|
||||
class="am-tab-switcher-pill"
|
||||
classList={{ "am-tab-switcher-pill-active": tab() === "import" }}
|
||||
onClick={() => setTab("import")}
|
||||
onClick={click("switch_dialog_tab", "configure_worktree_dialog", () => setTab("import"), { tab: "import" })}
|
||||
type="button"
|
||||
>
|
||||
{t("agentManager.dialog.tab.import")}
|
||||
@@ -460,7 +468,16 @@ export const NewWorktreeDialog: Component<{ onClose: () => void; defaultBaseBran
|
||||
</div>
|
||||
|
||||
{/* Advanced options toggle */}
|
||||
<button class="am-advanced-toggle" onClick={() => setShowAdvanced(!showAdvanced())} type="button">
|
||||
<button
|
||||
class="am-advanced-toggle"
|
||||
onClick={click(
|
||||
"advanced_options",
|
||||
"configure_worktree_dialog",
|
||||
() => setShowAdvanced(!showAdvanced()),
|
||||
() => ({ action: showAdvanced() ? "close" : "open" }),
|
||||
)}
|
||||
type="button"
|
||||
>
|
||||
<Icon name={showAdvanced() ? "chevron-down" : "chevron-right"} size="small" />
|
||||
<span>{t("agentManager.dialog.advancedOptions")}</span>
|
||||
</button>
|
||||
@@ -584,7 +601,9 @@ export const NewWorktreeDialog: Component<{ onClose: () => void; defaultBaseBran
|
||||
<button
|
||||
class="am-nv-pill"
|
||||
classList={{ "am-nv-pill-active": versions() === count }}
|
||||
onClick={() => setVersions(count)}
|
||||
onClick={click("version_count", "configure_worktree_dialog", () => setVersions(count), {
|
||||
count,
|
||||
})}
|
||||
type="button"
|
||||
>
|
||||
{count}
|
||||
@@ -595,7 +614,13 @@ export const NewWorktreeDialog: Component<{ onClose: () => void; defaultBaseBran
|
||||
placement="top"
|
||||
contentClass="am-tooltip-wrap"
|
||||
>
|
||||
<button class="am-nv-pill am-nv-pill-compare" onClick={() => setCompareMode(true)} type="button">
|
||||
<button
|
||||
class="am-nv-pill am-nv-pill-compare"
|
||||
onClick={click("compare_models", "configure_worktree_dialog", () => setCompareMode(true), {
|
||||
action: "open",
|
||||
})}
|
||||
type="button"
|
||||
>
|
||||
<Icon name="layers" size="small" />
|
||||
<span class="am-nv-pill-compare-label">{t("agentManager.dialog.compareModels")}</span>
|
||||
</button>
|
||||
@@ -622,6 +647,7 @@ export const NewWorktreeDialog: Component<{ onClose: () => void; defaultBaseBran
|
||||
<button
|
||||
class="am-nv-pill-back"
|
||||
onClick={() => {
|
||||
track("compare_models", { action: "close" })
|
||||
setCompareMode(false)
|
||||
setModelAllocations(new Map())
|
||||
}}
|
||||
@@ -670,7 +696,21 @@ export const NewWorktreeDialog: Component<{ onClose: () => void; defaultBaseBran
|
||||
</div>
|
||||
{/* Submit button — fixed footer, always visible */}
|
||||
<div class="am-nv-dialog-footer">
|
||||
<Button variant="primary" size="large" class="am-nv-submit" onClick={handleSubmit} disabled={!canSubmit()}>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="large"
|
||||
class="am-nv-submit"
|
||||
onClick={click("create_worktree", "configure_worktree_dialog", handleSubmit, () => ({
|
||||
mode: mode(),
|
||||
versionCount: total(),
|
||||
advanced: showAdvanced(),
|
||||
customBranch: showAdvanced() && !!branchName().trim(),
|
||||
customBase: showAdvanced() && !!baseBranch(),
|
||||
hasPrompt: !!prompt().trim(),
|
||||
hasAttachments: imageAttach.images().length > 0,
|
||||
}))}
|
||||
disabled={!canSubmit()}
|
||||
>
|
||||
<Show
|
||||
when={!starting()}
|
||||
fallback={
|
||||
@@ -714,7 +754,7 @@ export const NewWorktreeDialog: Component<{ onClose: () => void; defaultBaseBran
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="small"
|
||||
onClick={handlePRSubmit}
|
||||
onClick={click("import_pull_request", "configure_worktree_dialog", handlePRSubmit)}
|
||||
disabled={!prUrl().trim() || isPending()}
|
||||
>
|
||||
<Show when={prPending()} fallback={t("agentManager.import.open")}>
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { TelemetryEventName } from "../../src/services/telemetry/types"
|
||||
import type { TelemetryRequest } from "../src/types/messages/webview-messages"
|
||||
|
||||
interface Target {
|
||||
postMessage(message: TelemetryRequest): void
|
||||
}
|
||||
|
||||
type Value = string | number | boolean | undefined
|
||||
type Properties = Record<string, Value>
|
||||
type Input = Properties | (() => Properties)
|
||||
|
||||
export function capture(target: Target, button: string, surface: string, properties: Properties = {}) {
|
||||
target.postMessage({
|
||||
type: "telemetry",
|
||||
event: TelemetryEventName.AGENT_MANAGER_BUTTON_CLICKED,
|
||||
properties: {
|
||||
...properties,
|
||||
source: "agent-manager",
|
||||
button,
|
||||
surface,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function clicked(target: Target, button: string, surface: string, action: () => void, properties: Input = {}) {
|
||||
return () => {
|
||||
capture(target, button, surface, typeof properties === "function" ? properties() : properties)
|
||||
action()
|
||||
}
|
||||
}
|
||||
|
||||
function used<T>(target: Target, button: string, surface: string, action: (value: T) => void) {
|
||||
return (value: T) => {
|
||||
capture(target, button, surface)
|
||||
action(value)
|
||||
}
|
||||
}
|
||||
|
||||
export function tracker(target: Target) {
|
||||
return {
|
||||
track: (button: string, surface: string, properties?: Properties) => capture(target, button, surface, properties),
|
||||
click: (button: string, surface: string, action: () => void, properties?: Input) =>
|
||||
clicked(target, button, surface, action, properties),
|
||||
use: <T>(button: string, surface: string, action: (value: T) => void) => used(target, button, surface, action),
|
||||
}
|
||||
}
|
||||
@@ -72,6 +72,7 @@ interface FullScreenDiffViewProps {
|
||||
onCommentsChange: (comments: ReviewComment[]) => void
|
||||
composer?: ReviewComposer
|
||||
onSendAll?: () => void
|
||||
onSendClick?: () => void
|
||||
diffStyle: DiffStyle
|
||||
onDiffStyleChange: (style: DiffStyle) => void
|
||||
markdownRender?: boolean
|
||||
@@ -443,6 +444,11 @@ export const FullScreenDiffView: Component<FullScreenDiffViewProps> = (props) =>
|
||||
props.onSendAll?.()
|
||||
}
|
||||
|
||||
const sendAllClick = () => {
|
||||
props.onSendClick?.()
|
||||
sendAllToChat()
|
||||
}
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key !== "Enter") return
|
||||
if (!(e.metaKey || e.ctrlKey)) return
|
||||
@@ -573,7 +579,7 @@ export const FullScreenDiffView: Component<FullScreenDiffViewProps> = (props) =>
|
||||
keybind={sendAllKeybind()}
|
||||
placement="bottom"
|
||||
>
|
||||
<Button variant="primary" size="small" onClick={sendAllToChat}>
|
||||
<Button variant="primary" size="small" onClick={sendAllClick}>
|
||||
{t("agentManager.review.sendAllToChatWithCount", { count: comments().length })}
|
||||
</Button>
|
||||
</TooltipKeybind>
|
||||
|
||||
@@ -70,7 +70,7 @@ function providerFields(provider: ProviderId | undefined): Array<{ key: string;
|
||||
if (provider === "openai-compatible") {
|
||||
return [
|
||||
{ key: "baseUrl", label: "Base URL", placeholder: "https://api.example.com/v1" },
|
||||
{ key: "apiKey", label: "API Key", placeholder: "sk-..." },
|
||||
{ key: "apiKey", label: "API Key (optional)", placeholder: "sk-..." },
|
||||
]
|
||||
}
|
||||
if (provider === "gemini") return [{ key: "apiKey", label: "API Key", placeholder: "AI..." }]
|
||||
|
||||
@@ -10,6 +10,7 @@ import { Button } from "@kilocode/kilo-ui/button"
|
||||
import { useSession } from "../../context/session"
|
||||
import { useLanguage } from "../../context/language"
|
||||
import { useVSCode } from "../../context/vscode"
|
||||
import { tracksElapsed } from "./working-indicator-utils"
|
||||
|
||||
export const WorkingIndicator: Component = () => {
|
||||
const session = useSession()
|
||||
@@ -23,7 +24,7 @@ export const WorkingIndicator: Component = () => {
|
||||
const since = session.busySince()
|
||||
const status = session.status()
|
||||
|
||||
if (status === "idle" || !since) {
|
||||
if (!tracksElapsed(status, session.submitting(), since)) {
|
||||
setElapsed(0)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import type { SessionStatus } from "../../types/messages"
|
||||
|
||||
export function tracksElapsed(status: SessionStatus, submitting: boolean, since: number | undefined): since is number {
|
||||
return since !== undefined && (status !== "idle" || submitting)
|
||||
}
|
||||
@@ -59,7 +59,7 @@ const PROVIDER_FIELDS: Record<EmbeddingProvider, ProviderFieldDef[]> = {
|
||||
ollama: [{ key: "baseUrl", label: "Base URL", placeholder: "http://localhost:11434" }],
|
||||
"openai-compatible": [
|
||||
{ key: "baseUrl", label: "Base URL", placeholder: "https://api.example.com/v1" },
|
||||
{ key: "apiKey", label: "API Key", placeholder: "sk-...", sensitive: true },
|
||||
{ key: "apiKey", label: "API Key (optional)", placeholder: "sk-...", sensitive: true },
|
||||
],
|
||||
gemini: [{ key: "apiKey", label: "API Key", placeholder: "AI...", sensitive: true }],
|
||||
mistral: [{ key: "apiKey", label: "API Key", placeholder: "...", sensitive: true }],
|
||||
|
||||
@@ -649,7 +649,7 @@ export function variants(model: Provider.Model): Record<string, Record<string, a
|
||||
id.includes("deepseek-reasoner") ||
|
||||
id.includes("deepseek-r1") ||
|
||||
id.includes("deepseek-v3") ||
|
||||
id.includes("minimax") ||
|
||||
// id.includes("minimax") || // kilocode_change
|
||||
// id.includes("glm") || // kilocode_change
|
||||
// id.includes("kimi") || // kilocode_change
|
||||
// TODO: Remove this after models.dev data is fixed to use "kimi-k2.5" instead of "k2p5"
|
||||
@@ -679,7 +679,7 @@ export function variants(model: Provider.Model): Record<string, Record<string, a
|
||||
case "@kilocode/kilo-gateway": // kilocode_change
|
||||
case "@openrouter/ai-sdk-provider":
|
||||
// kilocode_change start
|
||||
if (id.includes("glm") || id.includes("kimi") || id.includes("qwen")) {
|
||||
if (id.includes("glm") || id.includes("kimi") || id.includes("qwen") || id.includes("minimax")) {
|
||||
return {
|
||||
instant: { reasoning: { enabled: false } },
|
||||
thinking: { reasoning: { enabled: true } },
|
||||
@@ -855,6 +855,14 @@ export function variants(model: Provider.Model): Record<string, Record<string, a
|
||||
// https://v5.ai-sdk.dev/providers/ai-sdk-providers/anthropic
|
||||
case "@ai-sdk/google-vertex/anthropic":
|
||||
// https://v5.ai-sdk.dev/providers/ai-sdk-providers/google-vertex#anthropic-provider
|
||||
// kilocode_change start - MiniMax M-series toggles thinking on/off rather than exposing effort levels
|
||||
if (id.includes("minimax")) {
|
||||
return {
|
||||
instant: { thinking: { type: "disabled" } },
|
||||
thinking: { thinking: { type: "adaptive" } },
|
||||
}
|
||||
}
|
||||
// kilocode_change end
|
||||
if (adaptiveEfforts) {
|
||||
let efforts = [...adaptiveEfforts]
|
||||
if (model.providerID === "github-copilot") {
|
||||
|
||||
@@ -2410,20 +2410,42 @@ describe("ProviderTransform.variants", () => {
|
||||
expect(result).toEqual({})
|
||||
})
|
||||
|
||||
test("minimax returns empty object", () => {
|
||||
// kilocode_change start: minimax
|
||||
test("minimax direct anthropic provider returns instant/thinking toggle", () => {
|
||||
const model = createMockModel({
|
||||
id: "minimax/minimax-model",
|
||||
id: "minimax/MiniMax-M3",
|
||||
providerID: "minimax",
|
||||
api: {
|
||||
id: "minimax-model",
|
||||
url: "https://api.minimax.com",
|
||||
npm: "@ai-sdk/openai-compatible",
|
||||
id: "MiniMax-M3",
|
||||
url: "https://api.minimax.io/anthropic/v1",
|
||||
npm: "@ai-sdk/anthropic",
|
||||
},
|
||||
})
|
||||
const result = ProviderTransform.variants(model)
|
||||
expect(result).toEqual({})
|
||||
expect(result).toEqual({
|
||||
instant: { thinking: { type: "disabled" } },
|
||||
thinking: { thinking: { type: "adaptive" } },
|
||||
})
|
||||
})
|
||||
|
||||
test("minimax via kilo gateway returns instant/thinking toggle", () => {
|
||||
const model = createMockModel({
|
||||
id: "kilo/minimax/minimax-m3",
|
||||
providerID: "kilo",
|
||||
api: {
|
||||
id: "minimax/minimax-m3",
|
||||
url: "https://gateway.kilo.ai",
|
||||
npm: "@kilocode/kilo-gateway",
|
||||
},
|
||||
})
|
||||
const result = ProviderTransform.variants(model)
|
||||
expect(result).toEqual({
|
||||
instant: { reasoning: { enabled: false } },
|
||||
thinking: { reasoning: { enabled: true } },
|
||||
})
|
||||
})
|
||||
// kilocode_change end
|
||||
|
||||
test("glm returns empty object", () => {
|
||||
const model = createMockModel({
|
||||
id: "glm/glm-4",
|
||||
|
||||
@@ -34,6 +34,7 @@ bun run merge.ts --version v1.1.50 --base-branch catrielmuller/kilo-opencode-v1.
|
||||
| `merge.ts` | Main orchestration script for upstream merges |
|
||||
| `list-versions.ts` | List available upstream versions |
|
||||
| `analyze.ts` | Analyze changes without merging |
|
||||
| `opencode-changesets.ts` | Generate Kilo changesets from upstream opencode release notes |
|
||||
| `fix-kilocode-markers.ts` | Rebuild `kilocode_change` markers for one file against the last merged upstream |
|
||||
| `reset-to-upstream.ts` | Reset one file to the transformed last merged upstream version |
|
||||
| `find-reset-candidates.ts` | Bulk-find files that have drifted insignificantly from upstream and (optionally) reset them |
|
||||
@@ -60,6 +61,16 @@ bun run merge.ts --version v1.1.50 --base-branch catrielmuller/kilo-opencode-v1.
|
||||
| `codemods/transform-imports.ts` | Transform import statements using ts-morph |
|
||||
| `codemods/transform-strings.ts` | Transform string literals |
|
||||
|
||||
## Release Notes Changesets
|
||||
|
||||
After merging upstream opencode releases, use `opencode-changesets.ts` to turn the upstream GitHub release notes into Kilo changesets:
|
||||
|
||||
```bash
|
||||
bun script/upstream/opencode-changesets.ts --from 1.17.0 --to 1.17.7
|
||||
```
|
||||
|
||||
The script fetches releases from `anomalyco/opencode`, selects published releases in the semver range `(from, to]`, and writes one `.changeset/opencode-vX-Y-Z-to-vX-Y-Z.md` file for the whole range. It requires the target release to exist, merges notes from every release into shared `##` sections and `###` categories, then folds those headings into each bullet (for example, `Core Bugfixes: ...`) so Changesets can embed the notes cleanly in package changelogs. It generates a patch changeset for the fixed release group, `@kilocode/cli` and `kilo-code`. Generated notes omit contributor thank-you blocks and the upstream `Desktop` and `SDK` sections by default because Kilo does not ship the opencode desktop app and SDK release notes are not user-facing for Kilo.
|
||||
|
||||
## Merge Process
|
||||
|
||||
The merge automation follows this process, applying **all transformations BEFORE the merge** to minimize conflicts:
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
|
||||
import { changeset, select, type Release } from "./opencode-changesets"
|
||||
|
||||
const releases: Release[] = [
|
||||
{ tag_name: "v1.2.3", body: "Patch release" },
|
||||
{ tag_name: "v1.2.2", body: "\r\n## Core\r\n\r\n- Fix issue\r\n" },
|
||||
{ tag_name: "1.2.1", body: "Old tag without prefix" },
|
||||
{ tag_name: "v1.2.0", body: "Base release" },
|
||||
{ tag_name: "v1.2.4", body: "Draft release", draft: true },
|
||||
{ tag_name: "v1.2.5", body: "Prerelease", prerelease: true },
|
||||
]
|
||||
|
||||
describe("opencode changesets", () => {
|
||||
test("selects releases in semver range with normalized tags", () => {
|
||||
expect(select(releases, "1.2.0", "v1.2.3")).toEqual([
|
||||
{ tag_name: "v1.2.1", body: "Old tag without prefix" },
|
||||
{ tag_name: "v1.2.2", body: "\r\n## Core\r\n\r\n- Fix issue\r\n" },
|
||||
{ tag_name: "v1.2.3", body: "Patch release" },
|
||||
])
|
||||
})
|
||||
|
||||
test("excludes prereleases", () => {
|
||||
expect(() => select(releases, "1.2.3", "1.2.5")).toThrow("Target opencode release does not exist")
|
||||
})
|
||||
|
||||
test("requires the target release to exist", () => {
|
||||
expect(() => select(releases, "1.2.0", "99.9.9")).toThrow("Target opencode release does not exist")
|
||||
})
|
||||
|
||||
test("requires the starting release to exist", () => {
|
||||
expect(() => select(releases, "1.1.9", "1.2.3")).toThrow("Starting opencode release does not exist")
|
||||
})
|
||||
|
||||
test("formats changeset markdown", () => {
|
||||
expect(
|
||||
changeset([{ tag_name: "v1.2.2", body: "\r\n## Core\r\n\r\n- Fix issue\r\n" }], "1.2.1", "1.2.2"),
|
||||
).toBe(`---
|
||||
"@kilocode/cli": patch
|
||||
"kilo-code": patch
|
||||
---
|
||||
|
||||
Changes from opencode v1.2.1 to v1.2.2 upstream:
|
||||
|
||||
- Core: Fix issue
|
||||
`)
|
||||
})
|
||||
|
||||
test("filters ignored sections and contributor thanks", () => {
|
||||
expect(
|
||||
changeset([
|
||||
{
|
||||
tag_name: "v1.2.2",
|
||||
body: `## Core
|
||||
|
||||
- Keep this
|
||||
|
||||
## Desktop
|
||||
|
||||
- Drop this
|
||||
|
||||
## SDK
|
||||
|
||||
- Drop sdk
|
||||
|
||||
**Thank you to 1 community contributor:**
|
||||
|
||||
- @user:
|
||||
- Helped
|
||||
`,
|
||||
},
|
||||
], "1.2.1", "1.2.2"),
|
||||
).toBe(`---
|
||||
"@kilocode/cli": patch
|
||||
"kilo-code": patch
|
||||
---
|
||||
|
||||
Changes from opencode v1.2.1 to v1.2.2 upstream:
|
||||
|
||||
- Core: Keep this
|
||||
`)
|
||||
})
|
||||
|
||||
test("bundles release notes into shared sections", () => {
|
||||
expect(
|
||||
changeset([
|
||||
{
|
||||
tag_name: "v1.2.1",
|
||||
body: `## Core
|
||||
|
||||
### Bugfixes
|
||||
|
||||
- Fix first
|
||||
|
||||
## TUI
|
||||
|
||||
### Improvements
|
||||
|
||||
- Improve first
|
||||
`,
|
||||
},
|
||||
{
|
||||
tag_name: "v1.2.2",
|
||||
body: `## Core
|
||||
|
||||
### Bugfixes
|
||||
|
||||
- Fix second
|
||||
|
||||
### Improvements
|
||||
|
||||
- Improve core
|
||||
|
||||
## TUI
|
||||
|
||||
### Improvements
|
||||
|
||||
- Improve second
|
||||
`,
|
||||
},
|
||||
], "1.2.0", "1.2.2"),
|
||||
).toBe(`---
|
||||
"@kilocode/cli": patch
|
||||
"kilo-code": patch
|
||||
---
|
||||
|
||||
Changes from opencode v1.2.0 to v1.2.2 upstream:
|
||||
|
||||
- Core Bugfixes: Fix first
|
||||
- Core Bugfixes: Fix second
|
||||
- Core Improvements: Improve core
|
||||
- TUI Improvements: Improve first
|
||||
- TUI Improvements: Improve second
|
||||
`)
|
||||
})
|
||||
|
||||
test("preserves multiline markdown blocks", () => {
|
||||
expect(
|
||||
changeset([
|
||||
{
|
||||
tag_name: "v1.2.2",
|
||||
body: `## Core
|
||||
|
||||
### Improvements
|
||||
|
||||
- Parent item
|
||||
- Nested item
|
||||
|
||||
Continuation paragraph
|
||||
- Second item
|
||||
`,
|
||||
},
|
||||
], "1.2.1", "1.2.2"),
|
||||
).toBe(`---
|
||||
"@kilocode/cli": patch
|
||||
"kilo-code": patch
|
||||
---
|
||||
|
||||
Changes from opencode v1.2.1 to v1.2.2 upstream:
|
||||
|
||||
- Core Improvements: Parent item
|
||||
- Nested item
|
||||
|
||||
Continuation paragraph
|
||||
- Core Improvements: Second item
|
||||
`)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,276 @@
|
||||
#!/usr/bin/env bun
|
||||
|
||||
import path from "path"
|
||||
import semver from "semver"
|
||||
import { parseArgs } from "util"
|
||||
|
||||
type Bump = "major" | "minor" | "patch"
|
||||
|
||||
export type Release = {
|
||||
tag_name: string
|
||||
name?: string | null
|
||||
body?: string | null
|
||||
draft?: boolean
|
||||
prerelease?: boolean
|
||||
}
|
||||
|
||||
type Opts = {
|
||||
from: string
|
||||
to: string
|
||||
root: string
|
||||
}
|
||||
|
||||
type Group = Map<string, Map<string, string[]>>
|
||||
|
||||
const repo = "anomalyco/opencode"
|
||||
const pkgs = ["@kilocode/cli", "kilo-code"]
|
||||
const bump: Bump = "patch"
|
||||
const drop = ["Desktop", "SDK"]
|
||||
|
||||
const usage = `
|
||||
Usage: bun script/upstream/opencode-changesets.ts --from <version> --to <version>
|
||||
|
||||
Creates one changeset for upstream opencode releases in the semver range (from, to].
|
||||
|
||||
Options:
|
||||
--from <version> Starting opencode version, exclusive
|
||||
--to <version> Ending opencode version, inclusive
|
||||
-h, --help Show this help message
|
||||
|
||||
Example:
|
||||
bun script/upstream/opencode-changesets.ts --from v1.16.0 --to v1.17.7
|
||||
`
|
||||
|
||||
function clean(input: string) {
|
||||
const raw = input.trim().replace(/^v/, "")
|
||||
const version = semver.valid(raw)
|
||||
if (!version) throw new Error(`Invalid semver version: ${input}`)
|
||||
return version
|
||||
}
|
||||
|
||||
function tag(input: string) {
|
||||
return `v${clean(input)}`
|
||||
}
|
||||
|
||||
function slug(from: string, to: string) {
|
||||
const base = tag(from).replace(/[^a-zA-Z0-9]+/g, "-").replace(/^-|-$/g, "").toLowerCase()
|
||||
const head = tag(to).replace(/[^a-zA-Z0-9]+/g, "-").replace(/^-|-$/g, "").toLowerCase()
|
||||
return `opencode-${base}-to-${head}.md`
|
||||
}
|
||||
|
||||
function header(input: string[], bump: Bump) {
|
||||
return input.map((item) => `"${item}": ${bump}`).join("\n")
|
||||
}
|
||||
|
||||
function body(release: Release) {
|
||||
const text = release.body?.replace(/\r\n?/g, "\n").trim()
|
||||
if (text) return text
|
||||
|
||||
const name = release.name?.trim()
|
||||
if (name && name !== release.tag_name) return name
|
||||
|
||||
return `Integrate upstream opencode ${release.tag_name}.`
|
||||
}
|
||||
|
||||
function filter(input: string, sections: string[]) {
|
||||
const dropped = new Set(sections.map((item) => item.trim().toLowerCase()).filter(Boolean))
|
||||
const lines = input.replace(/\r\n?/g, "\n").split("\n")
|
||||
const out: string[] = []
|
||||
let skip = false
|
||||
let thanks = false
|
||||
|
||||
for (const line of lines) {
|
||||
const match = line.match(/^##\s+(.+?)\s*$/)
|
||||
if (match) {
|
||||
skip = dropped.has(match[1].trim().toLowerCase())
|
||||
thanks = false
|
||||
}
|
||||
|
||||
if (line.match(/^\*\*Thank you to \d+ community contributors?:\*\*\s*$/)) {
|
||||
thanks = true
|
||||
continue
|
||||
}
|
||||
|
||||
if (thanks) {
|
||||
if (!line.startsWith("-") && !line.startsWith(" -") && line.trim() !== "") thanks = false
|
||||
if (thanks) continue
|
||||
}
|
||||
|
||||
if (!skip) out.push(line)
|
||||
}
|
||||
|
||||
return out.join("\n").replace(/\n{3,}/g, "\n\n").trim()
|
||||
}
|
||||
|
||||
function add(groups: Group, section: string, category: string, lines: string[]) {
|
||||
const text = lines.join("\n").trim()
|
||||
if (!text) return
|
||||
if (!groups.has(section)) groups.set(section, new Map())
|
||||
const group = groups.get(section)!
|
||||
if (!group.has(category)) group.set(category, [])
|
||||
group.get(category)!.push(text)
|
||||
}
|
||||
|
||||
function collect(releases: Release[]) {
|
||||
const groups: Group = new Map()
|
||||
|
||||
for (const release of releases) {
|
||||
const text = filter(body(release), drop)
|
||||
let section = "Core"
|
||||
let category = ""
|
||||
const block: string[] = []
|
||||
|
||||
const flush = () => {
|
||||
add(groups, section, category, block.splice(0))
|
||||
}
|
||||
|
||||
for (const line of text.split("\n")) {
|
||||
const heading = line.match(/^##\s+(.+?)\s*$/)
|
||||
if (heading) {
|
||||
flush()
|
||||
section = heading[1].trim()
|
||||
category = ""
|
||||
if (!groups.has(section)) groups.set(section, new Map())
|
||||
continue
|
||||
}
|
||||
|
||||
const sub = line.match(/^###\s+(.+?)\s*$/)
|
||||
if (sub) {
|
||||
flush()
|
||||
category = sub[1].trim()
|
||||
if (!groups.has(section)) groups.set(section, new Map())
|
||||
if (!groups.get(section)!.has(category)) groups.get(section)!.set(category, [])
|
||||
continue
|
||||
}
|
||||
|
||||
if (!line.trim() && block.length === 0) continue
|
||||
if (line.match(/^[-*]\s+/) && block.length > 0) flush()
|
||||
block.push(line)
|
||||
}
|
||||
|
||||
flush()
|
||||
}
|
||||
|
||||
return groups
|
||||
}
|
||||
|
||||
function render(groups: Group) {
|
||||
const lines: string[] = []
|
||||
|
||||
for (const [section, cats] of groups) {
|
||||
for (const [category, items] of cats) {
|
||||
if (items.length === 0) continue
|
||||
const prefix = [section, category].filter(Boolean).join(" ")
|
||||
for (const item of items) {
|
||||
const text = item.replace(/^\s*[-*]\s+/, "").trimEnd()
|
||||
const body = text.split("\n")
|
||||
const [first = "", ...rest] = body
|
||||
lines.push(`- ${prefix}: ${first.trim()}`)
|
||||
lines.push(...rest.map((line) => (line.trim() ? ` ${line}` : "")))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return lines.join("\n")
|
||||
}
|
||||
|
||||
function isRelease(input: unknown): input is Release {
|
||||
return Boolean(input && typeof input === "object" && "tag_name" in input && typeof input.tag_name === "string")
|
||||
}
|
||||
|
||||
export function select(releases: Release[], from: string, to: string) {
|
||||
const base = clean(from)
|
||||
const head = clean(to)
|
||||
if (semver.gt(base, head) || base === head) throw new Error(`Expected from version to be lower than to version`)
|
||||
|
||||
const seen = new Set<string>()
|
||||
const published = releases
|
||||
.filter((release) => !release.draft)
|
||||
.filter((release) => !release.prerelease)
|
||||
.map((release) => ({ release, version: semver.valid(release.tag_name.replace(/^v/, "")) }))
|
||||
.filter((item): item is { release: Release; version: string } => Boolean(item.version))
|
||||
|
||||
if (!published.some((item) => item.version === base)) {
|
||||
throw new Error(`Starting opencode release does not exist or is not published: ${tag(base)}`)
|
||||
}
|
||||
|
||||
if (!published.some((item) => item.version === head)) {
|
||||
throw new Error(`Target opencode release does not exist or is not published: ${tag(head)}`)
|
||||
}
|
||||
|
||||
return published
|
||||
.filter((item) => {
|
||||
if (seen.has(item.version)) return false
|
||||
seen.add(item.version)
|
||||
return semver.gt(item.version, base) && semver.lte(item.version, head)
|
||||
})
|
||||
.sort((a, b) => semver.compare(a.version, b.version))
|
||||
.map((item) => ({ ...item.release, tag_name: tag(item.version) }))
|
||||
}
|
||||
|
||||
export function changeset(releases: Release[], from: string, to: string) {
|
||||
const text = render(collect(releases)) || "No upstream release notes were published."
|
||||
return `---\n${header(pkgs, bump)}\n---\n\nChanges from opencode ${tag(from)} to ${tag(to)} upstream:\n\n${text}\n`
|
||||
}
|
||||
|
||||
async function fetch_all() {
|
||||
const list: Release[] = []
|
||||
const auth = process.env.GH_TOKEN ?? process.env.GITHUB_TOKEN
|
||||
|
||||
for (let page = 1; ; page++) {
|
||||
const res = await fetch(`https://api.github.com/repos/${repo}/releases?per_page=100&page=${page}`, {
|
||||
headers: {
|
||||
Accept: "application/vnd.github+json",
|
||||
...(auth ? { Authorization: `Bearer ${auth}` } : {}),
|
||||
},
|
||||
})
|
||||
|
||||
if (!res.ok) throw new Error(`GitHub releases request failed for ${repo}: ${res.status} ${await res.text()}`)
|
||||
|
||||
const json: unknown = await res.json()
|
||||
if (!Array.isArray(json) || !json.every(isRelease)) throw new Error(`GitHub returned invalid release data`)
|
||||
|
||||
const batch = json
|
||||
list.push(...batch)
|
||||
if (batch.length < 100) return list
|
||||
}
|
||||
}
|
||||
|
||||
function parse_opts() {
|
||||
const parsed = parseArgs({
|
||||
args: Bun.argv.slice(2),
|
||||
options: {
|
||||
from: { type: "string" },
|
||||
to: { type: "string" },
|
||||
help: { type: "boolean", short: "h", default: false },
|
||||
},
|
||||
})
|
||||
|
||||
if (parsed.values.help) {
|
||||
process.stdout.write(usage)
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
const from = parsed.values.from
|
||||
const to = parsed.values.to
|
||||
if (!from || !to) throw new Error("Expected from and to opencode versions")
|
||||
|
||||
return { from, to, root: path.resolve(import.meta.dir, "../..") } satisfies Opts
|
||||
}
|
||||
|
||||
export async function run(opts: Opts) {
|
||||
const releases = select(await fetch_all(), opts.from, opts.to)
|
||||
if (releases.length === 0) throw new Error(`No opencode releases found in range (${opts.from}, ${opts.to}]`)
|
||||
|
||||
const dir = path.join(opts.root, ".changeset")
|
||||
const file = path.join(dir, slug(opts.from, opts.to))
|
||||
await Bun.write(file, changeset(releases, opts.from, opts.to))
|
||||
process.stdout.write(`Wrote ${path.relative(opts.root, file)}\n`)
|
||||
}
|
||||
|
||||
if (import.meta.main) {
|
||||
await (async () => run(parse_opts()))().catch((err) => {
|
||||
process.stderr.write(`${err instanceof Error ? err.message : String(err)}\n`)
|
||||
process.exit(1)
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user