diff --git a/.changeset/fix-macos-cli-spawn-path.md b/.changeset/fix-macos-cli-spawn-path.md deleted file mode 100644 index 54831b3af76..00000000000 --- a/.changeset/fix-macos-cli-spawn-path.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"kilo-code": patch ---- - -Fix Agent Manager failing to start on macOS when launched from Finder/Spotlight diff --git a/.changeset/hungry-lands-sin.md b/.changeset/hungry-lands-sin.md deleted file mode 100644 index 65a68b98412..00000000000 --- a/.changeset/hungry-lands-sin.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"kilo-code": patch ---- - -Reduce the incidence of read_file errors when using Claude models. diff --git a/.changeset/large-jars-train.md b/.changeset/large-jars-train.md deleted file mode 100644 index 0343c1a39cc..00000000000 --- a/.changeset/large-jars-train.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -"kilo-code": patch ---- - -chore: update Gemini Cli models and metadata - -- Added gemini-3-flash-preview model configuration. -- Updated maxThinkingTokens for gemini-3-pro-preview to 32,768. -- Reordered model definitions to prioritize newer versions. diff --git a/.changeset/olive-carpets-swim.md b/.changeset/olive-carpets-swim.md deleted file mode 100644 index fd22c4a4e89..00000000000 --- a/.changeset/olive-carpets-swim.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"kilo-code": patch ---- - -Fix duplicate tool use in Anthropic diff --git a/.changeset/petite-moose-wish.md b/.changeset/petite-moose-wish.md deleted file mode 100644 index c1248aad842..00000000000 --- a/.changeset/petite-moose-wish.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"kilo-code": patch ---- - -Fix duplictate tool call processing in Chutes, DeepInfra, LiteLLM and xAI providers. diff --git a/.changeset/salty-shirts-guess.md b/.changeset/salty-shirts-guess.md deleted file mode 100644 index ffd0def6b01..00000000000 --- a/.changeset/salty-shirts-guess.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"kilo-code": patch ---- - -Fix Agent Manager not showing error when CLI is misconfigured. When the CLI exits with a configuration error (e.g., missing kilocodeToken), the extension now detects this and shows an error popup with options to run `kilocode auth` or `kilocode config`. diff --git a/.changeset/session-title-generated-event.md b/.changeset/session-title-generated-event.md deleted file mode 100644 index ae53250d344..00000000000 --- a/.changeset/session-title-generated-event.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -"kilo-code": patch -"@kilocode/cli": patch ---- - -feat: add session_title_generated event emission to CLI diff --git a/.changeset/small-towns-march.md b/.changeset/small-towns-march.md deleted file mode 100644 index 8be9da81341..00000000000 --- a/.changeset/small-towns-march.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"kilo-code": patch ---- - -Add chat autocomplete telemetry diff --git a/.changeset/stale-towns-bathe.md b/.changeset/stale-towns-bathe.md deleted file mode 100644 index ac7605fa338..00000000000 --- a/.changeset/stale-towns-bathe.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"kilo-code": patch ---- - -Jetbrains - Autocomplete Telemetry diff --git a/.changeset/tricky-glasses-yell.md b/.changeset/tricky-glasses-yell.md new file mode 100644 index 00000000000..ea2077a388d --- /dev/null +++ b/.changeset/tricky-glasses-yell.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Added model selection support below prompt for Z.ai diff --git a/.changeset/vast-results-cheat.md b/.changeset/vast-results-cheat.md deleted file mode 100644 index 8397044b814..00000000000 --- a/.changeset/vast-results-cheat.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@kilocode/cli": minor ---- - -Add markdown theming support for Reasoning box content diff --git a/.changeset/yellow-plants-work.md b/.changeset/yellow-plants-work.md deleted file mode 100644 index 9bb67bcd17f..00000000000 --- a/.changeset/yellow-plants-work.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"kilo-code": patch ---- - -fix(ollama): fix model not found error and context window display diff --git a/CHANGELOG.md b/CHANGELOG.md index 556ba2db9df..cdd2e93ce9b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,37 @@ # kilo-code +## 4.140.1 + +### Patch Changes + +- [#4615](https://github.com/Kilo-Org/kilocode/pull/4615) [`6909640`](https://github.com/Kilo-Org/kilocode/commit/690964040770cd21248e1bea964c995d8620d8e8) Thanks [@marius-kilocode](https://github.com/marius-kilocode)! - Add Agent Manager terminal switching so existing session terminals are revealed when changing sessions. + +- [#4586](https://github.com/Kilo-Org/kilocode/pull/4586) [`a3988cd`](https://github.com/Kilo-Org/kilocode/commit/a3988cd201f21f7b7616d68cb2bb2c0387dd91c2) Thanks [@marius-kilocode](https://github.com/marius-kilocode)! - Fix Agent Manager failing to start on macOS when launched from Finder/Spotlight + +- [#4561](https://github.com/Kilo-Org/kilocode/pull/4561) [`3c18860`](https://github.com/Kilo-Org/kilocode/commit/3c188603cc4d8375be4abf6e1bb9217b64e9cd2b) Thanks [@jrf0110](https://github.com/jrf0110)! - Introduces AI contribution tracking so users can better understand agentic coding impact + +- [#4526](https://github.com/Kilo-Org/kilocode/pull/4526) [`10b4d6c`](https://github.com/Kilo-Org/kilocode/commit/10b4d6c02f5b310dd6e44204fa40675ca4d3d99b) Thanks [@chrarnoldus](https://github.com/chrarnoldus)! - Reduce the incidence of read_file errors when using Claude models. + +- [#4560](https://github.com/Kilo-Org/kilocode/pull/4560) [`5bdfe6b`](https://github.com/Kilo-Org/kilocode/commit/5bdfe6b9b68acf345e302791c15291c05a043204) Thanks [@crazyrabbit0](https://github.com/crazyrabbit0)! - chore: update Gemini Cli models and metadata + + - Added gemini-3-flash-preview model configuration. + - Updated maxThinkingTokens for gemini-3-pro-preview to 32,768. + - Reordered model definitions to prioritize newer versions. + +- [#4596](https://github.com/Kilo-Org/kilocode/pull/4596) [`1c33884`](https://github.com/Kilo-Org/kilocode/commit/1c3388442bd9a06dcb8aed29431c138726dbedc8) Thanks [@hank9999](https://github.com/hank9999)! - Fix duplicate tool use in Anthropic + +- [#4620](https://github.com/Kilo-Org/kilocode/pull/4620) [`ae6818b`](https://github.com/Kilo-Org/kilocode/commit/ae6818b5ea2d5504f9ee5eff9bdd963d9d82c51e) Thanks [@chrarnoldus](https://github.com/chrarnoldus)! - Fix duplictate tool call processing in Chutes, DeepInfra, LiteLLM and xAI providers. + +- [#4597](https://github.com/Kilo-Org/kilocode/pull/4597) [`e2bb5c1`](https://github.com/Kilo-Org/kilocode/commit/e2bb5c1891b6319954b46fcca3b35807fc1f8f90) Thanks [@marius-kilocode](https://github.com/marius-kilocode)! - Fix Agent Manager not showing error when CLI is misconfigured. When the CLI exits with a configuration error (e.g., missing kilocodeToken), the extension now detects this and shows an error popup with options to run `kilocode auth` or `kilocode config`. + +- [#4590](https://github.com/Kilo-Org/kilocode/pull/4590) [`f2cc065`](https://github.com/Kilo-Org/kilocode/commit/f2cc0657870ae77a5720a872c9cd11b8315799b7) Thanks [@kiloconnect](https://github.com/apps/kiloconnect)! - feat: add session_title_generated event emission to CLI + +- [#4523](https://github.com/Kilo-Org/kilocode/pull/4523) [`e259b04`](https://github.com/Kilo-Org/kilocode/commit/e259b04037c71a9bdd9e53c174b70a975e772833) Thanks [@markijbema](https://github.com/markijbema)! - Add chat autocomplete telemetry + +- [#4582](https://github.com/Kilo-Org/kilocode/pull/4582) [`3de2547`](https://github.com/Kilo-Org/kilocode/commit/3de254757049d08d3c0c100768acc564d6de4888) Thanks [@catrielmuller](https://github.com/catrielmuller)! - Jetbrains - Autocomplete Telemetry + +- [#4488](https://github.com/Kilo-Org/kilocode/pull/4488) [`f7c3715`](https://github.com/Kilo-Org/kilocode/commit/f7c3715b4b7fea9fcd363d12bfb9467e9f169729) Thanks [@lifesized](https://github.com/lifesized)! - fix(ollama): fix model not found error and context window display + ## 4.140.0 ### Minor Changes diff --git a/cli/CHANGELOG.md b/cli/CHANGELOG.md index 023d3228ab7..0e4d2262a39 100644 --- a/cli/CHANGELOG.md +++ b/cli/CHANGELOG.md @@ -1,5 +1,15 @@ # @kilocode/cli +## 0.18.0 + +### Minor Changes + +- [#4583](https://github.com/Kilo-Org/kilocode/pull/4583) [`845f8c1`](https://github.com/Kilo-Org/kilocode/commit/845f8c13b23496bf4aaf0792be9d52bf26645b64) Thanks [@kiloconnect](https://github.com/apps/kiloconnect)! - Add markdown theming support for Reasoning box content + +### Patch Changes + +- [#4590](https://github.com/Kilo-Org/kilocode/pull/4590) [`f2cc065`](https://github.com/Kilo-Org/kilocode/commit/f2cc0657870ae77a5720a872c9cd11b8315799b7) Thanks [@kiloconnect](https://github.com/apps/kiloconnect)! - feat: add session_title_generated event emission to CLI + ## 0.17.1 ### Patch Changes diff --git a/cli/package.dist.json b/cli/package.dist.json index 7a40a7aedfe..99518fbaaef 100644 --- a/cli/package.dist.json +++ b/cli/package.dist.json @@ -1,6 +1,6 @@ { "name": "@kilocode/cli", - "version": "0.17.1", + "version": "0.18.0", "description": "Terminal User Interface for Kilo Code", "type": "module", "main": "index.js", diff --git a/cli/package.json b/cli/package.json index 6495195c67a..236fe721a26 100644 --- a/cli/package.json +++ b/cli/package.json @@ -1,6 +1,6 @@ { "name": "@kilocode/cli", - "version": "0.17.1", + "version": "0.18.0", "description": "Terminal User Interface for Kilo Code", "type": "module", "main": "dist/index.js", diff --git a/packages/telemetry/vitest.config.ts b/packages/telemetry/vitest.config.ts index b6d6dbb880f..b19460cf1a8 100644 --- a/packages/telemetry/vitest.config.ts +++ b/packages/telemetry/vitest.config.ts @@ -1,9 +1,14 @@ import { defineConfig } from "vitest/config" +// kilocode_change start +const isCI = process.env.CI === "true" || process.env.CI === "1" || Boolean(process.env.CI) + export default defineConfig({ test: { globals: true, environment: "node", watch: false, + reporters: isCI ? ["verbose"] : ["default"], }, }) +// kilocode_change end diff --git a/packages/types/vitest.config.ts b/packages/types/vitest.config.ts index c80a35136aa..38cc6320319 100644 --- a/packages/types/vitest.config.ts +++ b/packages/types/vitest.config.ts @@ -1,8 +1,13 @@ import { defineConfig } from "vitest/config" +// kilocode_change start +const isCI = process.env.CI === "true" || process.env.CI === "1" || Boolean(process.env.CI) + export default defineConfig({ test: { globals: true, watch: false, + reporters: isCI ? ["verbose"] : ["default"], }, }) +// kilocode_change end diff --git a/src/api/providers/kilocode/chunk-schema.ts b/src/api/providers/kilocode/chunk-schema.ts new file mode 100644 index 00000000000..63c6bb1f71b --- /dev/null +++ b/src/api/providers/kilocode/chunk-schema.ts @@ -0,0 +1,29 @@ +import * as z from "zod/v4" + +export const OpenRouterChunkSchema = z.object({ + provider: z.string().optional(), +}) + +export const VercelAiGatewayChunkSchema = z.object({ + choices: z.array( + z.object({ + delta: z.object({ + provider_metadata: z + .object({ + gateway: z + .object({ + routing: z + .object({ + resolvedProvider: z.string().optional(), + }) + .optional(), + }) + .optional(), + }) + .optional(), + }), + }), + ), +}) + +export const KiloCodeChunkSchema = OpenRouterChunkSchema.and(VercelAiGatewayChunkSchema) diff --git a/src/api/providers/openrouter.ts b/src/api/providers/openrouter.ts index eb97a823479..97a7fef3ac4 100644 --- a/src/api/providers/openrouter.ts +++ b/src/api/providers/openrouter.ts @@ -49,6 +49,7 @@ import { isAnyRecognizedKiloCodeError } from "../../shared/kilocode/errorUtils" import type { ApiHandlerCreateMessageMetadata, SingleCompletionHandler } from "../index" import { handleOpenAIError } from "./utils/openai-error-handler" import { generateImageWithProvider, ImageGenerationResult } from "./utils/image-generation" +import { KiloCodeChunkSchema } from "./kilocode/chunk-schema" // Add custom interface for OpenRouter params. type OpenRouterChatCompletionParams = OpenAI.Chat.ChatCompletionCreateParams & { @@ -341,7 +342,6 @@ export class OpenRouterHandler extends BaseProvider implements SingleCompletionH let lastUsage: CompletionUsage | undefined = undefined let inferenceProvider: string | undefined // kilocode_change - const toolCallAccumulator = new Map() // Accumulator for reasoning_details: accumulate text by type-index key const reasoningDetailsAccumulator = new Map< string, @@ -364,9 +364,11 @@ export class OpenRouterHandler extends BaseProvider implements SingleCompletionH } // kilocode_change start - if ("provider" in chunk && typeof chunk.provider === "string") { - inferenceProvider = chunk.provider - } + const kiloCodeChunk = KiloCodeChunkSchema.safeParse(chunk).data + inferenceProvider = + kiloCodeChunk?.choices?.[0]?.delta?.provider_metadata?.gateway?.routing?.resolvedProvider ?? + kiloCodeChunk?.provider ?? + inferenceProvider // kilocode_change end verifyFinishReason(chunk.choices[0]) // kilocode_change diff --git a/src/core/kilocode/agent-manager/AgentManagerProvider.ts b/src/core/kilocode/agent-manager/AgentManagerProvider.ts index 08fb5cde03f..5a2d4ee9c38 100644 --- a/src/core/kilocode/agent-manager/AgentManagerProvider.ts +++ b/src/core/kilocode/agent-manager/AgentManagerProvider.ts @@ -5,6 +5,7 @@ import { t } from "i18next" import { AgentRegistry } from "./AgentRegistry" import { renameMapKey } from "./mapUtils" import { + buildParallelModeWorktreePath, parseParallelModeBranch, parseParallelModeWorktreePath, isParallelModeCompletionMessage, @@ -38,6 +39,7 @@ import type { ClineProvider } from "../../webview/ClineProvider" import { extractSessionConfigs, MAX_VERSION_COUNT } from "./multiVersionUtils" import { SessionManager } from "../../../shared/kilocode/cli-sessions/core/SessionManager" import { WorkspaceGitService } from "./WorkspaceGitService" +import { SessionTerminalManager } from "./SessionTerminalManager" /** * AgentManagerProvider @@ -54,6 +56,7 @@ export class AgentManagerProvider implements vscode.Disposable { private remoteSessionService: RemoteSessionService private processHandler: CliProcessHandler private eventProcessor: KilocodeEventProcessor + private terminalManager: SessionTerminalManager private sessionMessages: Map = new Map() // Track first api_req_started per session to filter user-input echoes private firstApiReqStarted: Map = new Map() @@ -72,6 +75,7 @@ export class AgentManagerProvider implements vscode.Disposable { ) { this.registry = new AgentRegistry() this.remoteSessionService = new RemoteSessionService({ outputChannel }) + this.terminalManager = new SessionTerminalManager(this.registry, this.outputChannel) // Initialize currentGitUrl from workspace void this.initializeCurrentGitUrl() @@ -288,6 +292,9 @@ export class AgentManagerProvider implements vscode.Disposable { case "agentManager.refreshSessionMessages": void this.refreshSessionMessages(message.sessionId as string) break + case "agentManager.showTerminal": + this.terminalManager.showTerminal(message.sessionId as string) + break case "agentManager.sessionShare": SessionManager.init() ?.shareSession(message.sessionId as string) @@ -669,17 +676,42 @@ export class AgentManagerProvider implements vscode.Disposable { } /** - * Handle welcome event from CLI - extracts worktree branch for parallel mode sessions + * Handle welcome event from CLI - extracts worktree branch and path for parallel mode sessions */ private handleWelcomeEvent(sessionId: string, event: WelcomeStreamEvent): void { + let updated = false + const session = this.registry.getSession(sessionId) + const existingWorktreePath = session?.parallelMode?.worktreePath + if (event.worktreeBranch) { this.outputChannel.appendLine( `[AgentManager] Session ${sessionId} worktree branch: ${event.worktreeBranch}`, ) if (this.registry.updateParallelModeInfo(sessionId, { branch: event.worktreeBranch })) { - this.postStateToWebview() + updated = true } } + + if (event.worktreePath) { + this.outputChannel.appendLine(`[AgentManager] Session ${sessionId} worktree path: ${event.worktreePath}`) + if (this.registry.updateParallelModeInfo(sessionId, { worktreePath: event.worktreePath })) { + updated = true + } + } + + if (!event.worktreePath && event.worktreeBranch && !existingWorktreePath) { + const derivedWorktreePath = buildParallelModeWorktreePath(event.worktreeBranch) + this.outputChannel.appendLine( + `[AgentManager] Session ${sessionId} derived worktree path: ${derivedWorktreePath}`, + ) + if (this.registry.updateParallelModeInfo(sessionId, { worktreePath: derivedWorktreePath })) { + updated = true + } + } + + if (updated) { + this.postStateToWebview() + } } private handleKilocodeEvent(sessionId: string, event: KilocodeStreamEvent): void { @@ -699,6 +731,8 @@ export class AgentManagerProvider implements vscode.Disposable { if (!sessionId) return + this.terminalManager.showExistingTerminal(sessionId) + // Check if we have cached messages to send immediately const cachedMessages = this.sessionMessages.get(sessionId) if (cachedMessages) { @@ -1165,6 +1199,7 @@ export class AgentManagerProvider implements vscode.Disposable { public dispose(): void { this.processHandler.dispose() + this.terminalManager.dispose() this.sessionMessages.clear() this.firstApiReqStarted.clear() diff --git a/src/core/kilocode/agent-manager/AgentRegistry.ts b/src/core/kilocode/agent-manager/AgentRegistry.ts index 83568a947b9..b8c450834c6 100644 --- a/src/core/kilocode/agent-manager/AgentRegistry.ts +++ b/src/core/kilocode/agent-manager/AgentRegistry.ts @@ -152,13 +152,16 @@ export class AgentRegistry { info: Partial>, ): AgentSession | undefined { const session = this.sessions.get(id) - if (!session || !session.parallelMode?.enabled) { + if (!session) { return undefined } + const currentParallelMode = session.parallelMode ?? { enabled: true } + session.parallelMode = { - ...session.parallelMode, + ...currentParallelMode, ...info, + enabled: true, } return session @@ -214,19 +217,26 @@ export class AgentRegistry { * Used when upgrading a provisional session to a real session ID. */ public renameSession(oldId: string, newId: string): boolean { - const session = this.sessions.get(oldId) - if (!session) { - return false + if (oldId === newId) { + return this.sessions.has(oldId) } - // Update the session's internal ID - session.sessionId = newId - - // Move in the map - this.sessions.delete(oldId) - this.sessions.set(newId, session) + const oldSession = this.sessions.get(oldId) + if (!oldSession) { + return false + } - // Update selectedId if it was pointing to the old ID + const targetSession = this.sessions.get(newId) + if (targetSession) { + // Prefer keeping the target session object (e.g. resuming an existing session), + // but merge in any provisional logs so early streaming isn't lost. + targetSession.logs = [...oldSession.logs, ...targetSession.logs] + this.sessions.delete(oldId) + } else { + this.sessions.delete(oldId) + oldSession.sessionId = newId + this.sessions.set(newId, oldSession) + } if (this._selectedId === oldId) { this._selectedId = newId } diff --git a/src/core/kilocode/agent-manager/CliOutputParser.ts b/src/core/kilocode/agent-manager/CliOutputParser.ts index 425c5b4cdaf..d5ea2b1b186 100644 --- a/src/core/kilocode/agent-manager/CliOutputParser.ts +++ b/src/core/kilocode/agent-manager/CliOutputParser.ts @@ -81,6 +81,7 @@ export interface SessionTitleGeneratedStreamEvent { export interface WelcomeStreamEvent { streamEventType: "welcome" worktreeBranch?: string + worktreePath?: string timestamp: number /** Configuration error instructions from CLI (indicates misconfigured CLI) */ instructions?: string[] @@ -153,8 +154,20 @@ export function parseCliChunk(chunk: string, buffer: string = ""): ParseResult { continue } - // Not JSON - strip VT characters before treating as plain text output event + // Fallback: handle concatenated JSON objects on a single line const cleanLine = stripVTControlCharacters(trimmedLine) + const extracted = extractJsonObjects(cleanLine) + if (extracted.length > 0) { + for (const obj of extracted) { + const extractedEvent = toStreamEvent(obj) + if (extractedEvent !== null) { + events.push(extractedEvent) + } + } + continue + } + + // Not JSON - treat as plain text output event if (cleanLine) { events.push(createOutputEvent(cleanLine)) } @@ -202,6 +215,13 @@ export class CliOutputParser { // Not JSON - strip VT characters before treating as plain text const cleanLine = stripVTControlCharacters(trimmedBuffer) + const extracted = extractJsonObjects(cleanLine) + if (extracted.length > 0) { + const events = extracted + .map((obj) => toStreamEvent(obj)) + .filter((extractedEvent): extractedEvent is StreamEvent => extractedEvent !== null) + return { events, remainingBuffer: "" } + } if (cleanLine) { return { events: [createOutputEvent(cleanLine)], remainingBuffer: "" } } @@ -243,14 +263,16 @@ function toStreamEvent(parsed: Record): StreamEvent | null { } } - // Detect welcome event from CLI (format: { type: "welcome", metadata: { welcomeOptions: { worktreeBranch: "...", instructions: [...] } }, ... }) + // Detect welcome event from CLI (format: { type: "welcome", metadata: { welcomeOptions: { worktreeBranch: "...", workspace: "...", instructions: [...] } }, ... }) if (parsed.type === "welcome") { const metadata = parsed.metadata as Record | undefined const welcomeOptions = metadata?.welcomeOptions as Record | undefined + const worktreePath = (welcomeOptions?.workspace || welcomeOptions?.worktreePath) as string | undefined const instructions = welcomeOptions?.instructions as string[] | undefined return { streamEventType: "welcome", worktreeBranch: welcomeOptions?.worktreeBranch as string | undefined, + worktreePath, timestamp: (parsed.timestamp as number) || Date.now(), instructions: Array.isArray(instructions) && instructions.length > 0 ? instructions : undefined, } @@ -271,6 +293,59 @@ function toStreamEvent(parsed: Record): StreamEvent | null { } } +function extractJsonObjects(line: string): Record[] { + const objects: Record[] = [] + let depth = 0 + let startIndex = -1 + let inString = false + let isEscaped = false + + for (let i = 0; i < line.length; i++) { + const ch = line[i] + + if (isEscaped) { + isEscaped = false + continue + } + + if (inString) { + if (ch === "\\") { + isEscaped = true + } else if (ch === '"') { + inString = false + } + continue + } + + if (ch === '"') { + inString = true + continue + } + + if (ch === "{") { + if (depth === 0) { + startIndex = i + } + depth += 1 + continue + } + + if (ch === "}") { + depth -= 1 + if (depth === 0 && startIndex !== -1) { + const candidate = line.slice(startIndex, i + 1) + const parsed = tryParseJson(candidate) + if (parsed) { + objects.push(parsed) + } + startIndex = -1 + } + } + } + + return objects +} + function createOutputEvent(content: string): OutputStreamEvent { return { streamEventType: "output", diff --git a/src/core/kilocode/agent-manager/CliProcessHandler.ts b/src/core/kilocode/agent-manager/CliProcessHandler.ts index 3259fd45a55..3d5cc979a47 100644 --- a/src/core/kilocode/agent-manager/CliProcessHandler.ts +++ b/src/core/kilocode/agent-manager/CliProcessHandler.ts @@ -10,6 +10,7 @@ import { } from "./CliOutputParser" import { AgentRegistry } from "./AgentRegistry" import { buildCliArgs } from "./CliArgsBuilder" +import { buildParallelModeWorktreePath } from "./parallelModeParser" import type { ClineMessage, ProviderSettings } from "@roo-code/types" import { extractApiReqFailedMessage, extractPayloadMessage } from "./askErrorParser" import { buildProviderEnvOverrides } from "./providerEnvMapper" @@ -40,6 +41,8 @@ interface PendingProcessInfo { desiredSessionId?: string desiredLabel?: string worktreeBranch?: string // Captured from welcome event before session_created + worktreePath?: string // Captured from welcome event before session_created + provisionalSessionId?: string // Used to show streaming content before session_created sawApiReqStarted?: boolean // Track if api_req_started arrived before session_created gitUrl?: string stderrBuffer: string[] // Capture stderr for error detection @@ -47,7 +50,6 @@ interface PendingProcessInfo { timeoutId?: NodeJS.Timeout // Timer for auto-failing stuck pending sessions hadShellPath?: boolean // Track if shell PATH was used (for telemetry) cliPath?: string // CLI path for error telemetry - provisionalSessionId?: string // Temporary session ID created when api_req_started arrives (before session_created) configurationError?: string // Captured from welcome event instructions (indicates misconfigured CLI) } @@ -70,9 +72,9 @@ export interface CliProcessHandlerCallbacks { ) => void onChatMessages: (sessionId: string, messages: ClineMessage[]) => void onSessionCreated: (sawApiReqStarted: boolean) => void + onSessionRenamed?: (oldId: string, newId: string) => void onPaymentRequiredPrompt?: (payload: KilocodePayload) => void onSessionCompleted?: (sessionId: string, exitCode: number | null) => void // Called when process exits successfully - onSessionRenamed?: (oldId: string, newId: string) => void // Called when provisional session is upgraded to real session ID } export class CliProcessHandler { @@ -400,10 +402,34 @@ export class CliProcessHandler { // Capture worktree branch and configuration errors from welcome event (arrives before session_created) if (event.streamEventType === "welcome") { const welcomeEvent = event as WelcomeStreamEvent + const derivedWorktreePath = + !welcomeEvent.worktreePath && welcomeEvent.worktreeBranch + ? buildParallelModeWorktreePath(welcomeEvent.worktreeBranch) + : undefined + const resolvedWorktreePath = welcomeEvent.worktreePath ?? derivedWorktreePath + if (welcomeEvent.worktreeBranch) { this.pendingProcess.worktreeBranch = welcomeEvent.worktreeBranch this.debugLog(`Captured worktree branch from welcome: ${welcomeEvent.worktreeBranch}`) } + if (resolvedWorktreePath) { + this.pendingProcess.worktreePath = resolvedWorktreePath + const logMessage = derivedWorktreePath + ? `Derived worktree path from branch: ${resolvedWorktreePath}` + : `Captured worktree path from welcome: ${resolvedWorktreePath}` + this.callbacks.onLog(logMessage) + this.debugLog(logMessage) + } + if ( + this.pendingProcess.parallelMode && + this.pendingProcess.provisionalSessionId && + (welcomeEvent.worktreeBranch || resolvedWorktreePath) + ) { + this.registry.updateParallelModeInfo(this.pendingProcess.provisionalSessionId, { + branch: welcomeEvent.worktreeBranch, + worktreePath: resolvedWorktreePath, + }) + } const configError = this.extractConfigErrorFromWelcome(welcomeEvent) if (configError) { this.pendingProcess.configurationError = configError @@ -411,7 +437,7 @@ export class CliProcessHandler { } return } - // Handle kilocode events during pending state + if (event.streamEventType === "kilocode") { const payload = (event as KilocodeStreamEvent).payload @@ -425,28 +451,28 @@ export class CliProcessHandler { return } - // Track api_req_started for KilocodeEventProcessor + // Track api_req_started that arrives before session_created + // This is needed so KilocodeEventProcessor knows the user echo has already happened if (payload?.say === "api_req_started") { this.pendingProcess.sawApiReqStarted = true this.debugLog(`Captured api_req_started before session_created`) } - // Create provisional session on first content event (text, api_req_started, etc.) - // This ensures we don't lose the user's prompt echo or other early events + // Create provisional session on first content event (prompt echo, api_req_started, streaming text, etc.) if (!this.pendingProcess.provisionalSessionId) { this.createProvisionalSession(proc) } // Forward the event to the provisional session - if (this.pendingProcess?.provisionalSessionId) { + if (this.pendingProcess.provisionalSessionId) { onCliEvent(this.pendingProcess.provisionalSessionId, event) this.callbacks.onStateChanged() } return } - // If we have a provisional session, forward non-kilocode events to it - if (this.pendingProcess?.provisionalSessionId) { + // If we have a provisional session, forward non-kilocode events to it as well + if (this.pendingProcess.provisionalSessionId) { onCliEvent(this.pendingProcess.provisionalSessionId, event) this.callbacks.onStateChanged() return @@ -476,7 +502,8 @@ export class CliProcessHandler { const provisionalId = `provisional-${Date.now()}` this.pendingProcess.provisionalSessionId = provisionalId - const { prompt, startTime, parallelMode, desiredLabel, gitUrl, parser } = this.pendingProcess + const { prompt, startTime, parallelMode, desiredLabel, gitUrl, parser, worktreeBranch, worktreePath } = + this.pendingProcess this.registry.createSession(provisionalId, prompt, startTime, { parallelMode, @@ -484,6 +511,13 @@ export class CliProcessHandler { gitUrl, }) + if (parallelMode && (worktreeBranch || worktreePath)) { + this.registry.updateParallelModeInfo(provisionalId, { + branch: worktreeBranch, + worktreePath, + }) + } + this.activeSessions.set(provisionalId, { process: proc, parser }) if (proc.pid) { @@ -503,6 +537,7 @@ export class CliProcessHandler { provisionalSessionId: string, realSessionId: string, worktreeBranch: string | undefined, + worktreePath: string | undefined, parallelMode: boolean | undefined, ): void { this.debugLog(`Upgrading provisional session ${provisionalSessionId} -> ${realSessionId}`) @@ -517,8 +552,11 @@ export class CliProcessHandler { this.callbacks.onSessionRenamed?.(provisionalSessionId, realSessionId) - if (worktreeBranch && parallelMode) { - this.registry.updateParallelModeInfo(realSessionId, { branch: worktreeBranch }) + if (parallelMode && (worktreeBranch || worktreePath)) { + this.registry.updateParallelModeInfo(realSessionId, { + branch: worktreeBranch, + worktreePath, + }) } this.pendingProcess = null @@ -577,6 +615,7 @@ export class CliProcessHandler { parser, parallelMode, worktreeBranch, + worktreePath, desiredSessionId, desiredLabel, sawApiReqStarted, @@ -587,13 +626,24 @@ export class CliProcessHandler { // Use desired sessionId when provided (resuming) to keep UI continuity const sessionId = desiredSessionId ?? event.sessionId - // Handle provisional session upgrade if one exists - if (provisionalSessionId) { - this.upgradeProvisionalSession(provisionalSessionId, sessionId, worktreeBranch, parallelMode) + // If we created a provisional session, upgrade it instead of creating a second session entry. + // In some edge cases, fall back to the active session mapped to this process. + const provisionalIdFromProcess = this.findSessionIdForProcess(proc) + const effectiveProvisionalSessionId = + provisionalSessionId ?? + (provisionalIdFromProcess?.startsWith("provisional-") ? provisionalIdFromProcess : undefined) + + if (effectiveProvisionalSessionId && effectiveProvisionalSessionId !== sessionId) { + this.upgradeProvisionalSession( + effectiveProvisionalSessionId, + sessionId, + worktreeBranch, + worktreePath, + parallelMode, + ) return } - // Normal path: no provisional session, create the session now const existing = this.registry.getSession(sessionId) let session: ReturnType @@ -622,6 +672,19 @@ export class CliProcessHandler { this.debugLog(`Applied worktree branch: ${worktreeBranch}`) } + const resolvedWorktreePath = + worktreePath || (parallelMode && worktreeBranch ? buildParallelModeWorktreePath(worktreeBranch) : undefined) + + if (resolvedWorktreePath && parallelMode) { + this.registry.updateParallelModeInfo(session.sessionId, { worktreePath: resolvedWorktreePath }) + const logMessage = + worktreePath && worktreePath === resolvedWorktreePath + ? `Applied worktree path: ${resolvedWorktreePath}` + : `Applied derived worktree path: ${resolvedWorktreePath}` + this.callbacks.onLog(logMessage) + this.debugLog(logMessage) + } + // Clear pending session state this.registry.clearPendingSession() this.pendingProcess = null @@ -766,7 +829,7 @@ export class CliProcessHandler { this.callbacks.onPendingSessionChanged(null) this.pendingProcess = null - // Capture spawn error telemetry with context for debugging + // Capture spawn error telemetry with context for debugging. const { platform, shell } = getPlatformDiagnostics() const cliPathExtension = cliPath ? path.extname(cliPath).slice(1).toLowerCase() || undefined : undefined captureAgentManagerLoginIssue({ diff --git a/src/core/kilocode/agent-manager/SessionTerminalManager.ts b/src/core/kilocode/agent-manager/SessionTerminalManager.ts new file mode 100644 index 00000000000..3206b9f64ec --- /dev/null +++ b/src/core/kilocode/agent-manager/SessionTerminalManager.ts @@ -0,0 +1,175 @@ +import * as vscode from "vscode" +import type { AgentRegistry } from "./AgentRegistry" +import { buildParallelModeWorktreePath } from "./parallelModeParser" + +/** + * Manages VS Code terminals for agent sessions. + * Each session can have an associated terminal that opens in the session's worktree directory, + * or the main workspace folder for non-worktree sessions. + */ +export class SessionTerminalManager { + private sessionTerminals = new Map() + private disposables: vscode.Disposable[] = [] + + constructor( + private registry: AgentRegistry, + private outputChannel: vscode.OutputChannel, + ) { + // Clean up map when terminals are closed by user + this.disposables.push( + vscode.window.onDidCloseTerminal((terminal) => { + for (const [sessionId, entry] of this.sessionTerminals) { + if (entry.terminal === terminal) { + this.sessionTerminals.delete(sessionId) + this.outputChannel.appendLine( + `[AgentManager] Removed terminal mapping for session ${sessionId} (terminal closed)`, + ) + break + } + } + }), + ) + } + + /** + * Get the current workspace folder path. + */ + private getWorkspacePath(): string | undefined { + return vscode.workspace.workspaceFolders?.[0]?.uri.fsPath + } + + /** + * Show (or create) a terminal for the given session. + * For worktree sessions, the terminal's cwd is set to the worktree path. + * For local sessions, the terminal's cwd is set to the main workspace folder. + */ + showTerminal(sessionId: string): void { + const session = this.registry.getSession(sessionId) + if (!session) { + this.outputChannel.appendLine(`[AgentManager] showTerminal: session not found (${sessionId})`) + vscode.window.showWarningMessage("Session not found") + return + } + + const isParallelSession = session.parallelMode?.enabled ?? false + const branchName = session.parallelMode?.branch + let worktreePath = session.parallelMode?.worktreePath + if (isParallelSession && !worktreePath && branchName) { + worktreePath = buildParallelModeWorktreePath(branchName) + this.registry.updateParallelModeInfo(sessionId, { worktreePath }) + this.outputChannel.appendLine( + `[AgentManager] showTerminal: derived worktree path from branch ${branchName}: ${worktreePath}`, + ) + } + const workspacePath = this.getWorkspacePath() + this.outputChannel.appendLine( + `[AgentManager] showTerminal: session=${sessionId} parallel=${isParallelSession} worktreePath=${worktreePath ?? "undefined"} workspacePath=${workspacePath ?? "undefined"}`, + ) + + if (isParallelSession && !worktreePath) { + this.outputChannel.appendLine( + `[AgentManager] showTerminal: worktree path missing for parallel session ${sessionId}`, + ) + vscode.window.showWarningMessage("Worktree path not available yet") + return + } + + const terminalCwd = worktreePath || workspacePath + + if (!terminalCwd) { + this.outputChannel.appendLine(`[AgentManager] showTerminal: no cwd resolved for session ${sessionId}`) + vscode.window.showWarningMessage("No workspace folder open") + return + } + + let entry = this.sessionTerminals.get(sessionId) + + // Check if terminal still exists (user might have closed it) + if (entry && entry.terminal.exitStatus !== undefined) { + this.sessionTerminals.delete(sessionId) + entry = undefined + this.outputChannel.appendLine( + `[AgentManager] showTerminal: previous terminal exited for session ${sessionId}`, + ) + } + + if (entry && entry.cwd !== terminalCwd) { + entry.terminal.dispose() + this.sessionTerminals.delete(sessionId) + entry = undefined + this.outputChannel.appendLine( + `[AgentManager] showTerminal: terminal cwd changed for session ${sessionId}, recreating (${terminalCwd})`, + ) + } + + if (!entry) { + // For worktree sessions, use branch name; for local sessions, use session label + const terminalName = worktreePath + ? `Agent: ${session.parallelMode?.branch || session.label}` + : `Agent: ${session.label} (local)` + const terminal = vscode.window.createTerminal({ + cwd: terminalCwd, + name: terminalName, + iconPath: new vscode.ThemeIcon("terminal"), + }) + entry = { terminal, cwd: terminalCwd } + this.sessionTerminals.set(sessionId, entry) + this.outputChannel.appendLine( + `[AgentManager] showTerminal: created terminal for session ${sessionId} (cwd=${terminalCwd})`, + ) + } + + entry.terminal.show() + } + + /** + * Close the terminal associated with a session. + */ + closeTerminal(sessionId: string): void { + const entry = this.sessionTerminals.get(sessionId) + if (entry) { + entry.terminal.dispose() + this.sessionTerminals.delete(sessionId) + this.outputChannel.appendLine(`[AgentManager] closeTerminal: disposed terminal for ${sessionId}`) + } + } + + /** + * Show the terminal for a session only if it already exists. + */ + showExistingTerminal(sessionId: string): boolean { + const entry = this.sessionTerminals.get(sessionId) + if (!entry) { + return false + } + + if (entry.terminal.exitStatus !== undefined) { + this.sessionTerminals.delete(sessionId) + this.outputChannel.appendLine( + `[AgentManager] showExistingTerminal: terminal exited for session ${sessionId}, clearing mapping`, + ) + return false + } + + entry.terminal.show() + this.outputChannel.appendLine(`[AgentManager] showExistingTerminal: revealed terminal for session ${sessionId}`) + return true + } + + /** + * Check if a session has an active terminal. + */ + hasTerminal(sessionId: string): boolean { + const entry = this.sessionTerminals.get(sessionId) + return entry !== undefined && entry.terminal.exitStatus === undefined + } + + /** + * Dispose of all resources. + */ + dispose(): void { + this.disposables.forEach((d) => d.dispose()) + // Optionally close all agent terminals on extension deactivate + // For now, we leave them open so users can continue using them + } +} diff --git a/src/core/kilocode/agent-manager/__tests__/AgentManagerProvider.ipc.spec.ts b/src/core/kilocode/agent-manager/__tests__/AgentManagerProvider.ipc.spec.ts index 63f71273770..cbedc512037 100644 --- a/src/core/kilocode/agent-manager/__tests__/AgentManagerProvider.ipc.spec.ts +++ b/src/core/kilocode/agent-manager/__tests__/AgentManagerProvider.ipc.spec.ts @@ -9,6 +9,8 @@ vi.mock("vscode", () => { const window = { showErrorMessage: vi.fn(), showWarningMessage: vi.fn(), + onDidCloseTerminal: vi.fn(() => ({ dispose: vi.fn() })), + createTerminal: vi.fn(() => ({ show: vi.fn(), dispose: vi.fn() })), } const Uri = { joinPath: vi.fn(), @@ -22,7 +24,8 @@ vi.mock("vscode", () => { Production: 2, Test: 3, } - return { window, Uri, workspace, ExtensionMode } + const ThemeIcon = vi.fn() + return { window, Uri, workspace, ExtensionMode, ThemeIcon } }) describe("AgentManagerProvider IPC paths", () => { diff --git a/src/core/kilocode/agent-manager/__tests__/AgentManagerProvider.spec.ts b/src/core/kilocode/agent-manager/__tests__/AgentManagerProvider.spec.ts index c98de1e47ae..acd03f0e240 100644 --- a/src/core/kilocode/agent-manager/__tests__/AgentManagerProvider.spec.ts +++ b/src/core/kilocode/agent-manager/__tests__/AgentManagerProvider.spec.ts @@ -33,7 +33,13 @@ describe("AgentManagerProvider CLI spawning", () => { let provider: InstanceType const mockContext = { extensionUri: {}, extensionPath: "", extensionMode: 1 /* Development */ } as any const mockOutputChannel = { appendLine: vi.fn() } as any - let mockWindow: { showErrorMessage: Mock; showWarningMessage: Mock; ViewColumn: { One: number } } + let mockWindow: { + showErrorMessage: Mock + showWarningMessage: Mock + ViewColumn: { One: number } + onDidCloseTerminal: Mock + createTerminal: Mock + } beforeEach(async () => { vi.resetModules() @@ -47,6 +53,8 @@ describe("AgentManagerProvider CLI spawning", () => { showErrorMessage: vi.fn().mockResolvedValue(undefined), showWarningMessage: vi.fn().mockResolvedValue(undefined), ViewColumn: { One: 1 }, + onDidCloseTerminal: vi.fn().mockReturnValue({ dispose: vi.fn() }), + createTerminal: vi.fn().mockReturnValue({ show: vi.fn(), sendText: vi.fn(), dispose: vi.fn() }), } vi.doMock("vscode", () => ({ @@ -131,11 +139,14 @@ describe("AgentManagerProvider CLI spawning", () => { showErrorMessage: vi.fn().mockResolvedValue(undefined), showWarningMessage: vi.fn().mockResolvedValue(undefined), ViewColumn: { One: 1 }, + onDidCloseTerminal: vi.fn().mockReturnValue({ dispose: vi.fn() }), + createTerminal: vi.fn().mockReturnValue({ show: vi.fn(), sendText: vi.fn(), dispose: vi.fn() }), }, env: { openExternal: vi.fn() }, Uri: { parse: vi.fn(), joinPath: vi.fn() }, ViewColumn: { One: 1 }, ExtensionMode: { Development: 1, Production: 2, Test: 3 }, + ThemeIcon: vi.fn(), })) vi.doMock("../../../../utils/fs", () => ({ @@ -235,6 +246,19 @@ describe("AgentManagerProvider CLI spawning", () => { expect(sessions[0].sessionId).toBe("cli-session-123") }) + it("shows existing terminal when selecting a session", () => { + const sessionId = "session-terminal" + const registry = (provider as any).registry + registry.createSession(sessionId, "prompt") + ;(provider as any).sessionMessages.set(sessionId, []) + + const showExistingTerminal = vi.spyOn((provider as any).terminalManager, "showExistingTerminal") + + ;(provider as any).selectSession(sessionId) + + expect(showExistingTerminal).toHaveBeenCalledWith(sessionId) + }) + it("adds metadata text for tool requests and skips non chat events", async () => { const registry = (provider as any).registry const sessionId = "test-session-meta" @@ -596,7 +620,12 @@ describe("AgentManagerProvider gitUrl filtering", () => { vi.resetModules() const mockWorkspaceFolder = { uri: { fsPath: "/tmp/workspace" } } - const mockWindow = { showErrorMessage: () => undefined, ViewColumn: { One: 1 } } + const mockWindow = { + showErrorMessage: () => undefined, + ViewColumn: { One: 1 }, + onDidCloseTerminal: vi.fn().mockReturnValue({ dispose: vi.fn() }), + createTerminal: vi.fn().mockReturnValue({ show: vi.fn(), sendText: vi.fn(), dispose: vi.fn() }), + } const mockProvider = { getState: vi.fn().mockResolvedValue({ apiConfiguration: { apiProvider: "kilocode" } }), } @@ -823,7 +852,12 @@ describe("AgentManagerProvider telemetry", () => { vi.clearAllMocks() const mockWorkspaceFolder = { uri: { fsPath: "/tmp/workspace" } } - const mockWindow = { showErrorMessage: () => undefined, ViewColumn: { One: 1 } } + const mockWindow = { + showErrorMessage: () => undefined, + ViewColumn: { One: 1 }, + onDidCloseTerminal: vi.fn().mockReturnValue({ dispose: vi.fn() }), + createTerminal: vi.fn().mockReturnValue({ show: vi.fn(), sendText: vi.fn(), dispose: vi.fn() }), + } const mockProvider = { getState: vi.fn().mockResolvedValue({ apiConfiguration: { apiProvider: "kilocode" } }), } diff --git a/src/core/kilocode/agent-manager/__tests__/AgentRegistry.spec.ts b/src/core/kilocode/agent-manager/__tests__/AgentRegistry.spec.ts index fb8e097ad1c..82089592f8c 100644 --- a/src/core/kilocode/agent-manager/__tests__/AgentRegistry.spec.ts +++ b/src/core/kilocode/agent-manager/__tests__/AgentRegistry.spec.ts @@ -289,10 +289,13 @@ describe("AgentRegistry", () => { expect(result).toBeUndefined() }) - it("returns undefined when updating session without parallelMode enabled", () => { + it("enables parallelMode when updating a session without parallelMode", () => { const session = registry.createSession("session-1", "no parallel mode") const result = registry.updateParallelModeInfo(session.sessionId, { branch: "test" }) - expect(result).toBeUndefined() + expect(result?.parallelMode).toEqual({ + enabled: true, + branch: "test", + }) }) it("preserves parallelMode info in getState", () => { diff --git a/src/core/kilocode/agent-manager/__tests__/CliOutputParser.spec.ts b/src/core/kilocode/agent-manager/__tests__/CliOutputParser.spec.ts index fcbd3cc41f9..a8afa53961c 100644 --- a/src/core/kilocode/agent-manager/__tests__/CliOutputParser.spec.ts +++ b/src/core/kilocode/agent-manager/__tests__/CliOutputParser.spec.ts @@ -104,14 +104,15 @@ describe("parseCliChunk", () => { expect(event.timestamp).toBeLessThanOrEqual(after) }) - it("should parse welcome event with worktree branch", () => { + it("should parse welcome event with worktree branch and path", () => { const result = parseCliChunk( - '{"type":"welcome","metadata":{"welcomeOptions":{"worktreeBranch":"feature/test-branch"}},"timestamp":1234567890}\n', + '{"type":"welcome","metadata":{"welcomeOptions":{"worktreeBranch":"feature/test-branch","workspace":"/tmp/worktree-path"}},"timestamp":1234567890}\n', ) expect(result.events).toHaveLength(1) expect(result.events[0]).toEqual({ streamEventType: "welcome", worktreeBranch: "feature/test-branch", + worktreePath: "/tmp/worktree-path", timestamp: 1234567890, instructions: undefined, }) @@ -123,6 +124,7 @@ describe("parseCliChunk", () => { expect(result.events[0]).toEqual({ streamEventType: "welcome", worktreeBranch: undefined, + worktreePath: undefined, timestamp: 1234567890, instructions: undefined, }) @@ -214,6 +216,23 @@ describe("parseCliChunk", () => { expect(result.events[0]).toMatchObject({ streamEventType: "kilocode", payload: { timestamp: 123 } }) }) + it("should parse concatenated JSON objects on a single line", () => { + const input = + '{"type":"welcome","metadata":{"welcomeOptions":{"worktreeBranch":"feature/test","workspace":"/tmp/worktree"}},"timestamp":1}' + + '{"event":"session_created","sessionId":"sess-1","timestamp":2}\n' + const result = parseCliChunk(input) + expect(result.events).toHaveLength(2) + expect(result.events[0]).toMatchObject({ + streamEventType: "welcome", + worktreeBranch: "feature/test", + worktreePath: "/tmp/worktree", + }) + expect(result.events[1]).toMatchObject({ + streamEventType: "session_created", + sessionId: "sess-1", + }) + }) + it("should collect non-JSON lines as output events with VT codes stripped", () => { const input = 'not json\n{"timestamp":123,"source":"cli","type":"info"}\nalso not json\n' const result = parseCliChunk(input) @@ -304,4 +323,23 @@ describe("CliOutputParser class", () => { expect(events[1]).toMatchObject({ streamEventType: "kilocode", payload: { content: "Hello World" } }) expect(events[2]).toMatchObject({ streamEventType: "status" }) }) + + it("should flush concatenated JSON objects", () => { + const parser = new CliOutputParser() + parser.parse( + '{"type":"welcome","metadata":{"welcomeOptions":{"worktreeBranch":"feature/test","workspace":"/tmp/worktree"}},"timestamp":1}' + + '{"event":"session_created","sessionId":"sess-1","timestamp":2}', + ) + const result = parser.flush() + expect(result.events).toHaveLength(2) + expect(result.events[0]).toMatchObject({ + streamEventType: "welcome", + worktreeBranch: "feature/test", + worktreePath: "/tmp/worktree", + }) + expect(result.events[1]).toMatchObject({ + streamEventType: "session_created", + sessionId: "sess-1", + }) + }) }) diff --git a/src/core/kilocode/agent-manager/__tests__/CliProcessHandler.spec.ts b/src/core/kilocode/agent-manager/__tests__/CliProcessHandler.spec.ts index 7f9dad552f4..ee078ee80d4 100644 --- a/src/core/kilocode/agent-manager/__tests__/CliProcessHandler.spec.ts +++ b/src/core/kilocode/agent-manager/__tests__/CliProcessHandler.spec.ts @@ -12,6 +12,11 @@ vi.mock("node:child_process", () => ({ spawn: vi.fn(), })) +vi.mock("../telemetry", () => ({ + captureAgentManagerLoginIssue: vi.fn(), + getPlatformDiagnostics: vi.fn(() => ({ platform: "darwin", shell: "bash" })), +})) + /** * Creates a mock ChildProcess with EventEmitter capabilities */ @@ -504,18 +509,19 @@ describe("CliProcessHandler", () => { expect(registry.getSessions()).toHaveLength(0) }) - it("captures worktree branch from welcome event and applies it on session creation", () => { + it("captures worktree branch and path from welcome event and applies them on session creation", () => { const onCliEvent = vi.fn() // Start in parallel mode handler.spawnProcess("/path/to/kilocode", "/workspace", "test prompt", { parallelMode: true }, onCliEvent) // First, emit welcome event with worktree branch (this arrives before session_created) const welcomeEvent = - '{"type":"welcome","metadata":{"welcomeOptions":{"worktreeBranch":"feature/test-branch"}}}\n' + '{"type":"welcome","metadata":{"welcomeOptions":{"worktreeBranch":"feature/test-branch","workspace":"/tmp/worktree-path"}}}\n' mockProcess.stdout.emit("data", Buffer.from(welcomeEvent)) // Verify branch was captured in pending process expect((handler as any).pendingProcess.worktreeBranch).toBe("feature/test-branch") + expect((handler as any).pendingProcess.worktreePath).toBe("/tmp/worktree-path") // Then emit session_created mockProcess.stdout.emit("data", Buffer.from('{"event":"session_created","sessionId":"session-1"}\n')) @@ -524,6 +530,24 @@ describe("CliProcessHandler", () => { const session = registry.getSession("session-1") expect(session?.parallelMode?.enabled).toBe(true) expect(session?.parallelMode?.branch).toBe("feature/test-branch") + expect(session?.parallelMode?.worktreePath).toBe("/tmp/worktree-path") + }) + + it("derives worktree path from branch when welcome event has no workspace", () => { + const onCliEvent = vi.fn() + const branch = "feature/test-branch" + handler.spawnProcess("/path/to/kilocode", "/workspace", "test prompt", { parallelMode: true }, onCliEvent) + + const welcomeEvent = `{"type":"welcome","metadata":{"welcomeOptions":{"worktreeBranch":"${branch}"}}}\n` + mockProcess.stdout.emit("data", Buffer.from(welcomeEvent)) + + const expectedPath = path.join(os.tmpdir(), `kilocode-worktree-${branch}`) + expect((handler as any).pendingProcess.worktreePath).toBe(expectedPath) + + mockProcess.stdout.emit("data", Buffer.from('{"event":"session_created","sessionId":"session-1"}\n')) + + const session = registry.getSession("session-1") + expect(session?.parallelMode?.worktreePath).toBe(expectedPath) }) it("does not apply worktree branch when not in parallel mode", () => { @@ -1167,6 +1191,30 @@ describe("CliProcessHandler", () => { }), ) }) + + it("emits spawn error telemetry when process fails during pending state", async () => { + const telemetry = await import("../telemetry") + + const onCliEvent = vi.fn() + handler.spawnProcess("/path/to/kilocode.cmd", "/workspace", "test prompt", undefined, onCliEvent) + + mockProcess.emit("error", new Error("ENOENT")) + + expect(telemetry.getPlatformDiagnostics).toHaveBeenCalled() + expect(telemetry.captureAgentManagerLoginIssue).toHaveBeenCalledWith( + expect.objectContaining({ + issueType: "cli_spawn_error", + platform: "darwin", + shell: "bash", + errorMessage: "ENOENT", + cliPath: "/path/to/kilocode.cmd", + cliPathExtension: "cmd", + }), + ) + expect(callbacks.onStartSessionFailed).toHaveBeenCalledWith( + expect.objectContaining({ type: "spawn_error", message: "ENOENT" }), + ) + }) }) describe("pending session timeout", () => { @@ -1464,6 +1512,50 @@ describe("CliProcessHandler", () => { expect(registry.getSession("real-session-123")).toBeDefined() }) + it("applies worktree info to provisional session from welcome event", () => { + const onCliEvent = vi.fn() + handler.spawnProcess("/path/to/kilocode", "/workspace", "test prompt", { parallelMode: true }, onCliEvent) + + const welcomeEvent = + '{"type":"welcome","metadata":{"welcomeOptions":{"worktreeBranch":"feature/test-branch","workspace":"/tmp/worktree-path"}}}\n' + mockProcess.stdout.emit("data", Buffer.from(welcomeEvent)) + + const kilocodeEvent = JSON.stringify({ + streamEventType: "kilocode", + payload: { type: "say", say: "user_feedback", content: "test prompt" }, + }) + mockProcess.stdout.emit("data", Buffer.from(kilocodeEvent + "\n")) + + const provisionalSession = registry.getSessions()[0] + expect(provisionalSession.sessionId).toMatch(/^provisional-/) + expect(provisionalSession.parallelMode?.branch).toBe("feature/test-branch") + expect(provisionalSession.parallelMode?.worktreePath).toBe("/tmp/worktree-path") + }) + + it("preserves worktree path when provisional session is upgraded", () => { + const onCliEvent = vi.fn() + handler.spawnProcess("/path/to/kilocode", "/workspace", "test prompt", { parallelMode: true }, onCliEvent) + + const welcomeEvent = + '{"type":"welcome","metadata":{"welcomeOptions":{"worktreeBranch":"feature/test-branch","workspace":"/tmp/worktree-path"}}}\n' + mockProcess.stdout.emit("data", Buffer.from(welcomeEvent)) + + const kilocodeEvent = JSON.stringify({ + streamEventType: "kilocode", + payload: { type: "say", say: "user_feedback", content: "test prompt" }, + }) + mockProcess.stdout.emit("data", Buffer.from(kilocodeEvent + "\n")) + + const provisionalId = registry.getSessions()[0].sessionId + expect(provisionalId).toMatch(/^provisional-/) + + mockProcess.stdout.emit("data", Buffer.from('{"event":"session_created","sessionId":"real-session-123"}\n')) + + const session = registry.getSession("real-session-123") + expect(session?.parallelMode?.branch).toBe("feature/test-branch") + expect(session?.parallelMode?.worktreePath).toBe("/tmp/worktree-path") + }) + it("calls onSessionRenamed callback when provisional session is upgraded", () => { const onCliEvent = vi.fn() handler.spawnProcess("/path/to/kilocode", "/workspace", "test prompt", undefined, onCliEvent) diff --git a/src/core/kilocode/agent-manager/__tests__/SessionTerminalManager.spec.ts b/src/core/kilocode/agent-manager/__tests__/SessionTerminalManager.spec.ts new file mode 100644 index 00000000000..5a80707cdce --- /dev/null +++ b/src/core/kilocode/agent-manager/__tests__/SessionTerminalManager.spec.ts @@ -0,0 +1,344 @@ +import { describe, it, expect, beforeEach, vi, type Mock } from "vitest" +import os from "node:os" +import path from "node:path" +import * as vscode from "vscode" +import { SessionTerminalManager } from "../SessionTerminalManager" +import { AgentRegistry } from "../AgentRegistry" + +const MOCK_WORKSPACE_PATH = "/tmp/workspace" + +// Mock vscode module +vi.mock("vscode", () => ({ + window: { + createTerminal: vi.fn(), + showWarningMessage: vi.fn(), + onDidCloseTerminal: vi.fn(() => ({ dispose: vi.fn() })), + }, + workspace: { + workspaceFolders: [{ uri: { fsPath: "/tmp/workspace" } }], + }, + ThemeIcon: vi.fn().mockImplementation((id: string) => ({ id })), +})) + +describe("SessionTerminalManager", () => { + let registry: AgentRegistry + let terminalManager: SessionTerminalManager + let mockTerminal: { + show: Mock + dispose: Mock + exitStatus: undefined | { code: number } + } + let onDidCloseTerminalCallback: ((terminal: unknown) => void) | undefined + let mockOutputChannel: { appendLine: Mock } + + beforeEach(() => { + vi.clearAllMocks() + + registry = new AgentRegistry() + + // Capture the onDidCloseTerminal callback + ;(vscode.window.onDidCloseTerminal as Mock).mockImplementation((callback) => { + onDidCloseTerminalCallback = callback + return { dispose: vi.fn() } + }) + + // Create mock terminal + mockTerminal = { + show: vi.fn(), + dispose: vi.fn(), + exitStatus: undefined, + } + ;(vscode.window.createTerminal as Mock).mockReturnValue(mockTerminal) + + mockOutputChannel = { appendLine: vi.fn() } + terminalManager = new SessionTerminalManager(registry, mockOutputChannel as unknown as vscode.OutputChannel) + }) + + describe("showTerminal", () => { + it("shows warning when session does not exist", () => { + terminalManager.showTerminal("non-existent") + + expect(vscode.window.showWarningMessage).toHaveBeenCalledWith("Session not found") + expect(vscode.window.createTerminal).not.toHaveBeenCalled() + }) + + it("creates terminal with workspace path for local session (no worktree)", () => { + // Create a session without parallelMode (local session) + registry.createSession("session-1", "test prompt") + + terminalManager.showTerminal("session-1") + + expect(vscode.window.createTerminal).toHaveBeenCalledWith({ + cwd: MOCK_WORKSPACE_PATH, + name: "Agent: test prompt (local)", + iconPath: expect.objectContaining({ id: "terminal" }), + }) + expect(mockTerminal.show).toHaveBeenCalled() + }) + + it("shows warning when parallelMode session does not have worktreePath", () => { + // Create a session with parallelMode but no worktreePath yet + registry.createSession("session-1", "test prompt", undefined, { parallelMode: true }) + + terminalManager.showTerminal("session-1") + + expect(vscode.window.showWarningMessage).toHaveBeenCalledWith("Worktree path not available yet") + expect(vscode.window.createTerminal).not.toHaveBeenCalled() + }) + + it("derives worktree path from branch when missing", () => { + const branch = "feature-branch" + registry.createSession("session-1", "test prompt", undefined, { parallelMode: true }) + registry.updateParallelModeInfo("session-1", { branch }) + + terminalManager.showTerminal("session-1") + + const expectedPath = path.join(os.tmpdir(), `kilocode-worktree-${branch}`) + expect(vscode.window.createTerminal).toHaveBeenCalledWith({ + cwd: expectedPath, + name: `Agent: ${branch}`, + iconPath: expect.objectContaining({ id: "terminal" }), + }) + expect(mockTerminal.show).toHaveBeenCalled() + }) + + it("creates terminal with worktree path when session has worktree path", () => { + // Create a session with parallelMode and worktreePath + registry.createSession("session-1", "test prompt", undefined, { parallelMode: true }) + registry.updateParallelModeInfo("session-1", { + worktreePath: "/tmp/worktree-path", + branch: "feature-branch", + }) + + terminalManager.showTerminal("session-1") + + expect(vscode.window.createTerminal).toHaveBeenCalledWith({ + cwd: "/tmp/worktree-path", + name: "Agent: feature-branch", + iconPath: expect.objectContaining({ id: "terminal" }), + }) + expect(mockTerminal.show).toHaveBeenCalled() + }) + + it("uses session label when branch name is not available for worktree session", () => { + registry.createSession("session-1", "test prompt", undefined, { parallelMode: true }) + registry.updateParallelModeInfo("session-1", { + worktreePath: "/tmp/worktree-path", + }) + + terminalManager.showTerminal("session-1") + + expect(vscode.window.createTerminal).toHaveBeenCalledWith({ + cwd: "/tmp/worktree-path", + name: "Agent: test prompt", + iconPath: expect.objectContaining({ id: "terminal" }), + }) + }) + + it("reuses existing terminal instead of creating new one", () => { + registry.createSession("session-1", "test prompt", undefined, { parallelMode: true }) + registry.updateParallelModeInfo("session-1", { + worktreePath: "/tmp/worktree-path", + branch: "feature-branch", + }) + + // First call creates terminal + terminalManager.showTerminal("session-1") + expect(vscode.window.createTerminal).toHaveBeenCalledTimes(1) + + // Second call reuses terminal + terminalManager.showTerminal("session-1") + expect(vscode.window.createTerminal).toHaveBeenCalledTimes(1) + expect(mockTerminal.show).toHaveBeenCalledTimes(2) + }) + + it("creates new terminal if previous one was closed", () => { + registry.createSession("session-1", "test prompt", undefined, { parallelMode: true }) + registry.updateParallelModeInfo("session-1", { + worktreePath: "/tmp/worktree-path", + branch: "feature-branch", + }) + + // First call creates terminal + terminalManager.showTerminal("session-1") + expect(vscode.window.createTerminal).toHaveBeenCalledTimes(1) + + // Simulate terminal being closed (exitStatus is set) + mockTerminal.exitStatus = { code: 0 } + + // Create a new mock terminal for the second call + const newMockTerminal = { + show: vi.fn(), + dispose: vi.fn(), + exitStatus: undefined, + } + ;(vscode.window.createTerminal as Mock).mockReturnValue(newMockTerminal) + + // Second call should create new terminal + terminalManager.showTerminal("session-1") + expect(vscode.window.createTerminal).toHaveBeenCalledTimes(2) + }) + }) + + describe("closeTerminal", () => { + it("disposes terminal when it exists", () => { + registry.createSession("session-1", "test prompt", undefined, { parallelMode: true }) + registry.updateParallelModeInfo("session-1", { + worktreePath: "/tmp/worktree-path", + }) + + terminalManager.showTerminal("session-1") + terminalManager.closeTerminal("session-1") + + expect(mockTerminal.dispose).toHaveBeenCalled() + }) + + it("does nothing when terminal does not exist", () => { + // Should not throw + terminalManager.closeTerminal("non-existent") + }) + + it("removes terminal from tracking after close", () => { + registry.createSession("session-1", "test prompt", undefined, { parallelMode: true }) + registry.updateParallelModeInfo("session-1", { + worktreePath: "/tmp/worktree-path", + }) + + terminalManager.showTerminal("session-1") + terminalManager.closeTerminal("session-1") + + expect(terminalManager.hasTerminal("session-1")).toBe(false) + }) + }) + + describe("hasTerminal", () => { + it("returns false when no terminal exists for session", () => { + expect(terminalManager.hasTerminal("session-1")).toBe(false) + }) + + it("returns true when terminal exists and is active", () => { + registry.createSession("session-1", "test prompt", undefined, { parallelMode: true }) + registry.updateParallelModeInfo("session-1", { + worktreePath: "/tmp/worktree-path", + }) + + terminalManager.showTerminal("session-1") + + expect(terminalManager.hasTerminal("session-1")).toBe(true) + }) + + it("returns false when terminal was closed", () => { + registry.createSession("session-1", "test prompt", undefined, { parallelMode: true }) + registry.updateParallelModeInfo("session-1", { + worktreePath: "/tmp/worktree-path", + }) + + terminalManager.showTerminal("session-1") + mockTerminal.exitStatus = { code: 0 } + + expect(terminalManager.hasTerminal("session-1")).toBe(false) + }) + }) + + describe("showExistingTerminal", () => { + it("returns false when terminal does not exist", () => { + expect(terminalManager.showExistingTerminal("session-1")).toBe(false) + expect(vscode.window.createTerminal).not.toHaveBeenCalled() + }) + + it("shows terminal when it exists and is active", () => { + registry.createSession("session-1", "test prompt", undefined, { parallelMode: true }) + registry.updateParallelModeInfo("session-1", { + worktreePath: "/tmp/worktree-path", + }) + + terminalManager.showTerminal("session-1") + const result = terminalManager.showExistingTerminal("session-1") + + expect(result).toBe(true) + expect(mockTerminal.show).toHaveBeenCalledTimes(2) + }) + + it("clears mapping when terminal has exited", () => { + registry.createSession("session-1", "test prompt", undefined, { parallelMode: true }) + registry.updateParallelModeInfo("session-1", { + worktreePath: "/tmp/worktree-path", + }) + + terminalManager.showTerminal("session-1") + mockTerminal.exitStatus = { code: 0 } + + const result = terminalManager.showExistingTerminal("session-1") + expect(result).toBe(false) + expect(terminalManager.hasTerminal("session-1")).toBe(false) + }) + }) + + describe("onDidCloseTerminal cleanup", () => { + it("removes terminal from tracking when user closes it", () => { + registry.createSession("session-1", "test prompt", undefined, { parallelMode: true }) + registry.updateParallelModeInfo("session-1", { + worktreePath: "/tmp/worktree-path", + }) + + terminalManager.showTerminal("session-1") + expect(terminalManager.hasTerminal("session-1")).toBe(true) + + // Simulate user closing the terminal + onDidCloseTerminalCallback?.(mockTerminal) + + // Terminal should be removed from tracking + // Note: hasTerminal checks exitStatus, but the map entry should be removed + // We need to verify by trying to show terminal again + ;(vscode.window.createTerminal as Mock).mockClear() + const newMockTerminal = { + show: vi.fn(), + dispose: vi.fn(), + exitStatus: undefined, + } + ;(vscode.window.createTerminal as Mock).mockReturnValue(newMockTerminal) + + terminalManager.showTerminal("session-1") + expect(vscode.window.createTerminal).toHaveBeenCalledTimes(1) + }) + + it("does not affect other sessions when one terminal is closed", () => { + // Create two sessions with terminals + registry.createSession("session-1", "test prompt 1", undefined, { parallelMode: true }) + registry.updateParallelModeInfo("session-1", { worktreePath: "/tmp/worktree-1" }) + + registry.createSession("session-2", "test prompt 2", undefined, { parallelMode: true }) + registry.updateParallelModeInfo("session-2", { worktreePath: "/tmp/worktree-2" }) + + const mockTerminal1 = { show: vi.fn(), dispose: vi.fn(), exitStatus: undefined } + const mockTerminal2 = { show: vi.fn(), dispose: vi.fn(), exitStatus: undefined } + + ;(vscode.window.createTerminal as Mock) + .mockReturnValueOnce(mockTerminal1) + .mockReturnValueOnce(mockTerminal2) + + terminalManager.showTerminal("session-1") + terminalManager.showTerminal("session-2") + + // Close only the first terminal + onDidCloseTerminalCallback?.(mockTerminal1) + + // Second terminal should still be tracked + expect(terminalManager.hasTerminal("session-2")).toBe(true) + }) + }) + + describe("dispose", () => { + it("disposes all event listeners", () => { + const disposeMock = vi.fn() + ;(vscode.window.onDidCloseTerminal as Mock).mockReturnValue({ dispose: disposeMock }) + + const manager = new SessionTerminalManager(registry, { + appendLine: vi.fn(), + } as unknown as vscode.OutputChannel) + manager.dispose() + + expect(disposeMock).toHaveBeenCalled() + }) + }) +}) diff --git a/src/core/kilocode/agent-manager/__tests__/parallelModeParser.spec.ts b/src/core/kilocode/agent-manager/__tests__/parallelModeParser.spec.ts index 6e81b5a3618..757891f3203 100644 --- a/src/core/kilocode/agent-manager/__tests__/parallelModeParser.spec.ts +++ b/src/core/kilocode/agent-manager/__tests__/parallelModeParser.spec.ts @@ -1,5 +1,6 @@ import { describe, it, expect } from "vitest" import { + buildParallelModeWorktreePath, parseParallelModeBranch, parseParallelModeWorktreePath, isParallelModeCompletionMessage, @@ -112,4 +113,12 @@ describe("parallelModeParser", () => { expect(parseParallelModeCompletionBranch("Parallel mode complete!")).toBeUndefined() }) }) + + describe("buildParallelModeWorktreePath", () => { + it("uses the OS temp directory with the kilocode worktree prefix", () => { + const branch = "feature-123" + const result = buildParallelModeWorktreePath(branch) + expect(result).toContain(`kilocode-worktree-${branch}`) + }) + }) }) diff --git a/src/core/kilocode/agent-manager/parallelModeParser.ts b/src/core/kilocode/agent-manager/parallelModeParser.ts index 9ea4fa3ebdd..acc0d73247b 100644 --- a/src/core/kilocode/agent-manager/parallelModeParser.ts +++ b/src/core/kilocode/agent-manager/parallelModeParser.ts @@ -1,3 +1,6 @@ +import os from "node:os" +import path from "node:path" + export function parseParallelModeBranch(message: string): string | undefined { const match = message.match(/(?:Creating worktree with branch|Using existing branch):\s*(.+)/i) if (match) { @@ -12,6 +15,10 @@ export function parseParallelModeWorktreePath(message: string): string | undefin } return undefined } + +export function buildParallelModeWorktreePath(branch: string): string { + return path.join(os.tmpdir(), `kilocode-worktree-${branch}`) +} export function isParallelModeCompletionMessage(message: string): boolean { return message.includes("Parallel mode complete") } diff --git a/src/core/tools/ApplyDiffTool.ts b/src/core/tools/ApplyDiffTool.ts index 7161c7c08ef..0c33708c472 100644 --- a/src/core/tools/ApplyDiffTool.ts +++ b/src/core/tools/ApplyDiffTool.ts @@ -15,6 +15,7 @@ import { EXPERIMENT_IDS, experiments } from "../../shared/experiments" import { computeDiffStats, sanitizeUnifiedDiff } from "../diff/stats" import { BaseTool, ToolCallbacks } from "./BaseTool" import type { ToolUse } from "../../shared/tools" +import { trackContribution } from "../../services/contribution-tracking/ContributionTrackingService" // kilocode_change interface ApplyDiffParams { path: string @@ -175,6 +176,19 @@ export class ApplyDiffTool extends BaseTool<"apply_diff"> { const didApprove = await askApproval("tool", completeMessage, toolProgressStatus, isWriteProtected) + // kilocode_change start + // Track contribution (fire-and-forget, never blocks user workflow) + trackContribution({ + cwd: task.cwd, + filePath: relPath, + unifiedDiff: unifiedPatch, + status: didApprove ? "accepted" : "rejected", + taskId: task.taskId, + organizationId: state?.apiConfiguration?.kilocodeOrganizationId, + kilocodeToken: state?.apiConfiguration?.kilocodeToken || "", + }) + // kilocode_change end + if (!didApprove) { return } @@ -219,6 +233,19 @@ export class ApplyDiffTool extends BaseTool<"apply_diff"> { const didApprove = await askApproval("tool", completeMessage, toolProgressStatus, isWriteProtected) + // kilocode_change start + // Track contribution (fire-and-forget, never blocks user workflow) + trackContribution({ + cwd: task.cwd, + filePath: relPath, + unifiedDiff: unifiedPatch, + status: didApprove ? "accepted" : "rejected", + taskId: task.taskId, + organizationId: state?.apiConfiguration?.kilocodeOrganizationId, + kilocodeToken: state?.apiConfiguration?.kilocodeToken || "", + }) + // kilocode_change end + if (!didApprove) { await task.diffViewProvider.revertChanges() task.processQueuedMessages() diff --git a/src/core/tools/MultiApplyDiffTool.ts b/src/core/tools/MultiApplyDiffTool.ts index 43833f00685..60a42cf0ccd 100644 --- a/src/core/tools/MultiApplyDiffTool.ts +++ b/src/core/tools/MultiApplyDiffTool.ts @@ -18,6 +18,7 @@ import { applyDiffTool as applyDiffToolClass } from "./ApplyDiffTool" import { computeDiffStats, sanitizeUnifiedDiff } from "../diff/stats" import { isNativeProtocol } from "@roo-code/types" import { resolveToolProtocol } from "../../utils/resolveToolProtocol" +import { trackContribution } from "../../services/contribution-tracking/ContributionTrackingService" // kilocode_change export interface DiffOperation { path: string @@ -638,6 +639,19 @@ ${errorDetails ? `\nTechnical details:\n${errorDetails}\n` : ""} const isWriteProtected = cline.rooProtectedController?.isWriteProtected(relPath) || false didApprove = await askApproval("tool", operationMessage, toolProgressStatus, isWriteProtected) + // kilocode_change start + // Track contribution for single file operation (fire-and-forget) + trackContribution({ + cwd: cline.cwd, + filePath: relPath, + unifiedDiff: unifiedPatch, + status: didApprove ? "accepted" : "rejected", + taskId: cline.taskId, + organizationId: state?.apiConfiguration?.kilocodeOrganizationId, + kilocodeToken: state?.apiConfiguration?.kilocodeToken || "", + }) + // kilocode_change end + if (!didApprove) { // Revert changes if diff view was shown if (!isPreventFocusDisruptionEnabled) { @@ -663,6 +677,21 @@ ${errorDetails ? `\nTechnical details:\n${errorDetails}\n` : ""} } } else { // Batch operations - already approved above + // kilocode_change start + // Track contribution for batch file operation (fire-and-forget) + const unifiedPatchRaw = formatResponse.createPrettyPatch(relPath, beforeContent!, originalContent!) + const unifiedPatch = sanitizeUnifiedDiff(unifiedPatchRaw) + trackContribution({ + cwd: cline.cwd, + filePath: relPath, + unifiedDiff: unifiedPatch, + status: "accepted", // Batch operations are already approved at this point + taskId: cline.taskId, + organizationId: state?.apiConfiguration?.kilocodeOrganizationId, + kilocodeToken: state?.apiConfiguration?.kilocodeToken || "", + }) + // kilocode_change end + if (isPreventFocusDisruptionEnabled) { // Direct file write without diff view or opening the file cline.diffViewProvider.editType = "modify" diff --git a/src/core/tools/WriteToFileTool.ts b/src/core/tools/WriteToFileTool.ts index 8e3897744a6..c7a06fc1e62 100644 --- a/src/core/tools/WriteToFileTool.ts +++ b/src/core/tools/WriteToFileTool.ts @@ -17,6 +17,7 @@ import { EXPERIMENT_IDS, experiments } from "../../shared/experiments" import { convertNewFileToUnifiedDiff, computeDiffStats, sanitizeUnifiedDiff } from "../diff/stats" import { BaseTool, ToolCallbacks } from "./BaseTool" import type { ToolUse } from "../../shared/tools" +import { trackContribution } from "../../services/contribution-tracking/ContributionTrackingService" // kilocode_change interface WriteToFileParams { path: string @@ -142,6 +143,19 @@ export class WriteToFileTool extends BaseTool<"write_to_file"> { const didApprove = await askApproval("tool", completeMessage, undefined, isWriteProtected) + // kilocode_change start + // Track contribution (fire-and-forget, never blocks user workflow) + trackContribution({ + cwd: task.cwd, + filePath: relPath, + unifiedDiff: unified, + status: didApprove ? "accepted" : "rejected", + taskId: task.taskId, + organizationId: state?.apiConfiguration?.kilocodeOrganizationId, + kilocodeToken: state?.apiConfiguration?.kilocodeToken || "", + }) + // kilocode_change end + if (!didApprove) { return } @@ -174,6 +188,19 @@ export class WriteToFileTool extends BaseTool<"write_to_file"> { const didApprove = await askApproval("tool", completeMessage, undefined, isWriteProtected) + // kilocode_change start + // Track contribution (fire-and-forget, never blocks user workflow) + trackContribution({ + cwd: task.cwd, + filePath: relPath, + unifiedDiff: unified, + status: didApprove ? "accepted" : "rejected", + taskId: task.taskId, + organizationId: state?.apiConfiguration?.kilocodeOrganizationId, + kilocodeToken: state?.apiConfiguration?.kilocodeToken || "", + }) + // kilocode_change end + if (!didApprove) { await task.diffViewProvider.revertChanges() return diff --git a/src/core/tools/kilocode/editFileTool.ts b/src/core/tools/kilocode/editFileTool.ts index 91bafc71eae..c550047347f 100644 --- a/src/core/tools/kilocode/editFileTool.ts +++ b/src/core/tools/kilocode/editFileTool.ts @@ -13,6 +13,8 @@ import { TelemetryService } from "@roo-code/telemetry" import { type ClineProviderState } from "../../webview/ClineProvider" import { ClineSayTool } from "../../../shared/ExtensionMessage" import { X_KILOCODE_ORGANIZATIONID, X_KILOCODE_TASKID, X_KILOCODE_TESTER } from "../../../shared/kilocode/headers" +import { trackContribution } from "../../../services/contribution-tracking/ContributionTrackingService" +import { sanitizeUnifiedDiff } from "../../diff/stats" const FAST_APPLY_MODEL_PRICING = { "morph-v3-fast": { @@ -179,6 +181,21 @@ export async function editFileTool( cline.rooProtectedController?.isWriteProtected(relPath) || false, ) + // Track contribution (fire-and-forget, never blocks user workflow) + const provider = cline.providerRef.deref() + const state = await provider?.getState() + const unifiedPatchRaw = formatResponse.createPrettyPatch(relPath, originalContent, newContent) + const unifiedPatch = sanitizeUnifiedDiff(unifiedPatchRaw) + trackContribution({ + cwd: cline.cwd, + filePath: relPath, + unifiedDiff: unifiedPatch, + status: approved ? "accepted" : "rejected", + taskId: cline.taskId, + organizationId: state?.apiConfiguration?.kilocodeOrganizationId, + kilocodeToken: state?.apiConfiguration?.kilocodeToken || "", + }) + if (!approved) { await cline.diffViewProvider.revertChanges() return diff --git a/src/package.json b/src/package.json index 00c42d1dbfa..7e1bc3fd595 100644 --- a/src/package.json +++ b/src/package.json @@ -3,7 +3,7 @@ "displayName": "%extension.displayName%", "description": "%extension.description%", "publisher": "kilocode", - "version": "4.140.0", + "version": "4.140.1", "icon": "assets/icons/logo-outline-black.png", "galleryBanner": { "color": "#FFFFFF", diff --git a/src/services/contribution-tracking/ContributionTrackingService.ts b/src/services/contribution-tracking/ContributionTrackingService.ts new file mode 100644 index 00000000000..9e26720e01a --- /dev/null +++ b/src/services/contribution-tracking/ContributionTrackingService.ts @@ -0,0 +1,330 @@ +// kilocode_change - new file +import crypto from "crypto" +import { getKiloUrlFromToken } from "@roo-code/types" +import { TelemetryService } from "@roo-code/telemetry" +import { fetchWithRetries } from "../../shared/http" +import { getCurrentBranch } from "../code-index/managed/git-utils" +import { getProjectId } from "../../utils/kilo-config-file" +import { getGitRepositoryInfo } from "../../utils/git" +import { + type ContributionPayload, + type LineChange, + type TokenProvisionResponse, + TokenProvisionResponse as TokenProvisionResponseSchema, + type TrackContributionParams, +} from "./contribution-tracking-types" + +/** + * Service for tracking AI contributions to the attributions worker + * + * This service handles: + * - Short-lived JWT token management with caching + * - Line-level change tracking with SHA-1 hashing + * - Unified diff parsing + * - Fire-and-forget API calls to the attributions worker + */ +export class ContributionTrackingService { + private static instance: ContributionTrackingService + private cachedToken: TokenProvisionResponse | null = null + private tokenFetchPromise: Promise | null = null + + // AI Attribution service URL + private static readonly CONTRIBUTION_SERVICE_URL = "https://ai-attribution.kiloapps.io/attributions/track" + + // Refresh token 1 minute before expiry + private static readonly TOKEN_REFRESH_BUFFER_MS = 60 * 1000 + + private constructor() {} + + /** + * Get the singleton instance + */ + static getInstance(): ContributionTrackingService { + if (!ContributionTrackingService.instance) { + ContributionTrackingService.instance = new ContributionTrackingService() + } + return ContributionTrackingService.instance + } + + /** + * Clear cached token (useful for testing and logout scenarios) + */ + clearCachedToken(): void { + this.cachedToken = null + this.tokenFetchPromise = null + } + + /** + * Check if the cached token is still valid + * Returns false if token doesn't exist or is expired/about to expire + */ + private isTokenValid(organizationId: string): boolean { + if (!this.cachedToken) { + return false + } + + // Token must be for the same organization + if (this.cachedToken.organizationId !== organizationId) { + return false + } + + // Check if token is expired or about to expire (within refresh buffer) + // Derive the numeric timestamp from the ISO 8601 string at comparison time + const now = Date.now() + const expiresAtMs = new Date(this.cachedToken.expiresAt).getTime() + const expiresWithBuffer = expiresAtMs - ContributionTrackingService.TOKEN_REFRESH_BUFFER_MS + + return now < expiresWithBuffer + } + + /** + * Fetch a new short-lived token from the Kilo backend + * @param organizationId - The organization ID to get a token for + * @param kilocodeToken - The main Kilocode authentication token + * @returns The token provision response + */ + private async fetchToken(organizationId: string, kilocodeToken: string): Promise { + try { + const url = getKiloUrlFromToken( + `https://api.kilo.ai/api/organizations/${organizationId}/user-tokens`, + kilocodeToken, + ) + + const response = await fetchWithRetries({ + url, + method: "POST", + headers: { + Authorization: `Bearer ${kilocodeToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({}), // Empty body as per spec + }) + + if (!response.ok) { + throw new Error(`Failed to fetch token: ${response.statusText}`) + } + + // Store the canonical response directly without transformation + this.cachedToken = TokenProvisionResponseSchema.parse(await response.json()) + + return this.cachedToken + } catch (error) { + console.error("[ContributionTracking] Failed to fetch token:", error) + throw error + } + } + + /** + * Get a valid token, fetching a new one if necessary + * Handles caching and concurrent requests + */ + private async getValidToken(organizationId: string, kilocodeToken: string): Promise { + // If we have a valid cached token, return it + if (this.isTokenValid(organizationId)) { + return this.cachedToken! + } + + // If a fetch is already in progress, wait for it + if (this.tokenFetchPromise) { + return this.tokenFetchPromise + } + + // Start a new fetch + this.tokenFetchPromise = this.fetchToken(organizationId, kilocodeToken) + + try { + const token = await this.tokenFetchPromise + return token + } finally { + // Clear the promise so future calls can fetch again if needed + this.tokenFetchPromise = null + } + } + + /** + * Compute SHA-1 hash of line content + * Normalizes line endings for consistent hashing + */ + private computeLineHash(lineContent: string): string { + // Remove line endings for consistent hashing across platforms + const normalized = lineContent.replace(/\r?\n$/, "") + return crypto.createHash("sha1").update(normalized, "utf8").digest("hex") + } + + /** + * Extract line changes from unified diff + * Returns arrays of added and removed lines with their hashes + * + * @param unifiedDiff - The unified diff string + * @returns Object containing arrays of added and removed line changes + */ + private extractLineChanges(unifiedDiff: string): { + linesAdded: LineChange[] + linesRemoved: LineChange[] + } { + const linesAdded: LineChange[] = [] + const linesRemoved: LineChange[] = [] + + const lines = unifiedDiff.split("\n") + let currentLine = 0 + + for (const line of lines) { + if (line.startsWith("@@")) { + // Parse hunk header to get line numbers + // Format: @@ -oldStart,oldCount +newStart,newCount @@ + const match = line.match(/@@ -(\d+),?\d* \+(\d+),?\d* @@/) + if (match) { + currentLine = parseInt(match[2], 10) // New file line number + } + } else if (line.startsWith("+") && !line.startsWith("+++")) { + // Added line (skip +++ file markers) + const content = line.substring(1) + linesAdded.push({ + line_number: currentLine++, + line_hash: this.computeLineHash(content), + }) + } else if (line.startsWith("-") && !line.startsWith("---")) { + // Removed line (skip --- file markers) + const content = line.substring(1) + linesRemoved.push({ + line_number: currentLine, + line_hash: this.computeLineHash(content), + }) + } else if (!line.startsWith("\\")) { + // Context line (unchanged) - increment line counter + // Skip lines starting with \ (e.g., "\ No newline at end of file") + currentLine++ + } + } + + return { linesAdded, linesRemoved } + } + + /** + * Track a file edit contribution + * This is the main public method that should be called when a user accepts or rejects a file edit + * + * @param params - Parameters for tracking the contribution + * + * @example + * ```typescript + * const service = ContributionTrackingService.getInstance() + * await service.trackContribution({ + * cwd: '/path/to/repo', + * filePath: 'src/file.ts', + * unifiedDiff: '...', + * status: 'accepted', + * taskId: 'task_123', + * organizationId: 'org_456', + * kilocodeToken: 'token_789' + * }) + * ``` + */ + async trackContribution(params: TrackContributionParams): Promise { + try { + // Skip tracking if telemetry is disabled (respects user's privacy preferences) + if (TelemetryService.hasInstance() && !TelemetryService.instance.isTelemetryEnabled()) { + return + } + + // Skip tracking if no organization ID + if (!params.organizationId) { + return + } + + // Get git context (branch, repository URL, and project ID) + const [branch, gitInfo] = await Promise.all([ + getCurrentBranch(params.cwd), + getGitRepositoryInfo(params.cwd), + ]) + + // Get project ID with git repository URL as fallback + const projectId = await getProjectId(params.cwd, gitInfo.repositoryUrl) + + if (!projectId) { + return + } + + // Extract line changes from the unified diff + const { linesAdded, linesRemoved } = this.extractLineChanges(params.unifiedDiff) + + // Get a valid token for the attributions service + const cachedToken = await this.getValidToken(params.organizationId, params.kilocodeToken) + + // Build the payload with snake_case field names + const payload: ContributionPayload = { + project_id: projectId || "unknown", + branch: branch || "unknown", + file_path: params.filePath, + lines_added: linesAdded, + lines_removed: linesRemoved, + status: params.status, + task_id: params.taskId, + } + + // Send to the attributions worker + // Fire-and-forget: don't block user workflow if this fails + await this.sendToAttributionsWorker(payload, cachedToken.token) + } catch (error) { + // Log error but don't throw - tracking should never block user workflow + console.error("[ContributionTracking] Failed to track contribution:", error) + } + } + + /** + * Send contribution data to the attributions worker + * @param payload - The contribution payload + * @param token - The short-lived JWT token + */ + private async sendToAttributionsWorker(payload: ContributionPayload, token: string): Promise { + try { + const response = await fetchWithRetries({ + url: ContributionTrackingService.CONTRIBUTION_SERVICE_URL, + method: "POST", + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }, + body: JSON.stringify(payload), + }) + + if (!response.ok) { + throw new Error(`Failed to track contribution: ${response.statusText}`) + } + } catch (error) { + console.error("[ContributionTracking] Failed to send to attributions worker:", error) + throw error + } + } +} + +/** + * Track a contribution (fire-and-forget) + * + * This is a convenience function that handles getting the service instance + * and catching/logging any errors. Callsites can simply fire and forget + * without needing to handle errors themselves. + * + * @param params - Parameters for tracking the contribution + * + * @example + * ```typescript + * // Simple fire-and-forget usage + * trackContribution({ + * cwd: task.cwd, + * filePath: relPath, + * unifiedDiff: unifiedPatch, + * status: didApprove ? "accepted" : "rejected", + * taskId: task.taskId, + * organizationId: state?.apiConfiguration?.kilocodeOrganizationId, + * kilocodeToken: state?.apiConfiguration?.kilocodeToken || "", + * }) + * ``` + */ +export function trackContribution(params: TrackContributionParams): void { + const service = ContributionTrackingService.getInstance() + service.trackContribution(params).catch((error: unknown) => { + // Errors are already logged in the service, this just prevents unhandled rejection + console.debug("[trackContribution] Contribution tracking failed:", error) + }) +} diff --git a/src/services/contribution-tracking/__tests__/ContributionTrackingService.spec.ts b/src/services/contribution-tracking/__tests__/ContributionTrackingService.spec.ts new file mode 100644 index 00000000000..4ebc8f1cc1b --- /dev/null +++ b/src/services/contribution-tracking/__tests__/ContributionTrackingService.spec.ts @@ -0,0 +1,349 @@ +// kilocode_change - new file +import { describe, it, expect, vi, beforeEach } from "vitest" +import { ContributionTrackingService } from "../ContributionTrackingService" +import type { TrackContributionParams } from "../contribution-tracking-types" + +// Mock dependencies +vi.mock("../../../shared/http") +vi.mock("../../code-index/managed/git-utils") +vi.mock("../../../utils/kilo-config-file") +vi.mock("../../../utils/git") + +describe("ContributionTrackingService", () => { + let service: ContributionTrackingService + + beforeEach(() => { + // Get fresh instance for each test + service = ContributionTrackingService.getInstance() + // Clear any cached token + service.clearCachedToken() + // Clear all mocks + vi.clearAllMocks() + }) + + describe("singleton pattern", () => { + it("should return the same instance", () => { + const instance1 = ContributionTrackingService.getInstance() + const instance2 = ContributionTrackingService.getInstance() + expect(instance1).toBe(instance2) + }) + }) + + describe("clearCachedToken", () => { + it("should clear the cached token", () => { + service.clearCachedToken() + // Token should be cleared - we can't directly test this but it shouldn't throw + expect(() => service.clearCachedToken()).not.toThrow() + }) + }) + + describe("line hashing", () => { + it("should compute consistent hash for same content", () => { + // Access private method via any cast for testing + const hash1 = (service as any).computeLineHash("const x = 1") + const hash2 = (service as any).computeLineHash("const x = 1") + expect(hash1).toBe(hash2) + expect(hash1).toHaveLength(40) // SHA-1 produces 40 character hex string + }) + + it("should normalize line endings", () => { + const hash1 = (service as any).computeLineHash("const x = 1\n") + const hash2 = (service as any).computeLineHash("const x = 1\r\n") + const hash3 = (service as any).computeLineHash("const x = 1") + expect(hash1).toBe(hash2) + expect(hash2).toBe(hash3) + }) + + it("should produce different hashes for different content", () => { + const hash1 = (service as any).computeLineHash("const x = 1") + const hash2 = (service as any).computeLineHash("const x = 2") + expect(hash1).not.toBe(hash2) + }) + }) + + describe("diff parsing", () => { + it("should extract added lines", () => { + const diff = `@@ -1,2 +1,3 @@ + const x = 1 ++const y = 2 + console.log(x)` + + const { linesAdded, linesRemoved } = (service as any).extractLineChanges(diff) + expect(linesAdded).toHaveLength(1) + expect(linesAdded[0].line_number).toBe(2) + expect(linesAdded[0].line_hash).toBeDefined() + expect(linesRemoved).toHaveLength(0) + }) + + it("should extract removed lines", () => { + const diff = `@@ -1,3 +1,2 @@ + const x = 1 +-const y = 2 + console.log(x)` + + const { linesAdded, linesRemoved } = (service as any).extractLineChanges(diff) + expect(linesAdded).toHaveLength(0) + expect(linesRemoved).toHaveLength(1) + expect(linesRemoved[0].line_number).toBe(2) + expect(linesRemoved[0].line_hash).toBeDefined() + }) + + it("should handle multiple hunks", () => { + const diff = `@@ -1,2 +1,3 @@ + const x = 1 ++const y = 2 + console.log(x) +@@ -10,2 +11,3 @@ + function test() { ++ return true + }` + + const { linesAdded, linesRemoved } = (service as any).extractLineChanges(diff) + expect(linesAdded).toHaveLength(2) + expect(linesAdded[0].line_number).toBe(2) + expect(linesAdded[1].line_number).toBe(12) + }) + + it("should skip file markers", () => { + const diff = `--- a/file.ts ++++ b/file.ts +@@ -1,2 +1,3 @@ + const x = 1 ++const y = 2 + console.log(x)` + + const { linesAdded, linesRemoved } = (service as any).extractLineChanges(diff) + expect(linesAdded).toHaveLength(1) + expect(linesRemoved).toHaveLength(0) + }) + + it("should handle empty diff", () => { + const diff = "" + const { linesAdded, linesRemoved } = (service as any).extractLineChanges(diff) + expect(linesAdded).toHaveLength(0) + expect(linesRemoved).toHaveLength(0) + }) + }) + + describe("token management", () => { + it("should cache token and reuse it", async () => { + const { fetchWithRetries } = await import("../../../shared/http") + const mockFetchWithRetries = vi.mocked(fetchWithRetries) + + const futureExpiry = new Date(Date.now() + 15 * 60 * 1000).toISOString() + + mockFetchWithRetries.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + token: "short-lived-token", + expiresAt: futureExpiry, + organizationId: "org-1", + }), + } as Response) + + // First call should fetch token + const token1 = await (service as any).getValidToken("org-1", "main-token") + expect(mockFetchWithRetries).toHaveBeenCalledTimes(1) + + // Second call should reuse cached token + const token2 = await (service as any).getValidToken("org-1", "main-token") + expect(mockFetchWithRetries).toHaveBeenCalledTimes(1) // Still 1, not 2 + expect(token1.token).toBe(token2.token) + }) + + it("should fetch new token for different organization", async () => { + const { fetchWithRetries } = await import("../../../shared/http") + const mockFetchWithRetries = vi.mocked(fetchWithRetries) + + const futureExpiry = new Date(Date.now() + 15 * 60 * 1000).toISOString() + + mockFetchWithRetries + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ + token: "token-org-1", + expiresAt: futureExpiry, + organizationId: "org-1", + }), + } as Response) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ + token: "token-org-2", + expiresAt: futureExpiry, + organizationId: "org-2", + }), + } as Response) + + // Fetch token for org-1 + const token1 = await (service as any).getValidToken("org-1", "main-token") + expect(token1.token).toBe("token-org-1") + + // Fetch token for org-2 (should not use cached token from org-1) + const token2 = await (service as any).getValidToken("org-2", "main-token") + expect(token2.token).toBe("token-org-2") + expect(mockFetchWithRetries).toHaveBeenCalledTimes(2) + }) + }) + + describe("trackContribution", () => { + it("should skip tracking when no organization ID", async () => { + const { fetchWithRetries } = await import("../../../shared/http") + const mockFetchWithRetries = vi.mocked(fetchWithRetries) + + const params: TrackContributionParams = { + cwd: "/test/repo", + filePath: "test.ts", + unifiedDiff: "@@ -1,1 +1,2 @@\n const x = 1\n+const y = 2", + status: "accepted", + kilocodeToken: "token", + // organizationId is missing + } + + await service.trackContribution(params) + + // Should not make any API calls + expect(mockFetchWithRetries).not.toHaveBeenCalled() + }) + + it("should skip tracking when no project ID", async () => { + const { fetchWithRetries } = await import("../../../shared/http") + const mockFetchWithRetries = vi.mocked(fetchWithRetries) + + const { getProjectId } = await import("../../../utils/kilo-config-file") + const mockGetProjectId = vi.mocked(getProjectId) + mockGetProjectId.mockResolvedValueOnce(undefined) + + const { getCurrentBranch } = await import("../../code-index/managed/git-utils") + const mockGetCurrentBranch = vi.mocked(getCurrentBranch) + mockGetCurrentBranch.mockResolvedValueOnce("main") + + const { getGitRepositoryInfo } = await import("../../../utils/git") + const mockGetGitRepositoryInfo = vi.mocked(getGitRepositoryInfo) + mockGetGitRepositoryInfo.mockResolvedValueOnce({ + repositoryUrl: "https://github.com/test/repo.git", + repositoryName: "test/repo", + defaultBranch: "main", + }) + + const params: TrackContributionParams = { + cwd: "/test/repo", + filePath: "test.ts", + unifiedDiff: "@@ -1,1 +1,2 @@\n const x = 1\n+const y = 2", + status: "accepted", + organizationId: "org-1", + kilocodeToken: "token", + } + + await service.trackContribution(params) + + // Should not make any API calls + expect(mockFetchWithRetries).not.toHaveBeenCalled() + }) + + it("should successfully track accepted contribution", async () => { + const { fetchWithRetries } = await import("../../../shared/http") + const mockFetchWithRetries = vi.mocked(fetchWithRetries) + + const { getProjectId } = await import("../../../utils/kilo-config-file") + const mockGetProjectId = vi.mocked(getProjectId) + mockGetProjectId.mockResolvedValueOnce("test-project") + + const { getCurrentBranch } = await import("../../code-index/managed/git-utils") + const mockGetCurrentBranch = vi.mocked(getCurrentBranch) + mockGetCurrentBranch.mockResolvedValueOnce("feature/test") + + const { getGitRepositoryInfo } = await import("../../../utils/git") + const mockGetGitRepositoryInfo = vi.mocked(getGitRepositoryInfo) + mockGetGitRepositoryInfo.mockResolvedValueOnce({ + repositoryUrl: "https://github.com/test/repo.git", + repositoryName: "test/repo", + defaultBranch: "main", + }) + + const futureExpiry = new Date(Date.now() + 15 * 60 * 1000).toISOString() + + // Mock token fetch + mockFetchWithRetries + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ + token: "short-lived-token", + expiresAt: futureExpiry, + organizationId: "org-1", + }), + } as Response) + // Mock contribution tracking + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ success: true }), + } as Response) + + const params: TrackContributionParams = { + cwd: "/test/repo", + filePath: "test.ts", + unifiedDiff: "@@ -1,1 +1,2 @@\n const x = 1\n+const y = 2", + status: "accepted", + taskId: "task-123", + organizationId: "org-1", + kilocodeToken: "main-token", + } + + await service.trackContribution(params) + + // Should have made 2 API calls: token fetch + contribution tracking + expect(mockFetchWithRetries).toHaveBeenCalledTimes(2) + + // Verify contribution tracking call + const trackingCall = mockFetchWithRetries.mock.calls[1][0] + expect(trackingCall.method).toBe("POST") + expect(trackingCall.headers).toMatchObject({ + Authorization: "Bearer short-lived-token", + "Content-Type": "application/json", + }) + + const payload = JSON.parse(trackingCall.body as string) + expect(payload).toMatchObject({ + project_id: "test-project", + branch: "feature/test", + file_path: "test.ts", + status: "accepted", + task_id: "task-123", + }) + expect(payload.lines_added).toHaveLength(1) + expect(payload.lines_removed).toHaveLength(0) + }) + + it("should handle errors gracefully without throwing", async () => { + const { fetchWithRetries } = await import("../../../shared/http") + const mockFetchWithRetries = vi.mocked(fetchWithRetries) + + const { getCurrentBranch } = await import("../../code-index/managed/git-utils") + const mockGetCurrentBranch = vi.mocked(getCurrentBranch) + mockGetCurrentBranch.mockResolvedValueOnce("main") + + const { getGitRepositoryInfo } = await import("../../../utils/git") + const mockGetGitRepositoryInfo = vi.mocked(getGitRepositoryInfo) + mockGetGitRepositoryInfo.mockResolvedValueOnce({}) + + const { getProjectId } = await import("../../../utils/kilo-config-file") + const mockGetProjectId = vi.mocked(getProjectId) + mockGetProjectId.mockRejectedValueOnce(new Error("Git error")) + + const params: TrackContributionParams = { + cwd: "/test/repo", + filePath: "test.ts", + unifiedDiff: "@@ -1,1 +1,2 @@\n const x = 1\n+const y = 2", + status: "accepted", + organizationId: "org-1", + kilocodeToken: "token", + } + + // Should not throw + await expect(service.trackContribution(params)).resolves.not.toThrow() + + // Should not make tracking API call + expect(mockFetchWithRetries).not.toHaveBeenCalled() + }) + }) +}) diff --git a/src/services/contribution-tracking/contribution-tracking-types.ts b/src/services/contribution-tracking/contribution-tracking-types.ts new file mode 100644 index 00000000000..5cbbb0871a8 --- /dev/null +++ b/src/services/contribution-tracking/contribution-tracking-types.ts @@ -0,0 +1,48 @@ +// kilocode_change - new file +import { z } from "zod" + +/** + * Line change information with hash + * Uses snake_case to match the API contract + */ +export interface LineChange { + line_number: number + line_hash: string +} + +/** + * Contribution payload sent to the attributions worker + * Uses snake_case to match the API contract + */ +export interface ContributionPayload { + project_id: string + branch: string + file_path: string + lines_added: LineChange[] + lines_removed: LineChange[] + status: "accepted" | "rejected" + task_id?: string +} + +export type TokenProvisionResponse = z.infer +/** + * Zod schema for validating token provisioning response + */ +export const TokenProvisionResponse = z.object({ + token: z.string(), + expiresAt: z.string(), // ISO 8601 date string + organizationId: z.string(), +}) + +/** + * Parameters for tracking a contribution + */ +export interface TrackContributionParams { + cwd: string + filePath: string + unifiedDiff: string + status: "accepted" | "rejected" + taskId?: string + organizationId?: string + kilocodeToken: string +} diff --git a/src/utils/vitest-verbosity.ts b/src/utils/vitest-verbosity.ts index bd44f977d33..096eb3164ed 100644 --- a/src/utils/vitest-verbosity.ts +++ b/src/utils/vitest-verbosity.ts @@ -1,16 +1,23 @@ +// kilocode_change start export function resolveVerbosity(argv = process.argv, env = process.env) { + // Check if running in CI environment + const isCI = env.CI === "true" || env.CI === "1" || Boolean(env.CI) + // Check if --no-silent flag is used (native vitest flag) const cliNoSilent = argv.includes("--no-silent") || argv.includes("--silent=false") - const silent = !cliNoSilent // Silent by default + const silent = !cliNoSilent && !isCI // Silent by default, but not in CI // Check if verbose reporter is requested const wantsVerboseReporter = argv.some( (a) => a === "--reporter=verbose" || a === "-r=verbose" || a === "--reporter", ) + // Use verbose reporter in CI or when explicitly requested + const useVerboseReporter = isCI || wantsVerboseReporter + return { silent, - reporters: ["dot", ...(wantsVerboseReporter ? ["verbose"] : [])], + reporters: useVerboseReporter ? ["verbose"] : ["dot"], onConsoleLog: (_log: string, type: string) => { // When verbose, show everything // When silent, allow errors/warnings and drop info/log/warn noise @@ -20,3 +27,4 @@ export function resolveVerbosity(argv = process.argv, env = process.env) { }, } } +// kilocode_change end diff --git a/webview-ui/src/components/kilocode/hooks/__tests__/getModelsByProvider.spec.ts b/webview-ui/src/components/kilocode/hooks/__tests__/getModelsByProvider.spec.ts index 9d9c2dd7c50..d1fe30c8723 100644 --- a/webview-ui/src/components/kilocode/hooks/__tests__/getModelsByProvider.spec.ts +++ b/webview-ui/src/components/kilocode/hooks/__tests__/getModelsByProvider.spec.ts @@ -1,51 +1,58 @@ -import { ModelInfo, ProviderName, providerNames } from "@roo-code/types" +import { + ModelInfo, + ProviderName, + providerNames, + internationalZAiModels, + internationalZAiDefaultModelId, + mainlandZAiModels, + mainlandZAiDefaultModelId, +} from "@roo-code/types" import { RouterModels } from "@roo/api" -import { getModelsByProvider } from "../useProviderModels" +import { getModelsByProvider, getOptionsForProvider } from "../useProviderModels" describe("getModelsByProvider", () => { - it("returns models for all providers", () => { - const testModel: ModelInfo = { - maxTokens: 4096, - contextWindow: 8192, - supportsImages: false, - supportsPromptCache: false, - inputPrice: 0.1, - outputPrice: 0.2, - description: "Test model", - } + const testModel: ModelInfo = { + maxTokens: 4096, + contextWindow: 8192, + supportsImages: false, + supportsPromptCache: false, + inputPrice: 0.1, + outputPrice: 0.2, + description: "Test model", + } - const routerModels: RouterModels = { - openrouter: { "test-model": testModel }, - requesty: { "test-model": testModel }, - glama: { "test-model": testModel }, - unbound: { "test-model": testModel }, - litellm: { "test-model": testModel }, - kilocode: { "test-model": testModel }, - "nano-gpt": { "test-model": testModel }, //kilocode_change - ollama: { "test-model": testModel }, - lmstudio: { "test-model": testModel }, - "io-intelligence": { "test-model": testModel }, - deepinfra: { "test-model": testModel }, - "vercel-ai-gateway": { "test-model": testModel }, - huggingface: { "test-model": testModel }, - gemini: { "test-model": testModel }, - ovhcloud: { "test-model": testModel }, - chutes: { "test-model": testModel }, - "sap-ai-core": { "test-model": testModel }, // kilocode_change - synthetic: { "test-model": testModel }, // kilocode_change - inception: { "test-model": testModel }, - roo: { "test-model": testModel }, - } + const routerModels: RouterModels = { + openrouter: { "test-model": testModel }, + requesty: { "test-model": testModel }, + glama: { "test-model": testModel }, + unbound: { "test-model": testModel }, + litellm: { "test-model": testModel }, + kilocode: { "test-model": testModel }, + "nano-gpt": { "test-model": testModel }, + ollama: { "test-model": testModel }, + lmstudio: { "test-model": testModel }, + "io-intelligence": { "test-model": testModel }, + deepinfra: { "test-model": testModel }, + "vercel-ai-gateway": { "test-model": testModel }, + huggingface: { "test-model": testModel }, + gemini: { "test-model": testModel }, + ovhcloud: { "test-model": testModel }, + chutes: { "test-model": testModel }, + "sap-ai-core": { "test-model": testModel }, + synthetic: { "test-model": testModel }, + inception: { "test-model": testModel }, + roo: { "test-model": testModel }, + } + it("returns models for all providers", () => { const exceptions = [ "fake-ai", // don't know what this is "huggingface", // don't know what this is "human-relay", // no models - "nano-gpt", // dynamic provider - models fetched from API //kilocode_change + "nano-gpt", // dynamic provider - models fetched from API "openai", // not implemented "roo", // don't care "virtual-quota-fallback", // no models - "zai", // has weird mainland/international distiction "vercel-ai-gateway", // different structure ] @@ -58,6 +65,7 @@ describe("getModelsByProvider", () => { provider, routerModels, kilocodeDefaultModel: "test-default-model", + options: {}, }), ] satisfies [ProviderName, ReturnType], ) @@ -66,4 +74,45 @@ describe("getModelsByProvider", () => { expect(providersWithoutModels).toStrictEqual([]) }) + + it("returns international Z.AI models when isChina is false", () => { + const result = getModelsByProvider({ + provider: "zai", + routerModels, + kilocodeDefaultModel: "test-default-model", + options: { isChina: false }, + }) + + expect(result.models).toEqual(internationalZAiModels) + expect(result.defaultModel).toEqual(internationalZAiDefaultModelId) + }) + + it("returns mainland Z.AI models when isChina is true", () => { + const result = getModelsByProvider({ + provider: "zai", + routerModels, + kilocodeDefaultModel: "test-default-model", + options: { isChina: true }, + }) + + expect(result.models).toEqual(mainlandZAiModels) + expect(result.defaultModel).toEqual(mainlandZAiDefaultModelId) + }) +}) + +describe("getOptionsForProvider", () => { + it("returns default options for non-zai providers", () => { + const result = getOptionsForProvider("openrouter") + expect(result).toEqual({}) + }) + + it("returns isChina: false for zai provider with default apiConfiguration", () => { + const result = getOptionsForProvider("zai", { zaiApiLine: "international_coding" }) + expect(result).toEqual({ isChina: false }) + }) + + it("returns isChina: true for zai provider with china_coding apiConfiguration", () => { + const result = getOptionsForProvider("zai", { zaiApiLine: "china_coding" }) + expect(result).toEqual({ isChina: true }) + }) }) diff --git a/webview-ui/src/components/kilocode/hooks/useProviderModels.ts b/webview-ui/src/components/kilocode/hooks/useProviderModels.ts index ebe9e9bcbcf..9cf7a166bcd 100644 --- a/webview-ui/src/components/kilocode/hooks/useProviderModels.ts +++ b/webview-ui/src/components/kilocode/hooks/useProviderModels.ts @@ -54,6 +54,10 @@ import { inceptionDefaultModelId, minimaxModels, minimaxDefaultModelId, + internationalZAiModels, + internationalZAiDefaultModelId, + mainlandZAiModels, + mainlandZAiDefaultModelId, } from "@roo-code/types" import type { ModelRecord, RouterModels } from "@roo/api" import { useRouterModels } from "../../ui/hooks/useRouterModels" @@ -68,10 +72,12 @@ export const getModelsByProvider = ({ provider, routerModels, kilocodeDefaultModel, + options = { isChina: false }, }: { provider: ProviderName routerModels: RouterModels kilocodeDefaultModel: string + options: { isChina?: boolean } }): { models: ModelRecord; defaultModel: string } => { switch (provider) { case "openrouter": { @@ -310,6 +316,19 @@ export const getModelsByProvider = ({ defaultModel: basetenDefaultModelId, } } + case "zai": { + if (options.isChina) { + return { + models: mainlandZAiModels, + defaultModel: mainlandZAiDefaultModelId, + } + } else { + return { + models: internationalZAiModels, + defaultModel: internationalZAiDefaultModelId, + } + } + } default: return { models: {}, @@ -318,6 +337,16 @@ export const getModelsByProvider = ({ } } +export const getOptionsForProvider = (provider: ProviderName, apiConfiguration?: ProviderSettings) => { + switch (provider) { + case "zai": + // Determine which Z.AI model set to use based on the API line configuration + return { isChina: apiConfiguration?.zaiApiLine === "china_coding" } + default: + return {} + } +} + export const useProviderModels = (apiConfiguration?: ProviderSettings) => { const provider = apiConfiguration?.apiProvider || "anthropic" @@ -337,12 +366,15 @@ export const useProviderModels = (apiConfiguration?: ProviderSettings) => { syntheticApiKey: apiConfiguration?.syntheticApiKey, // kilocode_change }) + const options = getOptionsForProvider(provider, apiConfiguration) + const { models, defaultModel } = apiConfiguration && typeof routerModels.data !== "undefined" ? getModelsByProvider({ provider, routerModels: routerModels.data, kilocodeDefaultModel, + options, }) : FALLBACK_MODELS diff --git a/webview-ui/src/i18n/locales/ar/agentManager.json b/webview-ui/src/i18n/locales/ar/agentManager.json index eab35ad7f37..42061a4f113 100644 --- a/webview-ui/src/i18n/locales/ar/agentManager.json +++ b/webview-ui/src/i18n/locales/ar/agentManager.json @@ -58,7 +58,8 @@ "otherBranches": "فروع أخرى", "branchPickerTooltip": "تبديل الفرع الأساسي", "noBranches": "لم يتم العثور على فروع", - "noMatchingBranches": "لا توجد فروع تطابق بحثك" + "noMatchingBranches": "لا توجد فروع تطابق بحثك", + "openTerminal": "فتح الطرفية" }, "messages": { "waiting": "في انتظار رد الوكيل...", diff --git a/webview-ui/src/i18n/locales/ca/agentManager.json b/webview-ui/src/i18n/locales/ca/agentManager.json index face5c8f20f..72b852e6f7c 100644 --- a/webview-ui/src/i18n/locales/ca/agentManager.json +++ b/webview-ui/src/i18n/locales/ca/agentManager.json @@ -54,7 +54,8 @@ "otherBranches": "Altres branques", "branchPickerTooltip": "Canvia la branca base", "noBranches": "No s'han trobat branques", - "noMatchingBranches": "No hi ha branques que coincideixin amb la teva cerca" + "noMatchingBranches": "No hi ha branques que coincideixin amb la teva cerca", + "openTerminal": "Obrir terminal" }, "messages": { "waiting": "Esperant resposta de l'agent...", diff --git a/webview-ui/src/i18n/locales/cs/agentManager.json b/webview-ui/src/i18n/locales/cs/agentManager.json index b911aff48d7..c74102563db 100644 --- a/webview-ui/src/i18n/locales/cs/agentManager.json +++ b/webview-ui/src/i18n/locales/cs/agentManager.json @@ -55,7 +55,8 @@ "otherBranches": "Ostatní větve", "branchPickerTooltip": "Změnit základní větev", "noBranches": "Nebyly nalezeny žádné větve", - "noMatchingBranches": "Žádné větve neodpovídají tvému vyhledávání" + "noMatchingBranches": "Žádné větve neodpovídají tvému vyhledávání", + "openTerminal": "Otevřít terminál" }, "messages": { "waiting": "Čekání na odpověď agenta...", diff --git a/webview-ui/src/i18n/locales/de/agentManager.json b/webview-ui/src/i18n/locales/de/agentManager.json index 587db7365ed..fc0d6a750f6 100644 --- a/webview-ui/src/i18n/locales/de/agentManager.json +++ b/webview-ui/src/i18n/locales/de/agentManager.json @@ -54,7 +54,8 @@ "otherBranches": "Andere Branches", "branchPickerTooltip": "Basis-Branch wechseln", "noBranches": "Keine Branches gefunden", - "noMatchingBranches": "Keine Branches entsprechen deiner Suche" + "noMatchingBranches": "Keine Branches entsprechen deiner Suche", + "openTerminal": "Terminal öffnen" }, "messages": { "waiting": "Warte auf Antwort des Agenten...", diff --git a/webview-ui/src/i18n/locales/en/agentManager.json b/webview-ui/src/i18n/locales/en/agentManager.json index 065ead28d53..f1d452de587 100644 --- a/webview-ui/src/i18n/locales/en/agentManager.json +++ b/webview-ui/src/i18n/locales/en/agentManager.json @@ -54,7 +54,8 @@ "otherBranches": "Other branches", "branchPickerTooltip": "Switch base branch", "noBranches": "No branches found", - "noMatchingBranches": "No branches match your search" + "noMatchingBranches": "No branches match your search", + "openTerminal": "Open terminal" }, "messages": { "waiting": "Waiting for agent response...", diff --git a/webview-ui/src/i18n/locales/es/agentManager.json b/webview-ui/src/i18n/locales/es/agentManager.json index e701b535b7b..e839de0590a 100644 --- a/webview-ui/src/i18n/locales/es/agentManager.json +++ b/webview-ui/src/i18n/locales/es/agentManager.json @@ -54,7 +54,8 @@ "otherBranches": "Otras ramas", "branchPickerTooltip": "Cambiar rama base", "noBranches": "No se encontraron ramas", - "noMatchingBranches": "Ninguna rama coincide con tu búsqueda" + "noMatchingBranches": "Ninguna rama coincide con tu búsqueda", + "openTerminal": "Abrir terminal" }, "messages": { "waiting": "Esperando respuesta del agente...", diff --git a/webview-ui/src/i18n/locales/fr/agentManager.json b/webview-ui/src/i18n/locales/fr/agentManager.json index f537474f3ee..18306e6213d 100644 --- a/webview-ui/src/i18n/locales/fr/agentManager.json +++ b/webview-ui/src/i18n/locales/fr/agentManager.json @@ -54,7 +54,8 @@ "otherBranches": "Autres branches", "branchPickerTooltip": "Changer de branche de base", "noBranches": "Aucune branche trouvée", - "noMatchingBranches": "Aucune branche ne correspond à ta recherche" + "noMatchingBranches": "Aucune branche ne correspond à ta recherche", + "openTerminal": "Ouvrir le terminal" }, "messages": { "waiting": "En attente de la réponse de l'agent...", diff --git a/webview-ui/src/i18n/locales/hi/agentManager.json b/webview-ui/src/i18n/locales/hi/agentManager.json index 095fee0cb0b..25debc72c47 100644 --- a/webview-ui/src/i18n/locales/hi/agentManager.json +++ b/webview-ui/src/i18n/locales/hi/agentManager.json @@ -54,7 +54,8 @@ "otherBranches": "अन्य ब्रांच", "branchPickerTooltip": "बेस ब्रांच बदलें", "noBranches": "कोई ब्रांच नहीं मिली", - "noMatchingBranches": "आपकी खोज से मेल खाने वाली कोई ब्रांच नहीं" + "noMatchingBranches": "आपकी खोज से मेल खाने वाली कोई ब्रांच नहीं", + "openTerminal": "टर्मिनल खोलें" }, "messages": { "waiting": "एजेंट की प्रतिक्रिया की प्रतीक्षा में...", diff --git a/webview-ui/src/i18n/locales/id/agentManager.json b/webview-ui/src/i18n/locales/id/agentManager.json index cbc0182cea6..152f8b90718 100644 --- a/webview-ui/src/i18n/locales/id/agentManager.json +++ b/webview-ui/src/i18n/locales/id/agentManager.json @@ -54,7 +54,8 @@ "otherBranches": "Cabang lain", "branchPickerTooltip": "Ganti cabang dasar", "noBranches": "Tidak ada cabang ditemukan", - "noMatchingBranches": "Tidak ada cabang yang cocok dengan pencarian kamu" + "noMatchingBranches": "Tidak ada cabang yang cocok dengan pencarian kamu", + "openTerminal": "Buka terminal" }, "messages": { "waiting": "Menunggu respons agen...", diff --git a/webview-ui/src/i18n/locales/it/agentManager.json b/webview-ui/src/i18n/locales/it/agentManager.json index ecd8caebb64..4037b9b2019 100644 --- a/webview-ui/src/i18n/locales/it/agentManager.json +++ b/webview-ui/src/i18n/locales/it/agentManager.json @@ -54,7 +54,8 @@ "otherBranches": "Altri branch", "branchPickerTooltip": "Cambia branch di base", "noBranches": "Nessun branch trovato", - "noMatchingBranches": "Nessun branch corrisponde alla tua ricerca" + "noMatchingBranches": "Nessun branch corrisponde alla tua ricerca", + "openTerminal": "Apri terminale" }, "messages": { "waiting": "In attesa della risposta dell'agente...", diff --git a/webview-ui/src/i18n/locales/ja/agentManager.json b/webview-ui/src/i18n/locales/ja/agentManager.json index 5f84b0db397..fcec7994083 100644 --- a/webview-ui/src/i18n/locales/ja/agentManager.json +++ b/webview-ui/src/i18n/locales/ja/agentManager.json @@ -54,7 +54,8 @@ "otherBranches": "その他のブランチ", "branchPickerTooltip": "ベースブランチを切り替え", "noBranches": "ブランチが見つかりません", - "noMatchingBranches": "検索に一致するブランチがありません" + "noMatchingBranches": "検索に一致するブランチがありません", + "openTerminal": "ターミナルを開く" }, "messages": { "waiting": "エージェントの応答を待っています...", diff --git a/webview-ui/src/i18n/locales/ko/agentManager.json b/webview-ui/src/i18n/locales/ko/agentManager.json index 991d263d98b..440d5bf0cf1 100644 --- a/webview-ui/src/i18n/locales/ko/agentManager.json +++ b/webview-ui/src/i18n/locales/ko/agentManager.json @@ -54,7 +54,8 @@ "otherBranches": "다른 브랜치", "branchPickerTooltip": "기본 브랜치 변경", "noBranches": "브랜치를 찾을 수 없습니다", - "noMatchingBranches": "검색과 일치하는 브랜치가 없습니다" + "noMatchingBranches": "검색과 일치하는 브랜치가 없습니다", + "openTerminal": "터미널 열기" }, "messages": { "waiting": "에이전트 응답 대기 중...", diff --git a/webview-ui/src/i18n/locales/nl/agentManager.json b/webview-ui/src/i18n/locales/nl/agentManager.json index 4c921ba61ae..47fe692c0cd 100644 --- a/webview-ui/src/i18n/locales/nl/agentManager.json +++ b/webview-ui/src/i18n/locales/nl/agentManager.json @@ -54,7 +54,8 @@ "otherBranches": "Andere branches", "branchPickerTooltip": "Wijzig basisbranch", "noBranches": "Geen branches gevonden", - "noMatchingBranches": "Geen branches komen overeen met je zoekopdracht" + "noMatchingBranches": "Geen branches komen overeen met je zoekopdracht", + "openTerminal": "Terminal openen" }, "messages": { "waiting": "Wachten op antwoord van agent...", diff --git a/webview-ui/src/i18n/locales/pl/agentManager.json b/webview-ui/src/i18n/locales/pl/agentManager.json index b1ba2b20ca5..09d72fe03cf 100644 --- a/webview-ui/src/i18n/locales/pl/agentManager.json +++ b/webview-ui/src/i18n/locales/pl/agentManager.json @@ -56,7 +56,8 @@ "otherBranches": "Inne gałęzie", "branchPickerTooltip": "Zmień gałąź bazową", "noBranches": "Nie znaleziono gałęzi", - "noMatchingBranches": "Brak gałęzi pasujących do twojego wyszukiwania" + "noMatchingBranches": "Brak gałęzi pasujących do twojego wyszukiwania", + "openTerminal": "Otwórz terminal" }, "messages": { "waiting": "Oczekiwanie na odpowiedź agenta...", diff --git a/webview-ui/src/i18n/locales/pt-BR/agentManager.json b/webview-ui/src/i18n/locales/pt-BR/agentManager.json index 9de5f8de013..f4e1f415bbf 100644 --- a/webview-ui/src/i18n/locales/pt-BR/agentManager.json +++ b/webview-ui/src/i18n/locales/pt-BR/agentManager.json @@ -54,7 +54,8 @@ "otherBranches": "Outros branches", "branchPickerTooltip": "Mudar branch base", "noBranches": "Nenhum branch encontrado", - "noMatchingBranches": "Nenhum branch corresponde à sua pesquisa" + "noMatchingBranches": "Nenhum branch corresponde à sua pesquisa", + "openTerminal": "Abrir terminal" }, "messages": { "waiting": "Aguardando resposta do agente...", diff --git a/webview-ui/src/i18n/locales/ru/agentManager.json b/webview-ui/src/i18n/locales/ru/agentManager.json index c64429331fe..b25411abd30 100644 --- a/webview-ui/src/i18n/locales/ru/agentManager.json +++ b/webview-ui/src/i18n/locales/ru/agentManager.json @@ -56,7 +56,8 @@ "otherBranches": "Другие ветки", "branchPickerTooltip": "Сменить базовую ветку", "noBranches": "Ветки не найдены", - "noMatchingBranches": "Нет веток, соответствующих твоему поиску" + "noMatchingBranches": "Нет веток, соответствующих твоему поиску", + "openTerminal": "Открыть терминал" }, "messages": { "waiting": "Ожидание ответа агента...", diff --git a/webview-ui/src/i18n/locales/th/agentManager.json b/webview-ui/src/i18n/locales/th/agentManager.json index 6271a65dcba..1185e2ea78d 100644 --- a/webview-ui/src/i18n/locales/th/agentManager.json +++ b/webview-ui/src/i18n/locales/th/agentManager.json @@ -54,7 +54,8 @@ "otherBranches": "สาขาอื่นๆ", "branchPickerTooltip": "เปลี่ยนสาขาหลัก", "noBranches": "ไม่พบสาขา", - "noMatchingBranches": "ไม่มีสาขาที่ตรงกับการค้นหาของคุณ" + "noMatchingBranches": "ไม่มีสาขาที่ตรงกับการค้นหาของคุณ", + "openTerminal": "เปิดเทอร์มินัล" }, "messages": { "waiting": "รอการตอบกลับจากเอเจนต์...", diff --git a/webview-ui/src/i18n/locales/tr/agentManager.json b/webview-ui/src/i18n/locales/tr/agentManager.json index bc22929452b..c940da503f5 100644 --- a/webview-ui/src/i18n/locales/tr/agentManager.json +++ b/webview-ui/src/i18n/locales/tr/agentManager.json @@ -54,7 +54,8 @@ "otherBranches": "Diğer dallar", "branchPickerTooltip": "Temel dalı değiştir", "noBranches": "Dal bulunamadı", - "noMatchingBranches": "Aramanla eşleşen dal yok" + "noMatchingBranches": "Aramanla eşleşen dal yok", + "openTerminal": "Terminali aç" }, "messages": { "waiting": "Ajan yanıtı bekleniyor...", diff --git a/webview-ui/src/i18n/locales/uk/agentManager.json b/webview-ui/src/i18n/locales/uk/agentManager.json index 77086ef4af1..a137e9bd738 100644 --- a/webview-ui/src/i18n/locales/uk/agentManager.json +++ b/webview-ui/src/i18n/locales/uk/agentManager.json @@ -56,7 +56,8 @@ "otherBranches": "Інші гілки", "branchPickerTooltip": "Змінити базову гілку", "noBranches": "Гілки не знайдено", - "noMatchingBranches": "Немає гілок, що відповідають твоєму пошуку" + "noMatchingBranches": "Немає гілок, що відповідають твоєму пошуку", + "openTerminal": "Відкрити термінал" }, "messages": { "waiting": "Очікування відповіді агента...", diff --git a/webview-ui/src/i18n/locales/vi/agentManager.json b/webview-ui/src/i18n/locales/vi/agentManager.json index 2db1d275612..44b6629429f 100644 --- a/webview-ui/src/i18n/locales/vi/agentManager.json +++ b/webview-ui/src/i18n/locales/vi/agentManager.json @@ -54,7 +54,8 @@ "otherBranches": "Nhánh khác", "branchPickerTooltip": "Chuyển nhánh cơ sở", "noBranches": "Không tìm thấy nhánh nào", - "noMatchingBranches": "Không có nhánh nào khớp với tìm kiếm của bạn" + "noMatchingBranches": "Không có nhánh nào khớp với tìm kiếm của bạn", + "openTerminal": "Mở terminal" }, "messages": { "waiting": "Đang chờ phản hồi từ agent...", diff --git a/webview-ui/src/i18n/locales/zh-CN/agentManager.json b/webview-ui/src/i18n/locales/zh-CN/agentManager.json index 87b448565bf..29f826375d2 100644 --- a/webview-ui/src/i18n/locales/zh-CN/agentManager.json +++ b/webview-ui/src/i18n/locales/zh-CN/agentManager.json @@ -54,7 +54,8 @@ "otherBranches": "其他 Branch", "branchPickerTooltip": "切换基础 Branch", "noBranches": "未找到 Branch", - "noMatchingBranches": "没有匹配的 Branch" + "noMatchingBranches": "没有匹配的 Branch", + "openTerminal": "打开终端" }, "messages": { "waiting": "等待代理响应...", diff --git a/webview-ui/src/i18n/locales/zh-TW/agentManager.json b/webview-ui/src/i18n/locales/zh-TW/agentManager.json index 384c1354cb2..96d733564ec 100644 --- a/webview-ui/src/i18n/locales/zh-TW/agentManager.json +++ b/webview-ui/src/i18n/locales/zh-TW/agentManager.json @@ -54,7 +54,8 @@ "otherBranches": "其他 Branch", "branchPickerTooltip": "切換基礎 Branch", "noBranches": "找不到 Branch", - "noMatchingBranches": "沒有符合的 Branch" + "noMatchingBranches": "沒有符合的 Branch", + "openTerminal": "開啟終端機" }, "messages": { "waiting": "等待代理回應...", diff --git a/webview-ui/src/kilocode/agent-manager/components/SessionDetail.tsx b/webview-ui/src/kilocode/agent-manager/components/SessionDetail.tsx index a8fc91e3678..d632ca8a26e 100644 --- a/webview-ui/src/kilocode/agent-manager/components/SessionDetail.tsx +++ b/webview-ui/src/kilocode/agent-manager/components/SessionDetail.tsx @@ -18,7 +18,18 @@ import { ChatInput } from "./ChatInput" import { BranchPicker } from "./BranchPicker" import { vscode } from "../utils/vscode" import { formatRelativeTime, createRelativeTimeLabels } from "../utils/timeUtils" -import { Loader2, SendHorizontal, GitBranch, Folder, ChevronDown, AlertCircle, Zap, Layers, X } from "lucide-react" +import { + Loader2, + SendHorizontal, + GitBranch, + Folder, + ChevronDown, + AlertCircle, + Zap, + Layers, + X, + Terminal, +} from "lucide-react" import DynamicTextArea from "react-textarea-autosize" import { cn } from "../../../lib/utils" import { StandardTooltip } from "../../../components/ui" @@ -71,6 +82,7 @@ export function SessionDetail() { const showSpinner = sessionUiState?.showSpinner ?? false const isWorktree = selectedSession.parallelMode?.enabled const branchName = selectedSession.parallelMode?.branch + const isProvisionalSession = selectedSession.sessionId.startsWith("provisional-") // Determine if "Finish to Branch" button should be shown // Simplified logic: show when session is a worktree session and running @@ -114,6 +126,22 @@ export function SessionDetail() { )} +
+ {!isProvisionalSession && ( + + )} +
{selectedSession.status === "error" && selectedSession.error && ( diff --git a/webview-ui/src/kilocode/agent-manager/components/__tests__/SessionDetail.spec.tsx b/webview-ui/src/kilocode/agent-manager/components/__tests__/SessionDetail.spec.tsx index 3ab553714d0..f2a4702a502 100644 --- a/webview-ui/src/kilocode/agent-manager/components/__tests__/SessionDetail.spec.tsx +++ b/webview-ui/src/kilocode/agent-manager/components/__tests__/SessionDetail.spec.tsx @@ -323,4 +323,34 @@ describe("SessionDetail", () => { expect(screen.getByText("sessionDetail.runModeLocal")).toBeInTheDocument() }) }) + + describe("terminal button visibility", () => { + it("hides terminal button for provisional sessions", () => { + const session = createSession({ + sessionId: "provisional-123", + status: "running", + parallelMode: { enabled: true, branch: "feature/test" }, + }) + + renderWithStore(session, { + [session.sessionId]: createUiState({ isActive: true, showSpinner: false }), + }) + + expect(screen.queryByLabelText("sessionDetail.openTerminal")).not.toBeInTheDocument() + }) + + it("shows terminal button for non-provisional sessions", () => { + const session = createSession({ + sessionId: "real-session-123", + status: "running", + parallelMode: { enabled: true, branch: "feature/test" }, + }) + + renderWithStore(session, { + [session.sessionId]: createUiState({ isActive: true, showSpinner: false }), + }) + + expect(screen.getByLabelText("sessionDetail.openTerminal")).toBeInTheDocument() + }) + }) })