Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/vscode-e2e/src/types/global.d.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { RooCodeAPI } from "@roo-code/types"

declare global {
// eslint-disable-next-line no-var
var api: RooCodeAPI
}

Expand Down
42 changes: 42 additions & 0 deletions packages/cloud/src/__tests__/config.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { describe, it, expect, vi, afterEach } from "vitest"
import { getClerkBaseUrl, getRooCodeApiUrl } from "../config.js"

describe("config", () => {
afterEach(() => {
vi.resetModules()
delete process.env.CLERK_BASE_URL
delete process.env.ROO_CODE_API_URL
})

describe("getClerkBaseUrl", () => {
it("should return production URL when no env var is set", () => {
expect(getClerkBaseUrl()).toBe("https://clerk.roocode.com")
})

it("should return custom URL from CLERK_BASE_URL env var", () => {
process.env.CLERK_BASE_URL = "https://custom-clerk.example.com"
expect(getClerkBaseUrl()).toBe("https://custom-clerk.example.com")
})

it("should fall back to production URL when CLERK_BASE_URL is empty string (falsy)", () => {
process.env.CLERK_BASE_URL = ""
expect(getClerkBaseUrl()).toBe("https://clerk.roocode.com")
})
})

describe("getRooCodeApiUrl", () => {
it("should return production URL when no env var is set", () => {
expect(getRooCodeApiUrl()).toBe("https://app.roocode.com")
})

it("should return custom URL from ROO_CODE_API_URL env var", () => {
process.env.ROO_CODE_API_URL = "https://custom-api.example.com"
expect(getRooCodeApiUrl()).toBe("https://custom-api.example.com")
})

it("should fall back to production URL when ROO_CODE_API_URL is empty string (falsy)", () => {
process.env.ROO_CODE_API_URL = ""
expect(getRooCodeApiUrl()).toBe("https://app.roocode.com")
})
})
})
64 changes: 64 additions & 0 deletions packages/cloud/src/__tests__/utils.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { describe, it, expect, vi } from "vitest"
import { getUserAgent } from "../utils.js"

// Mock vscode module for ExtensionContext type
vi.mock("vscode", () => ({
window: {
showInformationMessage: vi.fn(),
},
env: {
openExternal: vi.fn(),
uriScheme: "vscode",
},
}))

describe("getUserAgent", () => {
it("should return 'Zoo-Code unknown' when no context is provided", () => {
const result = getUserAgent()
expect(result).toBe("Zoo-Code unknown")
})

it("should include extension version from context.packageJSON.version", () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const mockContext: any = {
extension: {
packageJSON: {
version: "3.66.0",
},
},
}

const result = getUserAgent(mockContext)
expect(result).toBe("Zoo-Code 3.66.0")
})

it("should return 'unknown' when packageJSON.version is missing", () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const mockContext: any = {
extension: {
packageJSON: {},
},
}

const result = getUserAgent(mockContext)
expect(result).toBe("Zoo-Code unknown")
})

it("should return 'unknown' when extension is undefined", () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const mockContext: any = {}

const result = getUserAgent(mockContext)
expect(result).toBe("Zoo-Code unknown")
})

it("should return 'unknown' when packageJSON is undefined", () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const mockContext: any = {
extension: {},
}

const result = getUserAgent(mockContext)
expect(result).toBe("Zoo-Code unknown")
})
})
11 changes: 10 additions & 1 deletion src/api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,17 @@ import {
} from "./providers"
import { NativeOllamaHandler } from "./providers/native-ollama"

/**
* Options for completePrompt — unified with ApiHandlerCreateMessageMetadata.
* Uses abortSignal (not signal) to match the metadata pattern used in stream path.
*/
export interface CompletePromptOptions extends Pick<ApiHandlerCreateMessageMetadata, "abortSignal"> {
/** Optional timeout override (ms) — falls back to provider default if omitted */
timeoutMs?: number
}

export interface SingleCompletionHandler {
completePrompt(prompt: string): Promise<string>
completePrompt(prompt: string, metadata?: CompletePromptOptions): Promise<string>
}

export interface ApiHandlerCreateMessageMetadata {
Expand Down
61 changes: 42 additions & 19 deletions src/api/providers/__tests__/anthropic-vertex.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -834,18 +834,22 @@ describe("VertexHandler", () => {

const result = await handler.completePrompt("Test prompt")
expect(result).toBe("Test response")
expect(handler["client"].messages.create).toHaveBeenCalledWith({
model: "claude-3-5-sonnet-v2@20241022",
max_tokens: 8192,
temperature: 0,
messages: [
{
role: "user",
content: [{ type: "text", text: "Test prompt", cache_control: { type: "ephemeral" } }],
},
],
stream: false,
})
expect(handler["client"].messages.create).toHaveBeenCalledWith(
{
model: "claude-3-5-sonnet-v2@20241022",
max_tokens: 8192,
temperature: 0,
messages: [
{
role: "user",
content: [{ type: "text", text: "Test prompt", cache_control: { type: "ephemeral" } }],
},
],
stream: false,
thinking: undefined,
},
undefined,
)
})

it("should handle API errors for Claude", async () => {
Expand Down Expand Up @@ -912,24 +916,43 @@ describe("VertexHandler", () => {
expect(result).toBe("")
})

it("should return text from first text block when mixed content for Claude", async () => {
it("should pass abort signal through to client", async () => {
handler = new AnthropicVertexHandler({
apiModelId: "claude-3-5-sonnet-v2@20241022",
vertexProjectId: "test-project",
vertexRegion: "us-central1",
})

const controller = new AbortController()
const mockCreate = vitest.fn().mockResolvedValue({
content: [
{ type: "thinking", thinking: "internal reasoning" },
{ type: "text", text: "visible response" },
],
content: [{ type: "text", text: "response" }],
})
;(handler["client"].messages as any).create = mockCreate

const result = await handler.completePrompt("Test prompt")
expect(result).toBe("visible response")
await handler.completePrompt("test prompt", { abortSignal: controller.signal })
expect(mockCreate).toHaveBeenCalledWith(expect.objectContaining({ model: expect.any(String) }), {
signal: controller.signal,
})
})

it("should work without options (backward compatible)", async () => {
handler = new AnthropicVertexHandler({
apiModelId: "claude-3-5-sonnet-v2@20241022",
vertexProjectId: "test-project",
vertexRegion: "us-central1",
})

const mockCreate = vitest.fn().mockResolvedValue({
content: [{ type: "text", text: "response" }],
})
;(handler["client"].messages as any).create = mockCreate

const result = await handler.completePrompt("test prompt")
expect(result).toBe("response")
expect(mockCreate).toHaveBeenCalledWith(expect.objectContaining({ model: expect.any(String) }), undefined)
})

it("completePrompt should pass signal through to client", async () => {})
})

describe("getModel", () => {
Expand Down
99 changes: 91 additions & 8 deletions src/api/providers/__tests__/anthropic.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -461,14 +461,17 @@ describe("AnthropicHandler", () => {
it("should complete prompt successfully", async () => {
const result = await handler.completePrompt("Test prompt")
expect(result).toBe("Test response")
expect(mockCreate).toHaveBeenCalledWith({
model: mockOptions.apiModelId,
messages: [{ role: "user", content: "Test prompt" }],
max_tokens: 8192,
temperature: 0,
thinking: undefined,
stream: false,
})
expect(mockCreate).toHaveBeenCalledWith(
{
model: mockOptions.apiModelId,
messages: [{ role: "user", content: "Test prompt" }],
max_tokens: 8192,
temperature: 0,
thinking: undefined,
stream: false,
},
undefined,
)
})

it("should handle API errors", async () => {
Expand All @@ -491,6 +494,86 @@ describe("AnthropicHandler", () => {
const result = await handler.completePrompt("Test prompt")
expect(result).toBe("")
})

it("should pass abort signal through to client", async () => {
const controller = new AbortController()
mockCreate.mockResolvedValueOnce({ content: [{ type: "text", text: "response" }] })
await handler.completePrompt("test prompt", { abortSignal: controller.signal })
expect(mockCreate).toHaveBeenCalledWith(
{
model: mockOptions.apiModelId,
messages: [{ role: "user", content: "test prompt" }],
max_tokens: 8192,
temperature: 0,
thinking: undefined,
stream: false,
},
{ signal: controller.signal },
)
})

it("should work without options (backward compatible)", async () => {
mockCreate.mockResolvedValueOnce({ content: [{ type: "text", text: "response" }] })
const result = await handler.completePrompt("test prompt")
expect(result).toBe("response")
expect(mockCreate).toHaveBeenCalledWith(
{
model: mockOptions.apiModelId,
messages: [{ role: "user", content: "test prompt" }],
max_tokens: 8192,
temperature: 0,
thinking: undefined,
stream: false,
},
undefined,
)
})

it("should merge signal and timeout together", async () => {
const controller = new AbortController()
mockCreate.mockResolvedValueOnce({ content: [{ type: "text", text: "response" }] })
await handler.completePrompt("test prompt", { abortSignal: controller.signal, timeoutMs: 10000 })
expect(mockCreate).toHaveBeenCalledWith(
{
model: mockOptions.apiModelId,
messages: [{ role: "user", content: "test prompt" }],
max_tokens: 8192,
temperature: 0,
thinking: undefined,
stream: false,
},
expect.objectContaining({ signal: controller.signal, timeout: 10000 }),
)
})

it("should pass timeoutMs through to client alongside abortSignal", async () => {
const controller = new AbortController()
mockCreate.mockResolvedValueOnce({ content: [{ type: "text", text: "response" }] })
await handler.completePrompt("test prompt", { abortSignal: controller.signal, timeoutMs: 5000 })
expect(mockCreate).toHaveBeenCalledWith(
expect.objectContaining({ model: mockOptions.apiModelId }),
expect.objectContaining({ signal: controller.signal, timeout: 5000 }),
)
})

it("should pass the same signal instance", async () => {
const controller = new AbortController()
mockCreate.mockResolvedValueOnce({ content: [{ type: "text", text: "response" }] })
await handler.completePrompt("test prompt", { abortSignal: controller.signal })
expect(mockCreate).toHaveBeenCalledWith(
expect.any(Object),
expect.objectContaining({ signal: controller.signal }),
)
// Verify it's the exact same instance, not just equal
const callOptions = mockCreate.mock.calls[0][1]
expect(callOptions?.signal).toBe(controller.signal)
})

it("should not include signal-related options when not provided", async () => {
mockCreate.mockResolvedValueOnce({ content: [{ type: "text", text: "response" }] })
await handler.completePrompt("test prompt")
expect(mockCreate).toHaveBeenCalledWith(expect.any(Object), undefined)
})
})

describe("getModel", () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -116,4 +116,58 @@ describe("BaseOpenAiCompatibleProvider Timeout Configuration", () => {
}),
)
})

describe("completePrompt", () => {
it("should pass timeout through to client when both signal and timeoutMs provided", async () => {
const handler = new TestOpenAiCompatibleProvider("test-api-key")
const controller = new AbortController()
const mockCreate = vitest.fn().mockResolvedValue({
choices: [{ message: { content: "response" } }],
})
handler["client"].chat.completions.create = mockCreate

await handler.completePrompt("test prompt", { abortSignal: controller.signal, timeoutMs: 5000 })
expect(mockCreate).toHaveBeenCalledWith(
expect.objectContaining({ model: "test-model" }),
expect.objectContaining({ signal: expect.any(AbortSignal), timeout: 5000 }),
)
})

it("should pass only timeoutMs when no signal provided", async () => {
const handler = new TestOpenAiCompatibleProvider("test-api-key")
const mockCreate = vitest.fn().mockResolvedValue({
choices: [{ message: { content: "response" } }],
})
handler["client"].chat.completions.create = mockCreate

await handler.completePrompt("test prompt", { timeoutMs: 3000 })
expect(mockCreate).toHaveBeenCalledWith(expect.objectContaining({ model: "test-model" }), { timeout: 3000 })
})

it("should handle timeoutMs=0 as valid value (!== undefined check)", async () => {
const handler = new TestOpenAiCompatibleProvider("test-api-key")
const mockCreate = vitest.fn().mockResolvedValue({
choices: [{ message: { content: "response" } }],
})
handler["client"].chat.completions.create = mockCreate

await handler.completePrompt("test prompt", { timeoutMs: 0 })
expect(mockCreate).toHaveBeenCalledWith(expect.objectContaining({ model: "test-model" }), { timeout: 0 })
})

it("should work without options (backward compatible)", async () => {
const handler = new TestOpenAiCompatibleProvider("test-api-key")
const mockCreate = vitest.fn().mockResolvedValue({
choices: [{ message: { content: "response" } }],
})
handler["client"].chat.completions.create = mockCreate

const result = await handler.completePrompt("test prompt")
expect(result).toBe("response")
expect(mockCreate).toHaveBeenCalledWith(
expect.objectContaining({ model: "test-model" }),
{}, // empty object when no options
)
})
})
})
Loading