Cloud Agent - Add kilo cloud command for running asynchronous cloud agent tasks (#11849)

This commit is contained in:
Evgeny Shurakov
2026-07-22 15:48:29 +02:00
committed by GitHub
parent 16988a5581
commit fe01f53e2b
30 changed files with 3814 additions and 2 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"@kilocode/cli": minor
---
Run asynchronous Cloud Agent tasks with repository, model, mode, and organization defaults through `kilo cloud`. Add `--stream` to `kilo cloud start` to print admission output and then stream WebSocket events as JSONL until completion or inactivity ends the stream.
+1
View File
@@ -50,6 +50,7 @@ exclude = [
'^https?://vercel\.link/',
# API base URL, returns 404 when fetched directly
'^https?://api\.apertis\.ai/v1/?$',
'^https?://cloud-agent-next\.kilosessions\.ai/?$',
# Redirects to authenticated Google Cloud console
'^https?://console\.cloud\.google\.com',
# Google AI Studio API keys page redirects to Google sign-in
@@ -26,6 +26,7 @@
| `kilo remote` | enable remote connection for real-time session relay |
| `kilo daemon` | manage the local kilo daemon |
| `kilo console` | open or stop the local Kilo Console |
| `kilo cloud` | run Cloud Agent tasks |
| `kilo db` | database tools |
| `kilo config` | configuration tools |
| `kilo plugin <module>` | install plugin and update config |
@@ -968,6 +968,76 @@ Options:
--json print daemon details as JSON [boolean]
```
## kilo cloud
```
run Cloud Agent tasks
Commands:
kilo cloud start start a Cloud Agent task
kilo cloud send send a follow-up prompt to a Cloud Agent task
kilo cloud status show Cloud Agent task status
kilo cloud result show a Cloud Agent task result
Options:
--help Show help [boolean]
--version Show version number [boolean]
```
### kilo cloud start
```
start a Cloud Agent task
Options:
--help Show help [boolean]
--version Show version number [boolean]
--prompt prompt for the Cloud Agent [string] [required]
--repo repository shorthand or URL [string]
--repo-type repository provider type [string] [choices: "github", "gitlab", "git"]
--branch repository branch [string]
--model Cloud Agent model [string]
--mode Cloud Agent mode [string]
--org-id Kilo organization ID [string]
--stream connect to the WebSocket stream and print events as JSONL [boolean]
```
### kilo cloud send
```
send a follow-up prompt to a Cloud Agent task
Options:
--help Show help [boolean]
--version Show version number [boolean]
--session-id Cloud Agent session ID [string] [required]
--prompt follow-up prompt for the Cloud Agent [string] [required]
```
### kilo cloud status
```
show Cloud Agent task status
Options:
--help Show help [boolean]
--version Show version number [boolean]
--session-id Cloud Agent session ID [string] [required]
--message-id Cloud Agent message ID [string] [required]
```
### kilo cloud result
```
show a Cloud Agent task result
Options:
--help Show help [boolean]
--version Show version number [boolean]
--session-id Cloud Agent session ID [string] [required]
--message-id Cloud Agent message ID [string] [required]
```
## kilo db
```
+3
View File
@@ -50,6 +50,8 @@
<!-- packages/opencode/src/provider/error.ts -->
- <https://cli.github.com/>
<!-- packages/kilo-vscode/src/agent-manager/WorktreeManager.ts -->
- <https://cloud-agent-next.kilosessions.ai>
<!-- packages/opencode/src/kilocode/cloud/origin.ts -->
- <https://cloud.digitalocean.com/v1/oauth/authorize>
<!-- packages/opencode/src/plugin/digitalocean.ts -->
- <https://cloudflare.com/cdn-cgi/trace>
@@ -106,6 +108,7 @@
<!-- packages/opencode/src/plugin/digitalocean.ts -->
- <https://kilo.ai>
<!-- packages/opencode/src/cli/cmd/github.handler.ts -->
<!-- packages/opencode/src/kilocode/cloud/origin.ts -->
<!-- packages/opencode/src/mcp/oauth-provider.ts -->
<!-- packages/opencode/src/session/network.ts -->
- <https://kilo.ai/>
@@ -0,0 +1,134 @@
import type { Argv } from "yargs"
import { Effect } from "effect"
import { cmd } from "@/cli/cmd/cmd"
import { effectCmd } from "@/cli/effect-cmd"
import { CloudCommands } from "@/kilocode/cloud/commands"
export const CloudStartCommand = effectCmd({
command: "start",
describe: "start a Cloud Agent task",
builder: (yargs) =>
yargs
.option("prompt", {
type: "string",
demandOption: true,
describe: "prompt for the Cloud Agent",
})
.option("repo", {
type: "string",
describe: "repository shorthand or URL",
})
.option("repo-type", {
type: "string",
choices: ["github", "gitlab", "git"] as const,
describe: "repository provider type",
})
.option("branch", {
type: "string",
describe: "repository branch",
})
.option("model", {
type: "string",
describe: "Cloud Agent model",
})
.option("mode", {
type: "string",
describe: "Cloud Agent mode",
})
.option("org-id", {
type: "string",
describe: "Kilo organization ID",
})
.option("stream", {
type: "boolean",
describe: "connect to the WebSocket stream and print events as JSONL",
}),
handler: Effect.fn("Cli.cloud.start")(function* (args) {
yield* CloudCommands.start({
prompt: args.prompt,
...(args.repo === undefined ? {} : { repo: args.repo }),
...(args.repoType === undefined ? {} : { repoType: args.repoType }),
...(args.branch === undefined ? {} : { branch: args.branch }),
...(args.model === undefined ? {} : { model: args.model }),
...(args.mode === undefined ? {} : { mode: args.mode }),
...(args.orgId === undefined ? {} : { orgID: args.orgId }),
...(args.stream === undefined ? {} : { stream: args.stream }),
})
}),
})
export const CloudSendCommand = effectCmd({
command: "send",
describe: "send a follow-up prompt to a Cloud Agent task",
instance: false,
builder: (yargs) =>
yargs
.option("session-id", {
type: "string",
demandOption: true,
describe: "Cloud Agent session ID",
})
.option("prompt", {
type: "string",
demandOption: true,
describe: "follow-up prompt for the Cloud Agent",
}),
handler: Effect.fn("Cli.cloud.send")(function* (args) {
yield* CloudCommands.send({ sessionID: args.sessionId, prompt: args.prompt })
}),
})
export const CloudStatusCommand = effectCmd({
command: "status",
describe: "show Cloud Agent task status",
instance: false,
builder: (yargs) =>
yargs
.option("session-id", {
type: "string",
demandOption: true,
describe: "Cloud Agent session ID",
})
.option("message-id", {
type: "string",
demandOption: true,
describe: "Cloud Agent message ID",
}),
handler: Effect.fn("Cli.cloud.status")(function* (args) {
yield* CloudCommands.status({ sessionID: args.sessionId, messageID: args.messageId })
}),
})
export const CloudResultCommand = effectCmd({
command: "result",
describe: "show a Cloud Agent task result",
instance: false,
builder: (yargs) =>
yargs
.option("session-id", {
type: "string",
demandOption: true,
describe: "Cloud Agent session ID",
})
.option("message-id", {
type: "string",
demandOption: true,
describe: "Cloud Agent message ID",
}),
handler: Effect.fn("Cli.cloud.result")(function* (args) {
yield* CloudCommands.result({ sessionID: args.sessionId, messageID: args.messageId })
}),
})
export const CloudCommand = cmd({
command: "cloud",
describe: "run Cloud Agent tasks",
builder: (yargs: Argv) =>
yargs
.command(CloudStartCommand)
.command(CloudSendCommand)
.command(CloudStatusCommand)
.command(CloudResultCommand)
.demandCommand(),
async handler() {},
})
@@ -12,6 +12,7 @@ import { SessionExport } from "@/kilocode/session-export"
import { KiloShutdown } from "@/kilocode/cli/shutdown"
import { createHelpCommand } from "@/kilocode/help-command"
import { KiloConsoleCommand } from "@/kilocode/cli/cmd/console"
import { CloudCommand } from "@/kilocode/cli/cmd/cloud"
import { RollCallCommand } from "@/kilocode/cli/cmd/roll-call"
import { ProfileCommand } from "@/kilocode/cli/cmd/profile"
import { DaemonCommand } from "@/kilocode/cli/cmd/daemon"
@@ -32,6 +33,7 @@ export namespace KiloCli {
export function register<T>(cli: Argv<T>): Argv<T> {
cli
.command(KiloConsoleCommand)
.command(CloudCommand)
.command(RollCallCommand)
.command(ProfileCommand)
.command(RemoteCommand)
@@ -0,0 +1,66 @@
import { Auth } from "@/auth"
import { Effect, Redacted, Schema } from "effect"
import z from "zod"
export namespace CloudAuth {
export type Environment = Readonly<Record<string, string | undefined>>
export interface Input {
readonly orgID?: string
readonly env?: Environment
}
export interface Resolved {
readonly token: Redacted.Redacted
readonly organizationID?: string
}
export class ResolutionError extends Schema.TaggedErrorClass<ResolutionError>()("CloudAuthResolutionError", {
kind: Schema.Literals(["missing", "organization"]),
message: Schema.String,
}) {}
const Uuid = z.uuid()
const credentials = Effect.fn("CloudAuth.credentials")(function* (env: Environment) {
const service = yield* Auth.Service
const info = yield* service.get("kilo")
const stored = info?.type === "api" ? info.key.trim() : info?.type === "oauth" ? info.access.trim() : undefined
const fallback = env.KILO_API_KEY?.trim()
const value = stored || fallback
if (!value) {
return yield* Effect.fail(
new ResolutionError({
kind: "missing",
message: "Kilo credentials are required; run `kilo auth login`",
}),
)
}
return { token: Redacted.make(value), accountID: info?.type === "oauth" ? info.accountId : undefined }
})
export const token = Effect.fn("CloudAuth.token")(function* (env: Environment = process.env) {
return (yield* credentials(env)).token
})
export const resolve = Effect.fn("CloudAuth.resolve")(function* (input: Input = {}) {
const env = input.env ?? process.env
const auth = yield* credentials(env)
const explicit = input.orgID
const setting = env.KILO_ORG_ID?.trim()
const organizationID = explicit !== undefined ? explicit : setting || auth.accountID
if (organizationID !== undefined && !Uuid.safeParse(organizationID).success) {
return yield* Effect.fail(
new ResolutionError({
kind: "organization",
message: "Kilo organization ID must be a valid UUID",
}),
)
}
return {
token: auth.token,
...(organizationID ? { organizationID } : {}),
} satisfies Resolved
})
}
@@ -0,0 +1,173 @@
import {
buildKiloHeaders,
DEFAULT_KILO_API_URL,
getDefaultHeaders,
getKiloUrlFromToken,
resolveKiloOpenRouterBaseUrl,
} from "@kilocode/kilo-gateway"
import { Context, Effect, Layer, Redacted, Schema } from "effect"
import z from "zod"
import type { CloudAuth } from "./auth"
import { parseServiceOrigin } from "./origin"
import { readBoundedJson } from "./response-json"
export namespace CloudCatalog {
const TIMEOUT = 10_000
const Models = z.object({
data: z.array(
z.object({
id: z.string().min(1).max(255),
architecture: z
.object({
output_modalities: z.array(z.string()).nullish(),
})
.optional(),
supported_parameters: z.array(z.string()).optional(),
}),
),
})
const Defaults = z.object({
defaultModel: z.string().min(1).max(255),
})
export type Environment = Readonly<Record<string, string | undefined>>
export type Fetch = (request: Request) => Promise<Response>
export interface Options {
readonly env?: Environment
readonly fetch?: Fetch
}
export interface Input extends CloudAuth.Resolved {}
export class CatalogError extends Schema.TaggedErrorClass<CatalogError>()("CloudCatalogError", {
kind: Schema.Literals(["auth", "network", "schema", "http"]),
status: Schema.optional(Schema.Number),
message: Schema.String,
}) {}
export interface Interface {
readonly models: (input: Input) => Effect.Effect<readonly string[], CatalogError>
readonly defaultModel: (input: Input) => Effect.Effect<string, CatalogError>
}
export class Service extends Context.Service<Service, Interface>()("@kilocode/CloudCatalog") {}
export const layer = (options: Options = {}) => {
const fetcher = options.fetch ?? ((request: Request) => globalThis.fetch(request))
const env = options.env ?? process.env
const request = Effect.fn("CloudCatalog.request")(function* <A>(url: string, input: Input, schema: z.ZodType<A>) {
const req = yield* Effect.try({
try: () =>
new Request(url, {
headers: {
...getDefaultHeaders(),
...buildKiloHeaders(
undefined,
input.organizationID ? { kilocodeOrganizationId: input.organizationID } : undefined,
),
"X-KILOCODE-FEATURE": "kilo-cli",
Authorization: `Bearer ${Redacted.value(input.token)}`,
},
redirect: "error",
signal: AbortSignal.timeout(TIMEOUT),
}),
catch: () =>
new CatalogError({
kind: "schema",
message: "Kilo catalog URL is invalid",
}),
})
const response = yield* Effect.tryPromise({
try: () => fetcher(req),
catch: () =>
new CatalogError({
kind: "network",
message: "Unable to reach the Kilo model catalog",
}),
})
if (!response.ok) {
const kind = response.status === 401 || response.status === 403 ? "auth" : "http"
return yield* Effect.fail(
new CatalogError({
kind,
status: response.status,
message:
kind === "auth"
? "Kilo credentials or organization were rejected by the model catalog"
: "The Kilo model catalog is unavailable",
}),
)
}
const body = yield* Effect.tryPromise({
try: () => readBoundedJson(response),
catch: () =>
new CatalogError({
kind: "schema",
message: "The Kilo model catalog returned an invalid response",
}),
})
const parsed = schema.safeParse(body)
if (!parsed.success) {
return yield* Effect.fail(
new CatalogError({
kind: "schema",
message: "The Kilo model catalog returned an invalid response",
}),
)
}
return parsed.data
})
const base = Effect.fn("CloudCatalog.base")(function* (input: Input) {
const raw = env.KILO_API_URL?.trim()
const fallback = raw || DEFAULT_KILO_API_URL
const value = getKiloUrlFromToken(fallback, Redacted.value(input.token))
return yield* Effect.try({
try: () => {
const url = new URL(resolveKiloOpenRouterBaseUrl({ baseURL: value }))
parseServiceOrigin(url.origin, { allowHttpLoopback: !!raw || value !== fallback })
if (url.username !== "" || url.password !== "") throw new Error("Catalog URL credentials are not allowed")
return url
},
catch: () =>
new CatalogError({
kind: "schema",
message: "Kilo catalog URL must be secure",
}),
})
})
const models = Effect.fn("CloudCatalog.models")(function* (input: Input) {
const root = yield* base(input)
const path = input.organizationID
? `../organizations/${encodeURIComponent(input.organizationID)}/models`
: "models"
const result = yield* request(new URL(path, root).toString(), input, Models)
return [
...new Set(
result.data
.filter(
(model) =>
!model.architecture?.output_modalities?.includes("image") &&
model.supported_parameters?.includes("tools"),
)
.map((model) => model.id),
),
]
})
const defaultModel = Effect.fn("CloudCatalog.defaultModel")(function* (input: Input) {
const root = yield* base(input)
const path = input.organizationID
? `../organizations/${encodeURIComponent(input.organizationID)}/defaults`
: "../defaults"
return (yield* request(new URL(path, root).toString(), input, Defaults)).defaultModel
})
return Layer.succeed(Service, Service.of({ models, defaultModel }))
}
}
@@ -0,0 +1,295 @@
import { CliError, fail } from "@/cli/effect-cmd"
import { Effect, Layer, Redacted } from "effect"
import { CloudAuth } from "./auth"
import { CloudCatalog } from "./catalog"
import {
CloudAgentSessionIdSchema,
MessageIdSchema,
PromptSchema,
projectStatus,
resultExitCode,
type AgentResultExitCode,
type AgentStartRequest,
type AgentStartResponse,
type GetMessageResultInput,
} from "./contracts"
import { CloudDefaults } from "./defaults"
import { CloudError } from "./errors"
import { resolveCloudAgentOrigin, resolveWebAppOrigin, type ServiceOrigin } from "./origin"
import { CloudRepository } from "./repository"
import { createStreamTicketClient, type StreamTicketClient } from "./stream-ticket"
import { createCloudAgentClient, type AgentClient } from "./trpc"
import { streamAgentEvents, type StreamAgentEventsOptions } from "./websocket-stream"
export namespace CloudCommands {
export interface StartInput {
readonly cwd?: string
readonly prompt: string
readonly repo?: string
readonly repoType?: CloudRepository.RepositoryType
readonly branch?: string
readonly model?: string
readonly mode?: string
readonly orgID?: string
readonly stream?: boolean
}
export interface SendInput {
readonly sessionID: string
readonly prompt: string
}
export interface LookupInput {
readonly sessionID: string
readonly messageID: string
}
export interface ClientOptions {
readonly origin: ServiceOrigin
readonly apiKey: string
}
export type ClientFactory = (options: ClientOptions) => AgentClient
export interface Deps {
readonly env?: CloudAuth.Environment
readonly make?: ClientFactory
readonly write?: (text: string) => unknown
readonly exit?: (code: AgentResultExitCode) => unknown
readonly createStreamTicketClient?: (options: {
readonly origin: ServiceOrigin
readonly apiKey: string
}) => StreamTicketClient
readonly streamAgentEvents?: (options: StreamAgentEventsOptions) => Promise<void>
}
const factory: ClientFactory = (options) => createCloudAgentClient(options)
function diagnostic(error: unknown) {
if (error instanceof CliError) return error.message
if (error instanceof CloudError) return error.message
if (error instanceof CloudAuth.ResolutionError) return error.message
if (error instanceof CloudCatalog.CatalogError) return error.message
if (error instanceof CloudDefaults.ResolutionError) return error.message
if (error instanceof CloudRepository.InvalidRepositoryError) return error.message
if (error instanceof CloudRepository.InvalidBranchError) return error.message
if (error instanceof CloudRepository.NotWorktreeError) return error.message
if (error instanceof CloudRepository.NoRemoteError) return error.message
if (error instanceof CloudRepository.AmbiguousRemoteError) return error.message
if (error instanceof CloudRepository.DiscoveryError) return error.message
return "Cloud Agent command failed"
}
function clean<A, E, R>(effect: Effect.Effect<A, E, R>): Effect.Effect<A, CliError, R> {
return effect.pipe(Effect.catch((error) => fail(diagnostic(error))))
}
function print(value: unknown, deps: Deps, admission?: "start" | "send") {
const text = JSON.stringify(value) + "\n"
const message = admission
? `Cloud Agent ${admission} was admitted but output could not be written; do not retry automatically`
: "Unable to write Cloud Agent output"
const write = deps.write
if (write) {
return Effect.tryPromise({
try: async () => {
await write(text)
},
catch: () => new CloudError(message),
})
}
return Effect.tryPromise({
try: () =>
new Promise<void>((resolve, reject) => {
process.stdout.write(text, (error) => (error ? reject(error) : resolve()))
}),
catch: () => new CloudError(message),
})
}
async function writeLine(text: string, deps: Deps) {
const line = `${text}\n`
const write = deps.write
if (write) {
await write(line)
return
}
await new Promise<void>((resolve, reject) => {
process.stdout.write(line, (error) => (error ? reject(error) : resolve()))
})
}
function notice(error: unknown, deps: Deps) {
return print({ streamEventType: "error", data: { message: diagnostic(error) } }, deps).pipe(
Effect.catch(() => Effect.void),
)
}
function attempt<A>(run: () => Promise<A>) {
return Effect.tryPromise({
try: run,
catch: (error) => (error instanceof CloudError ? error : new CloudError("Cloud Agent request failed")),
})
}
const auth = Effect.fn("CloudCommands.auth")(function* (deps: Deps) {
const env = deps.env ?? process.env
return yield* CloudAuth.token({ KILO_API_KEY: env.KILO_API_KEY })
})
function client(token: CloudAuth.Resolved["token"], deps: Deps) {
return Effect.try({
try: () => {
const env = deps.env ?? process.env
const origin = resolveCloudAgentOrigin(env)
return (deps.make ?? factory)({ origin, apiKey: Redacted.value(token) })
},
catch: (error) => (error instanceof CloudError ? error : new CloudError("Cloud Agent client setup failed")),
})
}
const lookup = Effect.fn("CloudCommands.lookup")(function* (input: LookupInput, deps: Deps) {
if (!CloudAgentSessionIdSchema.safeParse(input.sessionID).success) {
return yield* fail("Cloud Agent session ID is invalid")
}
if (!MessageIdSchema.safeParse(input.messageID).success) {
return yield* fail("Cloud Agent message ID is invalid")
}
const token = yield* auth(deps)
const agent = yield* client(token, deps)
const request = {
cloudAgentSessionId: input.sessionID,
messageId: input.messageID,
} satisfies GetMessageResultInput
return yield* attempt(() => agent.getMessageResult(request))
})
export const start = Effect.fn("CloudCommands.start")(function* (input: StartInput, deps: Deps = {}) {
return yield* clean(
Effect.gen(function* () {
if (!PromptSchema.safeParse(input.prompt).success) return yield* fail("Cloud Agent prompt is invalid")
const env = deps.env ?? process.env
const defaults = yield* CloudDefaults.resolve({
env,
...(input.mode === undefined ? {} : { mode: input.mode }),
...(input.model === undefined ? {} : { model: input.model }),
...(input.orgID === undefined ? {} : { orgID: input.orgID }),
}).pipe(Effect.provide(Layer.mergeAll(CloudDefaults.modelStateLayer, CloudCatalog.layer({ env }))))
const repository = yield* CloudRepository.resolve({
cwd: input.cwd ?? process.cwd(),
...(input.repo === undefined ? {} : { repo: input.repo }),
...(input.repoType === undefined ? {} : { type: input.repoType }),
...(input.branch === undefined ? {} : { branch: input.branch }),
})
const agent = yield* client(defaults.token, deps)
const request = {
message: { prompt: input.prompt },
agent: { mode: defaults.mode, model: defaults.model },
repository,
options: {
createdOnPlatform: "kilo-cli",
...(defaults.organizationID ? { kilocodeOrganizationId: defaults.organizationID } : {}),
},
} satisfies AgentStartRequest
const result = yield* attempt(() => agent.start(request))
yield* print({ ...result, streamUrl: undefined }, deps, "start")
if (input.stream) {
yield* Effect.gen(function* () {
const streamUrl = yield* resolveStreamUrl(result, defaults, env, deps)
yield* Effect.tryPromise({
try: () =>
(deps.streamAgentEvents ?? streamAgentEvents)({
streamUrl,
origin: resolveCloudAgentOrigin(env),
writeLine: (line) => writeLine(line, deps),
WebSocket: globalThis.WebSocket,
}),
catch: (error) => (error instanceof CloudError ? error : new CloudError("Cloud Agent stream failed")),
})
}).pipe(Effect.catch((error) => notice(error, deps)))
}
return result
}),
)
})
export const send = Effect.fn("CloudCommands.send")(function* (input: SendInput, deps: Deps = {}) {
return yield* clean(
Effect.gen(function* () {
if (!CloudAgentSessionIdSchema.safeParse(input.sessionID).success) {
return yield* fail("Cloud Agent session ID is invalid")
}
if (!PromptSchema.safeParse(input.prompt).success) return yield* fail("Cloud Agent prompt is invalid")
const token = yield* auth(deps)
const agent = yield* client(token, deps)
const result = yield* attempt(() =>
agent.send({
cloudAgentSessionId: input.sessionID,
message: { prompt: input.prompt },
}),
)
yield* print({ ...result, streamUrl: undefined }, deps, "send")
return result
}),
)
})
export const status = Effect.fn("CloudCommands.status")(function* (input: LookupInput, deps: Deps = {}) {
return yield* clean(
Effect.gen(function* () {
const result = projectStatus(yield* lookup(input, deps))
yield* print(result, deps)
return result
}),
)
})
export const result = Effect.fn("CloudCommands.result")(function* (input: LookupInput, deps: Deps = {}) {
return yield* clean(
Effect.gen(function* () {
const result = yield* lookup(input, deps)
const code = resultExitCode(result.status)
yield* print(result, deps)
yield* Effect.sync(() => (deps.exit ?? ((value: AgentResultExitCode) => (process.exitCode = value)))(code))
return result
}),
)
})
function resolveStreamUrl(
response: AgentStartResponse,
defaults: CloudDefaults.Resolved,
env: CloudAuth.Environment,
deps: Deps,
): Effect.Effect<string, CloudError> {
if (response.streamUrl !== undefined) {
return Effect.succeed(response.streamUrl)
}
const origin = resolveWebAppOrigin(env)
const ticketClient = (deps.createStreamTicketClient ?? createStreamTicketClient)({
origin,
apiKey: Redacted.value(defaults.token),
})
return Effect.tryPromise({
try: () =>
ticketClient.fetchTicket({
cloudAgentSessionId: response.cloudAgentSessionId,
...(defaults.organizationID ? { organizationId: defaults.organizationID } : {}),
}),
catch: (error) => (error instanceof CloudError ? error : new CloudError("Unable to obtain stream ticket")),
}).pipe(
Effect.map((ticket) => {
const params = new URLSearchParams({
cloudAgentSessionId: response.cloudAgentSessionId,
ticket: ticket.ticket,
})
return `/stream?${params.toString()}`
}),
)
}
}
@@ -0,0 +1,194 @@
import z from "zod"
export const MessageIdSchema = z.string().regex(/^msg_[0-9a-f]{12}[0-9A-Za-z]{14}$/)
export const CloudAgentSessionIdSchema = z
.string()
.regex(/^agent_[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i)
const BranchSchema = z
.string()
.min(1)
.max(255)
.regex(/^[a-zA-Z0-9._\-/]+$/)
export const ModelSchema = z
.string()
.min(1)
.max(255)
.regex(/^[a-zA-Z0-9._\-/:]+$/)
export const ModeSchema = z
.string()
.min(1)
.max(50)
.regex(/^[a-z][a-z0-9-]*$/)
const GithubRepoSchema = z
.string()
.regex(/^[a-zA-Z0-9_.-]+\/[a-zA-Z0-9_.-]+$/)
.refine((value) => value.split("/").every((part) => part !== "." && part !== ".."))
const GitUrlSchema = z
.string()
.url()
.refine((url) => url.startsWith("https://"), "Only HTTPS URLs are supported")
export const RepositoryInputSchema = z.discriminatedUnion("type", [
z.object({ type: z.literal("github"), repo: GithubRepoSchema, branch: BranchSchema.optional() }).strict(),
z.object({ type: z.literal("gitlab"), url: GitUrlSchema, branch: BranchSchema.optional() }).strict(),
z
.object({
type: z.literal("git"),
url: GitUrlSchema,
token: z.string().optional(),
branch: BranchSchema.optional(),
})
.strict(),
])
export const PromptSchema = z.string().min(1).max(100_000)
const StartMessageSchema = z.object({ prompt: PromptSchema, id: MessageIdSchema.optional() }).strict()
const SendMessageSchema = z.object({ prompt: PromptSchema, id: MessageIdSchema.nullish() }).strict()
const AgentSchema = z
.object({
mode: ModeSchema,
model: ModelSchema,
variant: z
.string()
.max(50)
.regex(/^[a-zA-Z]+$/)
.optional(),
})
.strict()
export const AgentStartRequestSchema = z
.object({
message: StartMessageSchema,
agent: AgentSchema,
repository: RepositoryInputSchema,
options: z
.object({
createdOnPlatform: z.literal("kilo-cli"),
kilocodeOrganizationId: z.string().uuid().optional(),
})
.strict(),
})
.strict()
export const AgentSendRequestSchema = z
.object({ cloudAgentSessionId: CloudAgentSessionIdSchema, message: SendMessageSchema })
.strict()
export const GetMessageResultInputSchema = z
.object({ cloudAgentSessionId: CloudAgentSessionIdSchema, messageId: MessageIdSchema })
.strict()
export const AgentStartResponseSchema = z.object({
cloudAgentSessionId: CloudAgentSessionIdSchema,
kiloSessionId: z.string(),
messageId: MessageIdSchema,
delivery: z.string().min(1).max(50),
streamUrl: z.string().min(1).optional(),
wrapperRunId: z.string().optional(),
})
export const AgentSendResponseSchema = z.object({
cloudAgentSessionId: CloudAgentSessionIdSchema,
status: z.literal("started"),
streamUrl: z.string().min(1),
messageId: MessageIdSchema,
delivery: z.string().min(1).max(50),
wrapperRunId: z.string().optional(),
})
const FailureStageSchema = z.string().min(1).max(100)
const FailureCodeSchema = z.string().min(1).max(100)
const FailureSubtypeSchema = z.string().min(1).max(100)
export const SafeFailureSchema = z
.object({
stage: FailureStageSchema.optional(),
code: FailureCodeSchema.optional(),
subtype: FailureSubtypeSchema.optional(),
attempts: z.number().int().nonnegative().optional(),
message: z.string().min(1).max(4_096).optional(),
retryable: z.boolean(),
})
.refine((failure) => failure.subtype === undefined || failure.code === "workspace_setup_failed", {
message: "Workspace failure subtype requires workspace_setup_failed failure code",
path: ["subtype"],
})
export const GetMessageResultOutputSchema = z
.object({
cloudAgentSessionId: CloudAgentSessionIdSchema,
messageId: MessageIdSchema,
status: z.enum(["queued", "running", "completed", "failed", "interrupted"]),
createdAt: z.number(),
queuedAt: z.number().optional(),
acceptedAt: z.number().optional(),
terminalAt: z.number().optional(),
completionSource: z.string().min(1).max(100).optional(),
failure: SafeFailureSchema.optional(),
gateResult: z.string().min(1).max(50).optional(),
assistant: z.object({ messageId: z.string(), text: z.string().optional() }).optional(),
})
.superRefine((result, ctx) => {
const terminal = result.status === "completed" || result.status === "failed" || result.status === "interrupted"
if (result.status === "queued" && result.acceptedAt !== undefined) {
ctx.addIssue({ code: "custom", message: "Queued results cannot include acceptedAt", path: ["acceptedAt"] })
}
if (!terminal && result.terminalAt !== undefined) {
ctx.addIssue({ code: "custom", message: "Active results cannot include terminalAt", path: ["terminalAt"] })
}
if (!terminal && result.completionSource !== undefined) {
ctx.addIssue({
code: "custom",
message: "Active results cannot include completionSource",
path: ["completionSource"],
})
}
if (result.status !== "failed" && result.status !== "interrupted" && result.failure !== undefined) {
ctx.addIssue({
code: "custom",
message: "Only failed or interrupted results can include failure details",
path: ["failure"],
})
}
if (result.status !== "completed" && result.gateResult !== undefined) {
ctx.addIssue({ code: "custom", message: "Only completed results can include gateResult", path: ["gateResult"] })
}
if (result.status !== "completed" && result.assistant !== undefined) {
ctx.addIssue({
code: "custom",
message: "Only completed results can include an assistant response",
path: ["assistant"],
})
}
})
export type RepositoryInput = z.infer<typeof RepositoryInputSchema>
export type AgentStartRequest = z.infer<typeof AgentStartRequestSchema>
export type AgentSendRequest = z.infer<typeof AgentSendRequestSchema>
export type GetMessageResultInput = z.infer<typeof GetMessageResultInputSchema>
export type AgentStartResponse = z.infer<typeof AgentStartResponseSchema>
export type AgentSendResponse = z.infer<typeof AgentSendResponseSchema>
export type MessageResult = z.infer<typeof GetMessageResultOutputSchema>
export type AgentStatus = Omit<MessageResult, "assistant">
export type AgentResultExitCode = 0 | 2 | 3 | 4
export interface Decoder<T> {
parse(value: unknown): T
}
export function projectStatus(result: MessageResult): AgentStatus {
const { assistant: _, ...status } = result
return status
}
export function resultExitCode(status: MessageResult["status"]): AgentResultExitCode {
const codes = {
completed: 0,
queued: 2,
running: 2,
failed: 3,
interrupted: 4,
} as const
return codes[status]
}
@@ -0,0 +1,138 @@
import { Agent } from "@/agent/agent"
import { Config } from "@/config/config"
import { KilocodeModelState } from "@/kilocode/config/model-state"
import { Context, Effect, Layer, Schema } from "effect"
import { CloudAuth } from "./auth"
import { CloudCatalog } from "./catalog"
import { ModelSchema, ModeSchema } from "./contracts"
export namespace CloudDefaults {
const COMPATIBLE = new Set(["code", "plan", "debug", "orchestrator", "ask", "build", "architect"])
export type ModelStateInfo = KilocodeModelState.State
export interface ModelStateInterface {
readonly get: () => Effect.Effect<ModelStateInfo>
}
export class ModelState extends Context.Service<ModelState, ModelStateInterface>()("@kilocode/CloudModelState") {}
export const modelStateLayer = Layer.succeed(
ModelState,
ModelState.of({ get: () => Effect.promise(() => KilocodeModelState.get()) }),
)
export interface Input {
readonly env?: CloudAuth.Environment
readonly mode?: string
readonly model?: string
readonly orgID?: string
}
export interface Resolved extends CloudAuth.Resolved {
readonly mode: string
readonly model: string
}
export class ResolutionError extends Schema.TaggedErrorClass<ResolutionError>()("CloudDefaultsResolutionError", {
kind: Schema.Literals(["mode", "model"]),
message: Schema.String,
}) {}
const mode = Effect.fn("CloudDefaults.mode")(function* (value: string | undefined, fallback: string | undefined) {
const service = yield* Agent.Service
if (value !== undefined) {
if (!ModeSchema.safeParse(value).success || !COMPATIBLE.has(value)) {
return yield* Effect.fail(
new ResolutionError({
kind: "mode",
message: `Cloud Agent mode is unavailable: ${value}`,
}),
)
}
const info = yield* service.get(value)
if (info && (info.mode === "subagent" || info.hidden === true)) {
return yield* Effect.fail(
new ResolutionError({
kind: "mode",
message: `Cloud Agent mode is unavailable: ${value}`,
}),
)
}
return { name: value, info }
}
const info = yield* service.get(fallback ?? "code")
if (info && COMPATIBLE.has(info.name) && info.mode !== "subagent" && info.hidden !== true) {
return { name: info.name, info }
}
const base = yield* service.get("code")
if (base && (base.mode === "subagent" || base.hidden === true)) {
return yield* Effect.fail(
new ResolutionError({
kind: "mode",
message: "Cloud Agent mode is unavailable: code",
}),
)
}
return { name: "code", info: base }
})
function ref(value: { readonly providerID: string; readonly modelID: string } | undefined) {
return value ? `${value.providerID}/${value.modelID}` : undefined
}
function normalize(value: string) {
return value.startsWith("kilo/") ? value.slice("kilo/".length) : value
}
export const resolve = Effect.fn("CloudDefaults.resolve")(function* (input: Input = {}) {
const auth = yield* CloudAuth.resolve({ orgID: input.orgID, env: input.env })
const config = yield* Config.Service
const cfg = yield* config.get()
const selected = yield* mode(input.mode, cfg.default_agent ?? undefined)
const states = yield* ModelState
const saved = yield* states.get()
const catalog = yield* CloudCatalog.Service
const available = new Set(yield* catalog.models(auth))
if (input.model !== undefined) {
const explicit = normalize(input.model)
if (!ModelSchema.safeParse(explicit).success || !available.has(explicit)) {
return yield* Effect.fail(
new ResolutionError({
kind: "model",
message: `Cloud Agent model is unavailable: ${input.model}`,
}),
)
}
return { ...auth, mode: selected.name, model: explicit } satisfies Resolved
}
const candidates = [
ref(selected.info?.model),
ref(saved.model[selected.name]),
cfg.model ?? undefined,
...saved.recent.map(ref),
]
for (const value of candidates) {
if (!value) continue
const candidate = normalize(value)
if (!ModelSchema.safeParse(candidate).success) continue
if (!available.has(candidate)) continue
return { ...auth, mode: selected.name, model: candidate } satisfies Resolved
}
const fallback = normalize(yield* catalog.defaultModel(auth))
if (ModelSchema.safeParse(fallback).success && available.has(fallback)) {
return { ...auth, mode: selected.name, model: fallback } satisfies Resolved
}
return yield* Effect.fail(
new ResolutionError({
kind: "model",
message: "The Kilo model catalog has no available default model",
}),
)
})
}
@@ -0,0 +1,24 @@
export class CloudError extends Error {
constructor(message: string) {
super(message)
this.name = "CloudError"
}
}
export class ServiceTransportError extends CloudError {
constructor() {
super("Unable to reach configured service")
this.name = "ServiceTransportError"
}
}
export class ServiceRedirectError extends CloudError {
constructor() {
super("Service redirects are not allowed")
this.name = "ServiceRedirectError"
}
}
export function ambiguousAdmissionError(procedure: "start" | "send") {
return new CloudError(`Cloud Agent ${procedure} outcome is unknown; do not retry automatically`)
}
@@ -0,0 +1,67 @@
import { CloudError, ServiceRedirectError, ServiceTransportError } from "./errors"
import type { ServiceOrigin } from "./origin"
export const DEFAULT_HTTP_TIMEOUT_MS = 30_000
export interface BearerRequest {
readonly method: "GET" | "POST"
readonly path: `/${string}`
readonly headers?: Readonly<Record<string, string>>
readonly body?: BodyInit
}
export interface BearerHttpClient {
request(request: BearerRequest): Promise<Response>
}
export interface BearerHttpClientOptions {
readonly origin: ServiceOrigin
readonly apiKey: string
readonly fetch?: typeof globalThis.fetch
readonly timeoutMs?: number
}
export function createBearerHttpClient(options: BearerHttpClientOptions): BearerHttpClient {
const fetcher = options.fetch ?? globalThis.fetch
return {
async request(request) {
const url = resolve(options.origin, request.path)
const headers = new Headers(request.headers)
if (headers.has("authorization")) {
throw new CloudError("Authorization headers are managed by Kilo")
}
headers.set("authorization", `Bearer ${options.apiKey}`)
const response = await fetcher(url, {
method: request.method,
headers,
...(request.body === undefined ? {} : { body: request.body }),
redirect: "manual",
signal: AbortSignal.timeout(options.timeoutMs ?? DEFAULT_HTTP_TIMEOUT_MS),
}).catch(() => {
throw new ServiceTransportError()
})
if ((response.status >= 300 && response.status < 400) || response.redirected) {
await discard(response)
throw new ServiceRedirectError()
}
return response
},
}
}
async function discard(response: Response) {
await response.body?.cancel().catch(() => undefined)
}
function resolve(origin: ServiceOrigin, path: `/${string}`) {
if (!path.startsWith("/") || path.startsWith("//") || path.includes("\\") || path.includes("#")) {
throw new CloudError("Unsafe service request path")
}
const url = new URL(path, origin)
if (url.origin !== origin) throw new CloudError("Unsafe service request path")
return url
}
@@ -0,0 +1,11 @@
const ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"
const LENGTH = 14
export function createMessageId(now = Date.now()) {
const prefix = BigInt(now).toString(16).padStart(12, "0").slice(-12)
const suffix = Array.from(
crypto.getRandomValues(new Uint8Array(LENGTH)),
(byte) => ALPHABET[byte % ALPHABET.length],
).join("")
return `msg_${prefix}${suffix}`
}
@@ -0,0 +1,70 @@
import { CloudError } from "./errors"
export const DEFAULT_CLOUD_AGENT_ORIGIN = "https://cloud-agent-next.kilosessions.ai"
export const DEFAULT_WEB_APP_ORIGIN = "https://kilo.ai"
export type ServiceOrigin = string
export type CloudEnvironment = Readonly<Record<string, string | undefined>>
export interface ParseServiceOriginOptions {
readonly allowHttpLoopback?: boolean
}
export function parseServiceOrigin(value: string, options: ParseServiceOriginOptions = {}): ServiceOrigin {
if (!/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\/[^/?#\\]+\/?$/.test(value) || value.includes("%") || /\p{Cc}/u.test(value)) {
throw new CloudError("Service URL must contain only an origin")
}
const url = parse(value)
if (
url.username !== "" ||
url.password !== "" ||
url.hostname.endsWith(".") ||
(url.pathname !== "" && url.pathname !== "/") ||
url.search !== "" ||
url.hash !== ""
) {
throw new CloudError("Service URL must contain only an origin")
}
const secure = url.protocol === "https:"
const loopback = options.allowHttpLoopback === true && url.protocol === "http:" && isLoopback(url.hostname)
if (!secure && !loopback) {
throw new CloudError("Service URL must use HTTPS unless it is an explicit loopback override")
}
return url.origin
}
export function resolveCloudAgentOrigin(env: CloudEnvironment = process.env) {
const value = env.CLOUD_AGENT_NEXT_BASE_URL
return parseServiceOrigin(value ?? DEFAULT_CLOUD_AGENT_ORIGIN, {
allowHttpLoopback: value !== undefined,
})
}
export function resolveWebAppOrigin(env: CloudEnvironment = process.env) {
const value = env.KILO_WEB_APP_URL
return parseServiceOrigin(value ?? DEFAULT_WEB_APP_ORIGIN, {
allowHttpLoopback: value !== undefined,
})
}
function parse(value: string) {
try {
return new URL(value)
} catch {
throw new CloudError("Service URL must be a valid origin")
}
}
function isLoopback(host: string) {
if (host === "localhost" || host === "[::1]") return true
const parts = host.split(".")
return parts.length === 4 && parts[0] === "127" && parts.every(isPart)
}
function isPart(part: string) {
if (!/^(?:0|[1-9][0-9]{0,2})$/.test(part)) return false
return Number(part) <= 255
}
@@ -0,0 +1,265 @@
import { Effect, Schema } from "effect"
import { Git } from "@/git"
export namespace CloudRepository {
export const RepositoryType = Schema.Literals(["github", "gitlab", "git"])
export type RepositoryType = Schema.Schema.Type<typeof RepositoryType>
const GitHub = Schema.Struct({
type: Schema.Literal("github"),
repo: Schema.String,
branch: Schema.optional(Schema.String),
})
const GitLab = Schema.Struct({
type: Schema.Literal("gitlab"),
url: Schema.String,
branch: Schema.optional(Schema.String),
})
const GitRepository = Schema.Struct({
type: Schema.Literal("git"),
url: Schema.String,
branch: Schema.optional(Schema.String),
})
export const Output = Schema.Union([GitHub, GitLab, GitRepository])
export type Output = Schema.Schema.Type<typeof Output>
export type Input = {
readonly cwd: string
readonly repo?: string
readonly type?: RepositoryType
readonly branch?: string
}
export type ParseInput = {
readonly repo: string
readonly type?: RepositoryType
readonly branch?: string
}
export class InvalidRepositoryError extends Schema.TaggedErrorClass<InvalidRepositoryError>()(
"CloudRepositoryInvalidRepositoryError",
{ message: Schema.String },
) {}
export class InvalidBranchError extends Schema.TaggedErrorClass<InvalidBranchError>()(
"CloudRepositoryInvalidBranchError",
{ message: Schema.String },
) {}
export class NotWorktreeError extends Schema.TaggedErrorClass<NotWorktreeError>()("CloudRepositoryNotWorktreeError", {
message: Schema.String,
}) {}
export class NoRemoteError extends Schema.TaggedErrorClass<NoRemoteError>()("CloudRepositoryNoRemoteError", {
message: Schema.String,
}) {}
export class AmbiguousRemoteError extends Schema.TaggedErrorClass<AmbiguousRemoteError>()(
"CloudRepositoryAmbiguousRemoteError",
{ message: Schema.String },
) {}
export class DiscoveryError extends Schema.TaggedErrorClass<DiscoveryError>()("CloudRepositoryDiscoveryError", {
message: Schema.String,
}) {}
export type ParseError = InvalidRepositoryError | InvalidBranchError
export type Error = ParseError | NotWorktreeError | NoRemoteError | AmbiguousRemoteError | DiscoveryError
const shorthand = /^[a-zA-Z0-9_.-]+\/[a-zA-Z0-9_.-]+$/
const branch = /^[a-zA-Z0-9._\-/]+$/
function invalid(message: string): never {
throw new InvalidRepositoryError({ message })
}
function validate(value: string | undefined): string | undefined {
if (value === undefined) return undefined
if (
value.length === 0 ||
value.length > 255 ||
!branch.test(value) ||
value.startsWith("-") ||
value.startsWith("/") ||
value.endsWith("/") ||
value.endsWith(".") ||
value.includes("//") ||
value.includes("..") ||
value.split("/").some((part) => part.startsWith(".") || part.endsWith(".lock"))
) {
throw new InvalidBranchError({ message: "Repository branch must be a valid Git branch name" })
}
return value
}
function github(path: string) {
const clean = path.startsWith("/") ? path.slice(1) : path
const trimmed = clean.endsWith("/") ? clean.slice(0, -1) : clean
const parts = trimmed.split("/")
if (parts.length !== 2) return invalid("GitHub repository URL must contain exactly owner/repository")
const owner = parts[0]
const raw = parts[1]
if (!owner || !raw) return invalid("GitHub repository URL must contain exactly owner/repository")
const repo = raw.endsWith(".git") ? raw.slice(0, -4) : raw
const value = `${owner}/${repo}`
if (!shorthand.test(value) || [owner, repo].some((part) => part === "." || part === "..")) {
return invalid("GitHub repository URL must contain a safe owner/repository value")
}
return value
}
function ssh(value: string): string | undefined {
if (value.startsWith("ssh://")) {
if (!URL.canParse(value)) return invalid("Repository SSH URL is invalid")
const url = new URL(value)
if (
url.protocol !== "ssh:" ||
url.hostname !== "github.com" ||
url.hostname.endsWith(".") ||
url.username !== "git" ||
url.password !== "" ||
url.port !== ""
) {
return invalid("Only standard GitHub SSH repository URLs are supported")
}
return github(url.pathname)
}
const match = value.match(/^([^@/\s]+)@([^:/\s]+):(.+)$/)
if (!match) return undefined
const host = match[2].toLowerCase()
if (match[1] !== "git" || host !== "github.com" || host.endsWith(".")) {
return invalid("Only standard GitHub SCP repository URLs are supported")
}
return github(match[3])
}
function https(value: string) {
if (!value.startsWith("https://") || !URL.canParse(value)) {
return invalid("Repository URL must be a valid HTTPS URL")
}
const url = new URL(value)
if (
url.protocol !== "https:" ||
url.username !== "" ||
url.password !== "" ||
url.hostname.endsWith(".") ||
url.search !== "" ||
url.hash !== ""
) {
return invalid("Repository URL must not include credentials, query strings, or fragments")
}
return url
}
function parsed(input: ParseInput) {
return Effect.try({
try: () => parse(input),
catch: (error) => {
if (error instanceof InvalidRepositoryError || error instanceof InvalidBranchError) return error
return new InvalidRepositoryError({ message: "Repository is invalid" })
},
})
}
export function parse(input: ParseInput): Output {
const ref = input.repo
const name = validate(input.branch)
const short = shorthand.test(ref) ? ref : undefined
if (short) {
const parts = short.split("/")
if (parts.some((part) => part === "." || part === "..")) {
return invalid("GitHub shorthand must contain a safe owner/repository value")
}
if (input.type !== undefined && input.type !== "github") {
return invalid("GitHub shorthand is only compatible with repository type github")
}
return { type: "github", repo: short, ...(name === undefined ? {} : { branch: name }) }
}
const secure = ssh(ref)
if (secure !== undefined) {
if (input.type !== undefined && input.type !== "github") {
return invalid("GitHub SSH repositories are only compatible with repository type github")
}
return { type: "github", repo: secure, ...(name === undefined ? {} : { branch: name }) }
}
const url = https(ref)
const host = url.hostname.toLowerCase()
if (host === "github.com") {
if (input.type !== undefined && input.type !== "github") {
return invalid("github.com URLs are only compatible with repository type github")
}
if (url.port !== "") return invalid("Repository type github requires a standard github.com URL")
return { type: "github", repo: github(url.pathname), ...(name === undefined ? {} : { branch: name }) }
}
if (input.type === "github") return invalid("Repository type github requires a standard github.com URL")
const type = input.type ?? (host === "gitlab.com" ? "gitlab" : "git")
return { type, url: url.toString(), ...(name === undefined ? {} : { branch: name }) }
}
export const resolve = Effect.fn("CloudRepository.resolve")(function* (input: Input) {
if (input.repo !== undefined) {
return yield* parsed({
repo: input.repo,
...(input.type === undefined ? {} : { type: input.type }),
...(input.branch === undefined ? {} : { branch: input.branch }),
})
}
const git = yield* Git.Service
const tree = yield* git.run(["rev-parse", "--is-inside-work-tree"], { cwd: input.cwd })
if (tree.exitCode !== 0 || tree.text().trim() !== "true") {
return yield* Effect.fail(new NotWorktreeError({ message: "Current directory is not inside a Git worktree" }))
}
const listed = yield* git.run(["remote"], { cwd: input.cwd })
if (listed.exitCode !== 0) {
return yield* Effect.fail(new DiscoveryError({ message: "Unable to inspect Git remotes" }))
}
const remotes = listed
.text()
.split(/\r?\n/)
.map((item) => item.trim())
.filter(Boolean)
if (remotes.length === 0) {
return yield* Effect.fail(new NoRemoteError({ message: "Current Git worktree has no remotes" }))
}
const current = yield* git.branch(input.cwd)
const tracking = yield* Effect.gen(function* () {
if (!current) return undefined
const result = yield* git.run(["config", "--get", `branch.${current}.remote`], { cwd: input.cwd })
if (result.exitCode !== 0) return undefined
const name = result.text().trim()
if (!remotes.includes(name)) return undefined
return name
})
const remote = tracking ?? (remotes.includes("origin") ? "origin" : remotes.length === 1 ? remotes[0] : undefined)
if (!remote) {
return yield* Effect.fail(
new AmbiguousRemoteError({ message: "Current Git worktree has multiple remotes and none can be selected" }),
)
}
const fetched = yield* git.run(["remote", "get-url", remote], { cwd: input.cwd })
const text = fetched.text()
const ref = text.endsWith("\r\n") ? text.slice(0, -2) : text.endsWith("\n") ? text.slice(0, -1) : text
if (fetched.exitCode !== 0 || !ref) {
return yield* Effect.fail(new DiscoveryError({ message: "Unable to read the selected Git remote fetch URL" }))
}
if (shorthand.test(ref)) {
return yield* Effect.fail(new InvalidRepositoryError({ message: "Local repository remotes are not supported" }))
}
return yield* parsed({
repo: ref,
...(input.type === undefined ? {} : { type: input.type }),
...(input.branch === undefined ? {} : { branch: input.branch }),
})
})
}
@@ -0,0 +1,45 @@
export const MAX_CLOUD_AGENT_RESPONSE_BYTES = 5 * 1024 * 1024
export class ResponseJsonError extends Error {
constructor(message: string) {
super(message)
this.name = "ResponseJsonError"
}
}
export async function readBoundedJson(response: Response, max = MAX_CLOUD_AGENT_RESPONSE_BYTES): Promise<unknown> {
const length = response.headers.get("content-length")
if (length !== null && /^\d+$/.test(length) && Number(length) > max) {
await response.body?.cancel().catch(() => undefined)
throw new ResponseJsonError("JSON response exceeds the configured limit")
}
if (response.body === null) throw new ResponseJsonError("JSON response body is missing")
const reader = response.body.getReader()
const chunks: Uint8Array[] = []
let size = 0
while (true) {
const chunk = await reader.read()
if (chunk.done) break
size += chunk.value.byteLength
if (size > max) {
await reader.cancel().catch(() => undefined)
throw new ResponseJsonError("JSON response exceeds the configured limit")
}
chunks.push(chunk.value)
}
const bytes = new Uint8Array(size)
let offset = 0
for (const chunk of chunks) {
bytes.set(chunk, offset)
offset += chunk.byteLength
}
try {
const text = new TextDecoder("utf-8", { fatal: true }).decode(bytes)
return JSON.parse(text) as unknown
} catch {
throw new ResponseJsonError("JSON response is invalid")
}
}
@@ -0,0 +1,96 @@
import z from "zod"
import { CloudError } from "./errors"
import { type ServiceOrigin } from "./origin"
import { readBoundedJson } from "./response-json"
const MAX_STREAM_TICKET_RESPONSE_BYTES = 64 * 1024
const STREAM_TICKET_TIMEOUT_MS = 30_000
const TICKET_RETRY_ATTEMPTS = 10
const TICKET_RETRY_DELAY_MS = 1000
const StreamTicketResponseSchema = z.object({
ticket: z.string().min(1),
expiresAt: z.number(),
})
export interface StreamTicket {
readonly ticket: string
readonly expiresAt: number
}
export interface StreamTicketClient {
fetchTicket(input: { readonly cloudAgentSessionId: string; readonly organizationId?: string }): Promise<StreamTicket>
}
export interface CreateStreamTicketClientOptions {
readonly origin: ServiceOrigin
readonly apiKey: string
readonly fetch?: typeof globalThis.fetch
}
export function createStreamTicketClient(options: CreateStreamTicketClientOptions): StreamTicketClient {
const fetcher = options.fetch ?? globalThis.fetch
return {
async fetchTicket(input) {
const url = new URL("/api/cloud-agent-next/sessions/stream-ticket", options.origin)
const body = JSON.stringify({
cloudAgentSessionId: input.cloudAgentSessionId,
...(input.organizationId === undefined ? {} : { organizationId: input.organizationId }),
})
let last: Error | undefined
for (let attempt = 0; attempt < TICKET_RETRY_ATTEMPTS; attempt++) {
if (attempt > 0) await delay(TICKET_RETRY_DELAY_MS)
let response: Response
try {
response = await fetcher(url, {
method: "POST",
redirect: "error",
headers: {
"content-type": "application/json",
authorization: `Bearer ${options.apiKey}`,
},
body,
signal: AbortSignal.timeout(STREAM_TICKET_TIMEOUT_MS),
})
} catch {
throw new CloudError("Unable to reach Web App stream ticket endpoint")
}
const payload = await readBoundedJson(response, MAX_STREAM_TICKET_RESPONSE_BYTES).catch(() => {
if (response.status === 403 || response.status === 404) return undefined
throw new CloudError("Web App returned an invalid stream ticket response")
})
if (response.ok) {
const parsed = StreamTicketResponseSchema.safeParse(payload)
if (!parsed.success) throw new CloudError("Web App returned an invalid stream ticket response")
return { ticket: parsed.data.ticket, expiresAt: parsed.data.expiresAt }
}
last = new CloudError(messageForStatus(response.status, payload))
if (response.status !== 403 && response.status !== 404) throw last
}
throw last ?? new CloudError("Unable to obtain stream ticket")
},
}
}
function messageForStatus(status: number, payload: unknown): string {
const server =
typeof payload === "object" && payload !== null && "error" in payload && typeof payload.error === "string"
? payload.error
: undefined
if (status === 401) return server ?? "Web App rejected authentication"
if (status === 403) return server ?? "Web App denied stream ticket access"
if (status === 404) return server ?? "Web App session was not found"
return server ?? `Web App rejected the stream ticket request with status ${status}`
}
function delay(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms))
}
@@ -0,0 +1,158 @@
import z from "zod"
import {
AgentSendRequestSchema,
AgentSendResponseSchema,
AgentStartRequestSchema,
AgentStartResponseSchema,
GetMessageResultInputSchema,
GetMessageResultOutputSchema,
type AgentSendRequest,
type AgentSendResponse,
type AgentStartRequest,
type AgentStartResponse,
type Decoder,
type GetMessageResultInput,
type MessageResult,
} from "./contracts"
import { ambiguousAdmissionError, CloudError, ServiceRedirectError, ServiceTransportError } from "./errors"
import { createBearerHttpClient, type BearerHttpClient, type BearerHttpClientOptions, type BearerRequest } from "./http"
import { createMessageId } from "./message-id"
import { readBoundedJson } from "./response-json"
const TrpcSuccessEnvelopeSchema = z.object({ result: z.object({ data: z.unknown() }).strict() }).strict()
type Admission = "start" | "send"
export interface TrpcClient {
query<T>(procedure: "getMessageResult", input: unknown, decoder: Decoder<T>): Promise<T>
mutation<T>(procedure: Admission, input: unknown, decoder: Decoder<T>): Promise<T>
}
export interface AgentClient {
start(input: AgentStartRequest): Promise<AgentStartResponse>
send(input: AgentSendRequest): Promise<AgentSendResponse>
getMessageResult(input: GetMessageResultInput): Promise<MessageResult>
}
export interface AgentClientOptions {
readonly id?: () => string
}
export interface CloudAgentClientOptions extends BearerHttpClientOptions, AgentClientOptions {}
export function createTrpcClient(options: { readonly http: BearerHttpClient }): TrpcClient {
return {
query(procedure, input, decoder) {
return request(
options.http,
{
method: "GET",
path: `/trpc/${procedure}?input=${encodeURIComponent(JSON.stringify(input))}`,
},
decoder,
)
},
mutation(procedure, input, decoder) {
return request(
options.http,
{
method: "POST",
path: `/trpc/${procedure}`,
headers: { "content-type": "application/json" },
body: JSON.stringify(input),
},
decoder,
procedure,
)
},
}
}
export function createAgentClient(trpc: TrpcClient, options: AgentClientOptions = {}): AgentClient {
const create = options.id ?? createMessageId
return {
async start(input) {
const parsed = decode(AgentStartRequestSchema, input, "Cloud Agent start request is invalid")
const id = parsed.message.id ?? create()
const body = { ...parsed, message: { ...parsed.message, id } }
const result = await trpc.mutation("start", body, AgentStartResponseSchema)
if (result.messageId !== id) throw ambiguousAdmissionError("start")
return result
},
async send(input) {
const parsed = decode(AgentSendRequestSchema, input, "Cloud Agent send request is invalid")
const id = parsed.message.id ?? create()
const body = { ...parsed, message: { ...parsed.message, id } }
const result = await trpc.mutation("send", body, AgentSendResponseSchema)
if (result.cloudAgentSessionId !== parsed.cloudAgentSessionId || result.messageId !== id) {
throw ambiguousAdmissionError("send")
}
return result
},
async getMessageResult(input) {
const parsed = decode(GetMessageResultInputSchema, input, "Cloud Agent lookup request is invalid")
const result = await trpc.query("getMessageResult", parsed, GetMessageResultOutputSchema)
if (result.cloudAgentSessionId !== parsed.cloudAgentSessionId || result.messageId !== parsed.messageId) {
throw new CloudError("Cloud Agent returned an invalid response")
}
return result
},
}
}
export function createCloudAgentClient(options: CloudAgentClientOptions) {
const http = createBearerHttpClient(options)
return createAgentClient(createTrpcClient({ http }), options)
}
function decode<T>(schema: z.ZodType<T>, input: unknown, message: string) {
const result = schema.safeParse(input)
if (!result.success) throw new CloudError(message)
return result.data
}
async function request<T>(
http: BearerHttpClient,
req: BearerRequest,
decoder: Decoder<T>,
admission?: Admission,
): Promise<T> {
const response = await http.request(req).catch((error: unknown) => {
if (admission !== undefined && (error instanceof ServiceTransportError || error instanceof ServiceRedirectError)) {
throw ambiguousAdmissionError(admission)
}
throw error
})
if (!response.ok) {
await discard(response)
if (admission !== undefined && response.status >= 500) throw ambiguousAdmissionError(admission)
throw new CloudError(errorMessageForStatus(response.status))
}
const payload = await readBoundedJson(response).catch(() => {
if (admission !== undefined) throw ambiguousAdmissionError(admission)
throw new CloudError("Cloud Agent returned an invalid response")
})
try {
const envelope = TrpcSuccessEnvelopeSchema.parse(payload)
return decoder.parse(envelope.result.data)
} catch {
if (admission !== undefined) throw ambiguousAdmissionError(admission)
throw new CloudError("Cloud Agent returned an invalid response")
}
}
async function discard(response: Response) {
await response.body?.cancel().catch(() => undefined)
}
function errorMessageForStatus(status: number) {
if (status === 401) return "Cloud Agent rejected authentication"
if (status === 402) return "Cloud Agent requires additional balance"
if (status === 403) return "Cloud Agent denied the request"
if (status === 404) return "Cloud Agent session or message was not found"
if (status >= 500) return "Cloud Agent is temporarily unavailable"
return `Cloud Agent rejected the request with status ${status}`
}
@@ -0,0 +1,194 @@
import { CloudError } from "./errors"
const COMPLETE_GRACE_PERIOD_MS = 3000
const DEFAULT_STREAM_TIMEOUT_MS = 30_000
const CLOSE_TIMEOUT_MS = 1000
const DRAIN_TIMEOUT_MS = 1000
// Measured via text.length (UTF-16 units), so queued string memory can reach
// roughly twice this for non-Latin-1 payloads; the goal is boundedness, not precision.
const MAX_QUEUED_BYTES = 8 * 1024 * 1024
export interface StreamAgentEventsOptions {
readonly streamUrl: string
readonly origin: string
readonly writeLine: (line: string) => void | Promise<void>
readonly WebSocket?: typeof WebSocket | undefined
readonly timeoutMs?: number
}
export function streamAgentEvents(options: StreamAgentEventsOptions): Promise<void> {
const resolved = (() => {
try {
return { url: resolveWebSocketUrl(options.streamUrl, options.origin) }
} catch (error) {
return { error }
}
})()
if ("error" in resolved) return Promise.reject(resolved.error)
const url = resolved.url
const WebSocketImpl = options.WebSocket ?? globalThis.WebSocket
return new Promise((resolve, reject) => {
const socket = new WebSocketImpl(url)
let settled = false
let completeTimer: ReturnType<typeof setTimeout> | undefined
let closeTimer: ReturnType<typeof setTimeout> | undefined
let idleTimer: ReturnType<typeof setTimeout> | undefined
let pending: Promise<void> = Promise.resolve()
let queued = 0
let writeError = false
let aborted = false
function clear() {
if (completeTimer !== undefined) {
clearTimeout(completeTimer)
completeTimer = undefined
}
if (closeTimer !== undefined) {
clearTimeout(closeTimer)
closeTimer = undefined
}
if (idleTimer !== undefined) {
clearTimeout(idleTimer)
idleTimer = undefined
}
}
function finish() {
clear()
if (settled) return
settled = true
void pending.then(() => {
if (writeError) {
reject(new CloudError("WebSocket stream output failed"))
return
}
resolve()
})
}
function fail(message: string) {
clear()
if (settled) return
settled = true
socket.close()
const timeout = Math.min(options.timeoutMs ?? DRAIN_TIMEOUT_MS, DRAIN_TIMEOUT_MS)
const timer = setTimeout(() => {
aborted = true
reject(new CloudError(writeError ? "WebSocket stream output failed" : message))
}, timeout)
void pending.then(() => {
clearTimeout(timer)
reject(new CloudError(writeError ? "WebSocket stream output failed" : message))
})
}
function abort(message: string) {
aborted = true
clear()
if (settled) return
settled = true
socket.close()
reject(new CloudError(message))
}
function arm() {
if (idleTimer !== undefined) clearTimeout(idleTimer)
idleTimer = setTimeout(() => fail("WebSocket stream timed out"), options.timeoutMs ?? DEFAULT_STREAM_TIMEOUT_MS)
}
function initiateClose() {
if (settled) return
try {
socket.close(1000)
} catch {
finish()
return
}
closeTimer = setTimeout(finish, CLOSE_TIMEOUT_MS)
}
arm()
socket.onmessage = (event: MessageEvent) => {
arm()
if (settled) return
const text = normalizeMessageData(event.data)
if (queued >= MAX_QUEUED_BYTES) {
abort("WebSocket stream output consumer is too slow")
return
}
queued += text.length
pending = pending
.then(() => {
if (aborted) return
return options.writeLine(text)
})
.catch(() => {
writeError = true
abort("WebSocket stream output failed")
})
.finally(() => {
queued -= text.length
})
if (isCompleteEvent(text) && completeTimer === undefined) {
completeTimer = setTimeout(initiateClose, COMPLETE_GRACE_PERIOD_MS)
}
}
socket.onerror = () => {
fail("WebSocket stream connection failed")
}
socket.onclose = (event) => {
if (event.code === 1000) return finish()
fail(`WebSocket stream closed unexpectedly (${event.code})`)
}
})
}
function resolveWebSocketUrl(streamUrl: string, origin: string): string {
let url: URL
try {
url = /^(?:wss?|https?):\/\//i.test(streamUrl) ? new URL(streamUrl) : new URL(streamUrl, origin)
} catch {
throw new CloudError("Invalid stream URL")
}
if (url.protocol === "http:") {
url.protocol = "ws:"
} else if (url.protocol === "https:") {
url.protocol = "wss:"
}
if (url.protocol !== "ws:" && url.protocol !== "wss:") {
throw new CloudError("Invalid stream URL protocol")
}
const expected = new URL(origin)
if (expected.protocol === "http:") {
expected.protocol = "ws:"
} else if (expected.protocol === "https:") {
expected.protocol = "wss:"
}
if (url.origin !== expected.origin) {
throw new CloudError("Invalid stream URL origin")
}
return url.toString()
}
function isCompleteEvent(text: string): boolean {
try {
const parsed = JSON.parse(text)
return typeof parsed === "object" && parsed !== null && parsed.streamEventType === "complete"
} catch {
return false
}
}
function normalizeMessageData(data: unknown): string {
if (typeof data === "string") return data
if (data instanceof ArrayBuffer || ArrayBuffer.isView(data)) return new TextDecoder().decode(data)
return String(data)
}
+4 -2
View File
@@ -8,7 +8,7 @@ import { AttachCommand } from "../cli/cmd/attach"
import { RunCommand } from "../cli/cmd/run"
import { GenerateCommand } from "../cli/cmd/generate"
import { DebugCommand } from "../cli/cmd/debug"
import { ProvidersCommand } from "../cli/cmd/providers" // kilocode_change — upstream renamed auth → providers
import { ProvidersCommand } from "../cli/cmd/providers"
import { AgentCommand } from "../cli/cmd/agent"
import { UpgradeCommand } from "../cli/cmd/upgrade"
import { UninstallCommand } from "../cli/cmd/uninstall"
@@ -30,6 +30,7 @@ import { RollCallCommand } from "./cli/cmd/roll-call"
import { ProfileCommand } from "./cli/cmd/profile"
import { DaemonCommand } from "./cli/cmd/daemon"
import { KiloConsoleCommand } from "./cli/cmd/console"
import { CloudCommand } from "./cli/cmd/cloud"
import { HelpCommand } from "./help-command"
import { InstallationBuildKind } from "@opencode-ai/core/installation/version"
@@ -55,7 +56,7 @@ export const commands = [
RunCommand,
GenerateCommand,
DebugCommand,
ProvidersCommand, // kilocode_change — upstream renamed AuthCommand → ProvidersCommand
ProvidersCommand,
AgentCommand,
UpgradeCommand,
UninstallCommand,
@@ -73,6 +74,7 @@ export const commands = [
RemoteCommand,
DaemonCommand,
KiloConsoleCommand,
CloudCommand,
DbCommand,
ConfigCLICommand,
...dev,
@@ -73,6 +73,7 @@ mock.module("@/kilocode/help-command", () => ({
for (const path of [
"@/kilocode/cli/cmd/console",
"@/kilocode/cli/cmd/cloud",
"@/kilocode/cli/cmd/roll-call",
"@/kilocode/cli/cmd/profile",
"@/kilocode/cli/cmd/daemon",
@@ -82,6 +83,7 @@ for (const path of [
]) {
mock.module(path, () => ({
KiloConsoleCommand: { command: "console", handler() {} },
CloudCommand: { command: "cloud", handler() {} },
RollCallCommand: { command: "roll-call", handler() {} },
ProfileCommand: { command: "profile", handler() {} },
DaemonCommand: { command: "daemon", handler() {} },
@@ -0,0 +1,540 @@
import { expect } from "bun:test"
import { Agent } from "@/agent/agent"
import { Auth } from "@/auth"
import { Config } from "@/config/config"
import type { AgentSendRequest, AgentStartRequest, MessageResult } from "@/kilocode/cloud/contracts"
import { CloudCommands } from "@/kilocode/cloud/commands"
import { CloudError } from "@/kilocode/cloud/errors"
import { Git } from "@/git"
import { Effect, Layer } from "effect"
import { TestInstance } from "../../fixture/fixture"
import { testEffect } from "../../lib/effect"
const SESSION = "agent_12345678-1234-1234-1234-123456789abc"
const MESSAGE = "msg_018f1e2d3c4bAbCdEfGhIjKlMn"
const TOKEN = "command-test-token"
const ORG = "11111111-1111-4111-8111-111111111111"
const auth = Layer.mock(Auth.Service)({
get: (id) =>
Effect.succeed(
id === "kilo"
? new Auth.Oauth({
type: "oauth",
access: TOKEN,
refresh: "test-refresh",
expires: Date.now() + 60_000,
accountId: ORG,
})
: undefined,
),
})
const it = testEffect(Layer.mergeAll(Agent.defaultLayer, Config.defaultLayer, Git.defaultLayer, auth))
const run = Effect.fn("CloudCommandTest.git")(function* (cwd: string, ...args: string[]) {
const git = yield* Git.Service
const result = yield* git.run(args, { cwd })
if (result.exitCode === 0) return
yield* Effect.die(new Error(result.stderr.toString("utf8")))
})
it.instance(
"assembles the default start request from Kilo state and the current repository",
() =>
Effect.acquireUseRelease(
Effect.sync(() =>
Bun.serve({
hostname: "127.0.0.1",
port: 0,
fetch(request) {
const url = new URL(request.url)
if (url.pathname.endsWith("/models")) {
return Response.json({ data: [{ id: "anthropic/command-model", supported_parameters: ["tools"] }] })
}
if (url.pathname.endsWith("/defaults")) {
return Response.json({ defaultModel: "anthropic/command-model" })
}
return new Response(null, { status: 404 })
},
}),
),
(server) =>
Effect.gen(function* () {
const test = yield* TestInstance
yield* run(test.directory, "remote", "add", "origin", "git@github.com:Kilo-Org/kilocode.git")
const requests: AgentStartRequest[] = []
const keys: string[] = []
const output: string[] = []
const response = {
cloudAgentSessionId: SESSION,
kiloSessionId: "ses_command_test",
messageId: MESSAGE,
delivery: "queued" as const,
}
const result = yield* CloudCommands.start(
{
cwd: test.directory,
prompt: "Inspect the current repository",
},
{
env: { KILO_API_URL: server.url.origin },
make: (options) => {
keys.push(options.apiKey)
return {
async start(input) {
requests.push(input)
return response
},
async send() {
throw new Error("unused send")
},
async getMessageResult() {
throw new Error("unused result")
},
}
},
write: (text) => output.push(text),
},
)
expect(result).toEqual(response)
expect(keys).toEqual([TOKEN])
expect(requests).toHaveLength(1)
expect(requests[0]).toEqual({
message: { prompt: "Inspect the current repository" },
agent: { mode: "plan", model: "anthropic/command-model" },
repository: { type: "github", repo: "Kilo-Org/kilocode" },
options: {
createdOnPlatform: "kilo-cli",
kilocodeOrganizationId: ORG,
},
})
expect(requests[0]?.repository).not.toHaveProperty("branch")
expect(output).toEqual([JSON.stringify(response) + "\n"])
}),
(server) => Effect.promise(() => server.stop(true)),
),
{
git: true,
config: {
default_agent: "plan",
agent: { plan: { model: "kilo/anthropic/command-model" } },
},
},
)
it.instance(
"streams WebSocket events when --stream is passed",
() =>
Effect.acquireUseRelease(
Effect.sync(() =>
Bun.serve({
hostname: "127.0.0.1",
port: 0,
fetch(request) {
const url = new URL(request.url)
if (url.pathname.endsWith("/models")) {
return Response.json({ data: [{ id: "anthropic/command-model", supported_parameters: ["tools"] }] })
}
if (url.pathname.endsWith("/defaults")) {
return Response.json({ defaultModel: "anthropic/command-model" })
}
return new Response(null, { status: 404 })
},
}),
),
(server) =>
Effect.gen(function* () {
const test = yield* TestInstance
const output: string[] = []
const ticketCalls: { cloudAgentSessionId: string; organizationId?: string }[] = []
const streamCalls: string[] = []
const response = {
cloudAgentSessionId: SESSION,
kiloSessionId: "ses_stream_test",
messageId: MESSAGE,
delivery: "queued" as const,
streamUrl: "/stream?cloudAgentSessionId=agent_123&ticket=inlined",
}
const result = yield* CloudCommands.start(
{
cwd: test.directory,
prompt: "Inspect the repository",
repo: "Kilo-Org/kilocode",
stream: true,
},
{
env: { KILO_API_URL: server.url.origin },
make: () => ({
async start() {
return response
},
async send() {
throw new Error("unused")
},
async getMessageResult() {
throw new Error("unused")
},
}),
createStreamTicketClient: () => ({
async fetchTicket(input) {
ticketCalls.push(input)
return { ticket: "should-not-be-used", expiresAt: 0 }
},
}),
streamAgentEvents: async (options) => {
streamCalls.push(options.streamUrl)
await options.writeLine('{"event":"one"}')
await options.writeLine('{"streamEventType":"complete","data":{"exitCode":0}}')
},
write: (text) => output.push(text),
},
)
expect(result).toEqual(response)
expect(ticketCalls).toEqual([])
expect(streamCalls).toEqual(["/stream?cloudAgentSessionId=agent_123&ticket=inlined"])
expect(output).toEqual([
JSON.stringify({ ...response, streamUrl: undefined }) + "\n",
'{"event":"one"}\n',
'{"streamEventType":"complete","data":{"exitCode":0}}\n',
])
}),
(server) => Effect.promise(() => server.stop(true)),
),
{
git: true,
config: {
default_agent: "plan",
agent: { plan: { model: "kilo/anthropic/command-model" } },
},
},
)
it.instance(
"fetches a stream ticket when the response omits streamUrl",
() =>
Effect.acquireUseRelease(
Effect.sync(() =>
Bun.serve({
hostname: "127.0.0.1",
port: 0,
fetch(request) {
const url = new URL(request.url)
if (url.pathname.endsWith("/models")) {
return Response.json({ data: [{ id: "anthropic/command-model", supported_parameters: ["tools"] }] })
}
if (url.pathname.endsWith("/defaults")) {
return Response.json({ defaultModel: "anthropic/command-model" })
}
return new Response(null, { status: 404 })
},
}),
),
(server) =>
Effect.gen(function* () {
const test = yield* TestInstance
const output: string[] = []
const ticketCalls: { cloudAgentSessionId: string; organizationId?: string }[] = []
const streamCalls: string[] = []
const response = {
cloudAgentSessionId: SESSION,
kiloSessionId: "ses_stream_test",
messageId: MESSAGE,
delivery: "queued" as const,
}
yield* CloudCommands.start(
{
cwd: test.directory,
prompt: "Inspect the repository",
repo: "Kilo-Org/kilocode",
orgID: ORG,
stream: true,
},
{
env: { KILO_API_URL: server.url.origin },
make: () => ({
async start() {
return response
},
async send() {
throw new Error("unused")
},
async getMessageResult() {
throw new Error("unused")
},
}),
createStreamTicketClient: () => ({
async fetchTicket(input) {
ticketCalls.push(input)
return { ticket: "derived-tok", expiresAt: 1234567890 }
},
}),
streamAgentEvents: async (options) => {
streamCalls.push(options.streamUrl)
await options.writeLine('{"event":"derived"}')
},
write: (text) => output.push(text),
},
)
expect(ticketCalls).toEqual([{ cloudAgentSessionId: SESSION, organizationId: ORG }])
expect(streamCalls).toEqual([`/stream?cloudAgentSessionId=${SESSION}&ticket=derived-tok`])
expect(output).toEqual([JSON.stringify(response) + "\n", '{"event":"derived"}\n'])
}),
(server) => Effect.promise(() => server.stop(true)),
),
{
git: true,
config: {
default_agent: "plan",
agent: { plan: { model: "kilo/anthropic/command-model" } },
},
},
)
it.instance(
"keeps admission successful when stream ticket acquisition fails",
() =>
Effect.acquireUseRelease(
Effect.sync(() =>
Bun.serve({
hostname: "127.0.0.1",
port: 0,
fetch(request) {
const url = new URL(request.url)
if (url.pathname.endsWith("/models")) {
return Response.json({ data: [{ id: "anthropic/command-model", supported_parameters: ["tools"] }] })
}
if (url.pathname.endsWith("/defaults")) {
return Response.json({ defaultModel: "anthropic/command-model" })
}
return new Response(null, { status: 404 })
},
}),
),
(server) =>
Effect.gen(function* () {
const test = yield* TestInstance
const output: string[] = []
const response = {
cloudAgentSessionId: SESSION,
kiloSessionId: "ses_stream_test",
messageId: MESSAGE,
delivery: "queued" as const,
}
const result = yield* CloudCommands.start(
{
cwd: test.directory,
prompt: "Inspect the repository",
repo: "Kilo-Org/kilocode",
stream: true,
},
{
env: { KILO_API_URL: server.url.origin },
make: () => ({
async start() {
return response
},
async send() {
throw new Error("unused")
},
async getMessageResult() {
throw new Error("unused")
},
}),
createStreamTicketClient: () => ({
async fetchTicket() {
throw new CloudError("Unable to obtain stream ticket")
},
}),
streamAgentEvents: async () => {
throw new Error("unused")
},
write: (text) => output.push(text),
},
)
expect(result).toEqual(response)
expect(output).toEqual([
JSON.stringify(response) + "\n",
JSON.stringify({ streamEventType: "error", data: { message: "Unable to obtain stream ticket" } }) + "\n",
])
}),
(server) => Effect.promise(() => server.stop(true)),
),
{
git: true,
config: {
default_agent: "plan",
agent: { plan: { model: "kilo/anthropic/command-model" } },
},
},
)
it.instance(
"keeps a successful admission successful when the follow-up stream fails",
() =>
Effect.acquireUseRelease(
Effect.sync(() =>
Bun.serve({
hostname: "127.0.0.1",
port: 0,
fetch(request) {
const url = new URL(request.url)
if (url.pathname.endsWith("/models")) {
return Response.json({ data: [{ id: "anthropic/command-model", supported_parameters: ["tools"] }] })
}
if (url.pathname.endsWith("/defaults")) {
return Response.json({ defaultModel: "anthropic/command-model" })
}
return new Response(null, { status: 404 })
},
}),
),
(server) =>
Effect.gen(function* () {
const test = yield* TestInstance
const output: string[] = []
const response = {
cloudAgentSessionId: SESSION,
kiloSessionId: "ses_stream_test",
messageId: MESSAGE,
delivery: "queued" as const,
streamUrl: "/stream?cloudAgentSessionId=agent_123&ticket=inlined",
}
const result = yield* CloudCommands.start(
{
cwd: test.directory,
prompt: "Inspect the repository",
repo: "Kilo-Org/kilocode",
stream: true,
},
{
env: { KILO_API_URL: server.url.origin },
make: () => ({
async start() {
return response
},
async send() {
throw new Error("unused")
},
async getMessageResult() {
throw new Error("unused")
},
}),
streamAgentEvents: async () => {
throw new Error("stream failed after admission")
},
write: (text) => output.push(text),
},
)
expect(result).toEqual(response)
expect(output).toEqual([
JSON.stringify({ ...response, streamUrl: undefined }) + "\n",
JSON.stringify({ streamEventType: "error", data: { message: "Cloud Agent stream failed" } }) + "\n",
])
}),
(server) => Effect.promise(() => server.stop(true)),
),
{
git: true,
config: {
default_agent: "plan",
agent: { plan: { model: "kilo/anthropic/command-model" } },
},
},
)
it.instance("sends follow-ups, prints status without assistant content, and applies the result exit code", () =>
Effect.gen(function* () {
const output: string[] = []
const exits: number[] = []
const sends: AgentSendRequest[] = []
const sent = {
cloudAgentSessionId: SESSION,
status: "started" as const,
streamUrl: "wss://cloud-agent.example/stream",
messageId: MESSAGE,
delivery: "queued" as const,
}
const results: MessageResult[] = [
{
cloudAgentSessionId: SESSION,
messageId: MESSAGE,
status: "completed",
createdAt: 1,
terminalAt: 2,
assistant: { messageId: "assistant_1", text: "done" },
},
{
cloudAgentSessionId: SESSION,
messageId: MESSAGE,
status: "failed",
createdAt: 1,
terminalAt: 2,
failure: { retryable: false },
},
]
const deps = {
env: { KILO_ORG_ID: "not-relevant-to-existing-sessions" },
make: () => ({
async start() {
throw new Error("unused start")
},
async send(input: AgentSendRequest) {
sends.push(input)
return sent
},
async getMessageResult() {
const result = results.shift()
if (!result) throw new Error("missing test result")
return result
},
}),
write: (text: string) => output.push(text),
exit: (code: number) => exits.push(code),
}
yield* CloudCommands.send({ sessionID: SESSION, prompt: "Continue" }, deps)
yield* CloudCommands.status({ sessionID: SESSION, messageID: MESSAGE }, deps)
yield* CloudCommands.result({ sessionID: SESSION, messageID: MESSAGE }, deps)
expect(sends).toEqual([{ cloudAgentSessionId: SESSION, message: { prompt: "Continue" } }])
expect(output).toEqual([
JSON.stringify({ ...sent, streamUrl: undefined }) + "\n",
JSON.stringify({
cloudAgentSessionId: SESSION,
messageId: MESSAGE,
status: "completed",
createdAt: 1,
terminalAt: 2,
}) + "\n",
JSON.stringify({
cloudAgentSessionId: SESSION,
messageId: MESSAGE,
status: "failed",
createdAt: 1,
terminalAt: 2,
failure: { retryable: false },
}) + "\n",
])
expect(exits).toEqual([3])
const error = yield* CloudCommands.send(
{ sessionID: SESSION, prompt: "Do not duplicate" },
{ ...deps, write: () => Promise.reject(new Error("closed output")) },
).pipe(Effect.flip)
expect(error.message).toBe(
"Cloud Agent send was admitted but output could not be written; do not retry automatically",
)
}),
)
@@ -0,0 +1,436 @@
import { expect } from "bun:test"
import { Effect, Layer, Redacted, Ref } from "effect"
import { Agent } from "@/agent/agent"
import { Auth } from "@/auth"
import { Config } from "@/config/config"
import { CloudAuth } from "@/kilocode/cloud/auth"
import { CloudCatalog } from "@/kilocode/cloud/catalog"
import { CloudDefaults } from "@/kilocode/cloud/defaults"
import { MAX_CLOUD_AGENT_RESPONSE_BYTES } from "@/kilocode/cloud/response-json"
import { testEffect } from "../../lib/effect"
const it = testEffect(Layer.mergeAll(Agent.defaultLayer, Config.defaultLayer))
type RequestInfo = {
readonly authorization: string | null
readonly feature: string | null
readonly organization: string | null
readonly path: string
}
const oauth = (token: string, organizationID: string) =>
new Auth.Oauth({
type: "oauth",
access: token,
refresh: "test-refresh",
expires: Date.now() + 60_000,
accountId: organizationID,
})
const authLayer = (info: Auth.Info) =>
Layer.mock(Auth.Service)({
get: (id) => Effect.succeed(id === "kilo" ? info : undefined),
})
const stateLayer = (state: CloudDefaults.ModelStateInfo) =>
Layer.mock(CloudDefaults.ModelState)({
get: () => Effect.succeed(state),
})
const state = (input: Partial<CloudDefaults.ModelStateInfo> = {}): CloudDefaults.ModelStateInfo => ({
model: {},
recent: [],
favorite: [],
variant: {},
...input,
})
function withCatalog<A, E, R>(
models: readonly string[],
defaultModel: string,
use: (url: URL, requests: RequestInfo[]) => Effect.Effect<A, E, R>,
) {
const requests: RequestInfo[] = []
return Effect.acquireUseRelease(
Effect.sync(() =>
Bun.serve({
hostname: "127.0.0.1",
port: 0,
fetch(request) {
const url = new URL(request.url)
requests.push({
authorization: request.headers.get("authorization"),
feature: request.headers.get("x-kilocode-feature"),
organization: request.headers.get("x-kilocode-organizationid"),
path: url.pathname,
})
if (url.pathname.endsWith("/models")) {
return Response.json({ data: models.map((id) => ({ id, supported_parameters: ["tools"] })) })
}
if (url.pathname.endsWith("/defaults")) return Response.json({ defaultModel })
return new Response(null, { status: 404 })
},
}),
),
(server) => use(server.url, requests),
(server) => Effect.promise(() => server.stop(true)),
)
}
it.instance("routes URL-scoped credentials to their catalog origin", () => {
const token = "https://catalog.example.test:scoped-token"
const requests: RequestInfo[] = []
return Effect.gen(function* () {
const catalog = yield* CloudCatalog.Service
expect(yield* catalog.models({ token: Redacted.make(token) })).toEqual(["anthropic/scoped"])
expect(requests).toEqual([
{
authorization: `Bearer ${token}`,
feature: "kilo-cli",
organization: null,
path: "/api/openrouter/models",
},
])
}).pipe(
Effect.provide(
CloudCatalog.layer({
env: {},
fetch: async (request) => {
const url = new URL(request.url)
expect(url.origin).toBe("https://catalog.example.test")
requests.push({
authorization: request.headers.get("authorization"),
feature: request.headers.get("x-kilocode-feature"),
organization: request.headers.get("x-kilocode-organizationid"),
path: url.pathname,
})
return Response.json({ data: [{ id: "anthropic/scoped", supported_parameters: ["tools"] }] })
},
}),
),
)
})
it.instance("returns only tool-capable text-output models", () =>
Effect.gen(function* () {
const catalog = yield* CloudCatalog.Service
const models = yield* catalog.models({ token: Redacted.make("stored-token") })
expect(models).toEqual(["anthropic/code"])
}).pipe(
Effect.provide(
CloudCatalog.layer({
fetch: async () =>
Response.json({
data: [
{
id: "anthropic/code",
architecture: { output_modalities: ["text"] },
supported_parameters: ["tools"],
},
{
id: "image/generator",
architecture: { output_modalities: ["image"] },
supported_parameters: ["tools"],
},
{
id: "anthropic/chat",
architecture: { output_modalities: ["text"] },
supported_parameters: ["temperature"],
},
{ id: "anthropic/unknown" },
],
}),
}),
),
),
)
it.instance("rejects oversized catalog responses", () =>
Effect.gen(function* () {
const catalog = yield* CloudCatalog.Service
const error = yield* catalog.models({ token: Redacted.make("stored-token") }).pipe(Effect.flip)
expect(error).toMatchObject({ _tag: "CloudCatalogError", kind: "schema" })
}).pipe(
Effect.provide(
CloudCatalog.layer({
fetch: async () =>
Response.json({ data: [] }, { headers: { "content-length": String(MAX_CLOUD_AGENT_RESPONSE_BYTES + 1) } }),
}),
),
),
)
it.instance(
"explicit overrides beat environment and saved defaults without persisting",
() =>
withCatalog(["anthropic/explicit", "anthropic/default"], "anthropic/default", (url, requests) =>
Effect.gen(function* () {
const savedID = "22222222-2222-4222-8222-222222222222"
const envID = "33333333-3333-4333-8333-333333333333"
const explicitID = "44444444-4444-4444-8444-444444444444"
const stored = oauth("stored-token", savedID)
const current = yield* Ref.make<Auth.Info>(stored)
const auth = Layer.mock(Auth.Service)({
get: (id) => (id === "kilo" ? Ref.get(current) : Effect.succeed(undefined)),
set: (id, info) => (id === "kilo" ? Ref.set(current, info) : Effect.void),
})
const resolved = yield* Effect.gen(function* () {
const result = yield* CloudDefaults.resolve({
mode: "debug",
model: "kilo/anthropic/explicit",
orgID: explicitID,
env: {
KILO_API_KEY: "ignored-env-token",
KILO_ORG_ID: envID,
},
})
const service = yield* Auth.Service
expect(yield* service.get("kilo")).toEqual(stored)
return result
}).pipe(
Effect.provide(
Layer.mergeAll(
auth,
stateLayer(
state({
model: { debug: { providerID: "kilo", modelID: "anthropic/saved" } },
}),
),
CloudCatalog.layer({ env: { KILO_API_URL: url.origin } }),
),
),
)
expect(resolved).toMatchObject({
mode: "debug",
model: "anthropic/explicit",
organizationID: explicitID,
})
expect(requests.map((request) => request.path)).toEqual([`/api/organizations/${explicitID}/models`])
expect(
requests.every(
(request) =>
request.path.startsWith(`/api/organizations/${explicitID}/`) &&
request.authorization === "Bearer stored-token" &&
request.feature === "kilo-cli" &&
request.organization === explicitID,
),
).toBe(true)
}),
),
{
config: {
default_agent: "plan",
model: "kilo/anthropic/repository",
agent: { plan: { model: "kilo/anthropic/mode" } },
},
},
)
it.instance(
"skips a stale saved model and uses the available repository model",
() =>
withCatalog(
["anthropic/repository", "anthropic/recent", "anthropic/default"],
"anthropic/default",
(url, requests) =>
Effect.gen(function* () {
const resolved = yield* CloudDefaults.resolve()
expect(resolved.mode).toBe("code")
expect(resolved.model).toBe("anthropic/repository")
expect(requests.map((request) => request.path)).toEqual(["/api/openrouter/models"])
expect(requests.every((request) => request.organization === null)).toBe(true)
}).pipe(
Effect.provide(
Layer.mergeAll(
authLayer(new Auth.Api({ type: "api", key: "stored-api-token" })),
stateLayer(
state({
model: { code: { providerID: "kilo", modelID: "anthropic/stale" } },
recent: [{ providerID: "kilo", modelID: "anthropic/recent" }],
}),
),
CloudCatalog.layer({ env: { KILO_API_URL: url.origin } }),
),
),
),
),
{
config: {
model: "kilo/anthropic/repository",
agent: { code: { model: null } },
},
},
)
it.instance(
"uses the saved model for the resolved mode when it remains available",
() =>
withCatalog(["anthropic/saved", "anthropic/default"], "anthropic/default", (url) =>
CloudDefaults.resolve().pipe(
Effect.tap((resolved) =>
Effect.sync(() => {
expect(resolved.mode).toBe("code")
expect(resolved.model).toBe("anthropic/saved")
}),
),
Effect.provide(
Layer.mergeAll(
authLayer(new Auth.Api({ type: "api", key: "stored-api-token" })),
stateLayer(
state({
model: { code: { providerID: "kilo", modelID: "anthropic/saved" } },
}),
),
CloudCatalog.layer({ env: { KILO_API_URL: url.origin } }),
),
),
),
),
{
config: { agent: { code: { model: null } } },
},
)
it.instance(
"fetches the catalog default only when configured and saved candidates are unavailable",
() =>
withCatalog(["anthropic/default"], "anthropic/default", (url, requests) =>
CloudDefaults.resolve().pipe(
Effect.tap((resolved) =>
Effect.sync(() => {
expect(resolved.model).toBe("anthropic/default")
expect(requests.map((request) => request.path)).toEqual(["/api/openrouter/models", "/api/defaults"])
}),
),
Effect.provide(
Layer.mergeAll(
authLayer(new Auth.Api({ type: "api", key: "stored-api-token" })),
stateLayer(state()),
CloudCatalog.layer({ env: { KILO_API_URL: url.origin } }),
),
),
),
),
{
config: { agent: { code: { model: null } } },
},
)
it.instance(
"falls back from an inferred custom mode but rejects an explicit custom mode",
() =>
withCatalog(["anthropic/code", "anthropic/custom", "anthropic/default"], "anthropic/default", (url, requests) =>
Effect.gen(function* () {
const inferred = yield* CloudDefaults.resolve()
expect(inferred.mode).toBe("code")
expect(inferred.model).toBe("anthropic/code")
const error = yield* CloudDefaults.resolve({ mode: "custom" }).pipe(Effect.flip)
expect(error).toMatchObject({
_tag: "CloudDefaultsResolutionError",
kind: "mode",
})
expect(requests).toHaveLength(1)
}).pipe(
Effect.provide(
Layer.mergeAll(
authLayer(new Auth.Api({ type: "api", key: "stored-api-token" })),
stateLayer(state()),
CloudCatalog.layer({ env: { KILO_API_URL: url.origin } }),
),
),
),
),
{
config: {
default_agent: "custom",
agent: {
code: { model: "kilo/anthropic/code" },
custom: { mode: "primary", model: "kilo/anthropic/custom" },
},
},
},
)
it.instance("rejects invalid persisted organization state and insecure catalog origins", () =>
Effect.gen(function* () {
const invalid = authLayer(oauth("stored-token", "not-a-uuid"))
const token = yield* CloudAuth.token().pipe(Effect.provide(invalid))
expect(Redacted.value(token)).toBe("stored-token")
const org = yield* CloudAuth.resolve().pipe(Effect.provide(invalid), Effect.flip)
expect(org).toMatchObject({
_tag: "CloudAuthResolutionError",
kind: "organization",
})
const catalog = yield* CloudDefaults.resolve({ env: { KILO_API_URL: "http://example.com" } }).pipe(
Effect.provide(
Layer.mergeAll(
authLayer(new Auth.Api({ type: "api", key: "stored-api-token" })),
stateLayer(state()),
CloudCatalog.layer({
env: { KILO_API_URL: "http://example.com" },
fetch: () => Promise.reject(new Error("insecure catalog request must not run")),
}),
),
),
Effect.flip,
)
expect(catalog).toMatchObject({
_tag: "CloudCatalogError",
kind: "schema",
})
}),
)
it.instance(
"uses stored auth and the resolved mode model before lower-precedence defaults",
() =>
withCatalog(
["anthropic/mode", "anthropic/saved", "anthropic/repository", "anthropic/recent", "anthropic/default"],
"anthropic/default",
(url, requests) =>
Effect.gen(function* () {
const organizationID = "11111111-1111-4111-8111-111111111111"
const resolved = yield* CloudDefaults.resolve({
env: { KILO_API_KEY: "ignored-env-token" },
}).pipe(
Effect.provide(
Layer.mergeAll(
authLayer(oauth("stored-token", organizationID)),
stateLayer(
state({
model: { plan: { providerID: "kilo", modelID: "anthropic/saved" } },
recent: [{ providerID: "kilo", modelID: "anthropic/recent" }],
}),
),
CloudCatalog.layer({ env: { KILO_API_URL: url.origin } }),
),
),
)
expect(resolved).toMatchObject({
mode: "plan",
model: "anthropic/mode",
organizationID,
})
expect(requests.map((request) => request.path)).toEqual([`/api/organizations/${organizationID}/models`])
expect(
requests.every(
(request) => request.authorization === "Bearer stored-token" && request.organization === organizationID,
),
).toBe(true)
}),
),
{
config: {
default_agent: "plan",
model: "kilo/anthropic/repository",
agent: { plan: { model: "kilo/anthropic/mode" } },
},
},
)
@@ -0,0 +1,119 @@
import { describe, expect } from "bun:test"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { Effect, Layer } from "effect"
import { Git } from "../../../src/git"
import { CloudRepository } from "../../../src/kilocode/cloud/repository"
import { tmpdirScoped } from "../../fixture/fixture"
import { testEffect } from "../../lib/effect"
const it = testEffect(Layer.mergeAll(Git.defaultLayer, CrossSpawnSpawner.defaultLayer))
const run = Effect.fn("CloudRepositoryTest.git")(function* (cwd: string, ...args: string[]) {
const git = yield* Git.Service
const result = yield* git.run(args, { cwd })
if (result.exitCode === 0) return result.text().trim()
return yield* Effect.die(new Error(result.stderr.toString("utf8")))
})
describe("CloudRepository", () => {
it.live("resolves and validates an explicit repository branch outside Git", () =>
Effect.gen(function* () {
const cwd = yield* tmpdirScoped()
const result = yield* CloudRepository.resolve({
cwd,
repo: "kilo-org/kilo",
branch: "feature/cloud-start",
})
expect(result).toEqual({
type: "github",
repo: "kilo-org/kilo",
branch: "feature/cloud-start",
})
const error = yield* CloudRepository.resolve({ cwd, repo: "kilo-org/kilo", branch: "feature.lock" }).pipe(
Effect.flip,
)
expect(error).toBeInstanceOf(CloudRepository.InvalidBranchError)
const type = yield* CloudRepository.resolve({
cwd,
repo: "https://github.com/kilo-org/kilo.git",
type: "gitlab",
}).pipe(Effect.flip)
expect(type).toBeInstanceOf(CloudRepository.InvalidRepositoryError)
}),
)
it.live("rejects GitHub repositories that become dot segments after trimming .git", () =>
Effect.gen(function* () {
const cwd = yield* tmpdirScoped()
for (const repo of ["https://github.com/kilo-org/...git", "git@github.com:kilo-org/...git"]) {
const error = yield* CloudRepository.resolve({ cwd, repo }).pipe(Effect.flip)
expect(error).toBeInstanceOf(CloudRepository.InvalidRepositoryError)
}
}),
)
it.live("normalizes an inferred GitHub SCP remote without adding the current branch", () =>
Effect.gen(function* () {
const cwd = yield* tmpdirScoped({ git: true })
yield* run(cwd, "remote", "add", "origin", "git@github.com:kilo-org/ssh-repo.git")
yield* run(cwd, "checkout", "-b", "feature/not-in-output")
const result = yield* CloudRepository.resolve({ cwd })
expect(result).toEqual({ type: "github", repo: "kilo-org/ssh-repo" })
}),
)
it.live("rejects inferred local remotes that resemble GitHub shorthand", () =>
Effect.gen(function* () {
const cwd = yield* tmpdirScoped({ git: true })
yield* run(cwd, "remote", "add", "origin", "kilo-org/local-repo")
const error = yield* CloudRepository.resolve({ cwd }).pipe(Effect.flip)
expect(error).toBeInstanceOf(CloudRepository.InvalidRepositoryError)
}),
)
it.live("prefers the tracking remote, then origin, then a sole remote fetch URL", () =>
Effect.gen(function* () {
const cwd = yield* tmpdirScoped({ git: true })
const git = yield* Git.Service
const branch = yield* git.branch(cwd)
if (!branch) yield* Effect.die(new Error("temporary repository has no current branch"))
yield* run(cwd, "remote", "add", "origin", "https://github.com/kilo-org/origin.git")
yield* run(cwd, "remote", "add", "tracked", "https://github.com/kilo-org/tracked.git")
yield* run(cwd, "remote", "set-url", "--add", "--push", "tracked", "https://github.com/kilo-org/push.git")
yield* run(cwd, "config", `branch.${branch}.remote`, "tracked")
expect(yield* CloudRepository.resolve({ cwd })).toEqual({ type: "github", repo: "kilo-org/tracked" })
yield* run(cwd, "config", `branch.${branch}.remote`, "missing")
expect(yield* CloudRepository.resolve({ cwd })).toEqual({ type: "github", repo: "kilo-org/origin" })
yield* run(cwd, "remote", "remove", "origin")
expect(yield* CloudRepository.resolve({ cwd })).toEqual({ type: "github", repo: "kilo-org/tracked" })
}),
)
it.live("returns typed errors when no remote can be selected", () =>
Effect.gen(function* () {
const cwd = yield* tmpdirScoped({ git: true })
const none = yield* CloudRepository.resolve({ cwd }).pipe(Effect.flip)
expect(none).toBeInstanceOf(CloudRepository.NoRemoteError)
yield* run(cwd, "remote", "add", "alpha", "https://github.com/kilo-org/alpha.git")
yield* run(cwd, "remote", "add", "beta", "https://github.com/kilo-org/beta.git")
const ambiguous = yield* CloudRepository.resolve({ cwd }).pipe(Effect.flip)
expect(ambiguous).toBeInstanceOf(CloudRepository.AmbiguousRemoteError)
}),
)
})
@@ -0,0 +1,134 @@
import { describe, expect, test } from "bun:test"
import { createStreamTicketClient, type StreamTicketClient } from "@/kilocode/cloud/stream-ticket"
import { parseServiceOrigin } from "@/kilocode/cloud/origin"
function jsonResponse(body: unknown, status = 200) {
return new Response(JSON.stringify(body), {
status,
headers: { "content-type": "application/json" },
})
}
function client(options: { fetch: ReturnType<typeof mockFetch> }): StreamTicketClient {
return createStreamTicketClient({
origin: parseServiceOrigin("https://app.example"),
apiKey: "key",
fetch: options.fetch.fetch,
})
}
describe("createStreamTicketClient", () => {
test("fetches a stream ticket from the web app", async () => {
const fetch = mockFetch().resolved(jsonResponse({ ticket: "tok", expiresAt: 1234567890 }))
const result = await client({ fetch }).fetchTicket({ cloudAgentSessionId: "agent_123" })
expect(result).toEqual({ ticket: "tok", expiresAt: 1234567890 })
expect(fetch.calls).toHaveLength(1)
const [url, init] = fetch.calls[0]!
expect(url.toString()).toBe("https://app.example/api/cloud-agent-next/sessions/stream-ticket")
expect(init).toMatchObject({
method: "POST",
redirect: "error",
headers: expect.objectContaining({
authorization: "Bearer key",
"content-type": "application/json",
}),
body: JSON.stringify({ cloudAgentSessionId: "agent_123" }),
})
expect(init?.signal).toBeInstanceOf(AbortSignal)
})
test("includes organizationId when provided", async () => {
const fetch = mockFetch().resolved(jsonResponse({ ticket: "tok", expiresAt: 1234567890 }))
await client({ fetch }).fetchTicket({
cloudAgentSessionId: "agent_123",
organizationId: "123e4567-e89b-12d3-a456-426614174000",
})
expect(fetch.calls[0]![1]).toMatchObject({
body: JSON.stringify({
cloudAgentSessionId: "agent_123",
organizationId: "123e4567-e89b-12d3-a456-426614174000",
}),
})
})
test("throws on transport failure", async () => {
const fetch = mockFetch().rejected(new Error("network error"))
await expect(client({ fetch }).fetchTicket({ cloudAgentSessionId: "agent_123" })).rejects.toThrow(
"Unable to reach Web App stream ticket endpoint",
)
})
test("retries on 403/404 and succeeds once the session becomes visible", async () => {
const fetch = mockFetch()
.resolved(jsonResponse({ error: "Organization does not own this session" }, 403))
.resolved(jsonResponse({ error: "Organization does not own this session" }, 403))
.resolved(jsonResponse({ ticket: "tok", expiresAt: 1234567890 }))
const result = await client({ fetch }).fetchTicket({ cloudAgentSessionId: "agent_123" })
expect(result).toEqual({ ticket: "tok", expiresAt: 1234567890 })
expect(fetch.calls).toHaveLength(3)
})
test("retries when a 404 response has an invalid body", async () => {
const fetch = mockFetch()
.resolved(new Response("not json", { status: 404 }))
.resolved(jsonResponse({ ticket: "tok", expiresAt: 1234567890 }))
const result = await client({ fetch }).fetchTicket({ cloudAgentSessionId: "agent_123" })
expect(result).toEqual({ ticket: "tok", expiresAt: 1234567890 })
expect(fetch.calls).toHaveLength(2)
})
test("gives up after repeated 403 responses", async () => {
const fetch = mockFetch().repeated(() => jsonResponse({ error: "Denied" }, 403))
await expect(client({ fetch }).fetchTicket({ cloudAgentSessionId: "agent_123" })).rejects.toThrow("Denied")
expect(fetch.calls).toHaveLength(10)
}, 15_000)
test("throws on authentication failure", async () => {
const fetch = mockFetch().resolved(jsonResponse({ error: "Unauthorized" }, 401))
await expect(client({ fetch }).fetchTicket({ cloudAgentSessionId: "agent_123" })).rejects.toThrow("Unauthorized")
})
test("throws on invalid response", async () => {
const fetch = mockFetch().resolved(jsonResponse({ missing: "fields" }))
await expect(client({ fetch }).fetchTicket({ cloudAgentSessionId: "agent_123" })).rejects.toThrow(
"Web App returned an invalid stream ticket response",
)
})
})
function mockFetch() {
const calls: [URL | RequestInfo, RequestInit | undefined][] = []
const sequence: (() => Response | Promise<Response>)[] = []
let fallback: (() => Response | Promise<Response>) | undefined
const self = {
calls,
resolved(value: Response) {
sequence.push(() => value)
return self
},
repeated(factory: () => Response) {
fallback = factory
return self
},
rejected(error: unknown) {
sequence.push(() => Promise.reject(error))
return self
},
get fetch(): typeof fetch {
return ((input: URL | RequestInfo, init?: RequestInit) => {
calls.push([input, init])
const next = sequence.shift() ?? fallback
if (!next) throw new Error("unexpected fetch call")
return Promise.resolve(next())
}) as typeof fetch
},
}
return self
}
@@ -0,0 +1,260 @@
import { describe, expect, test } from "bun:test"
import {
AgentSendRequestSchema,
AgentStartRequestSchema,
GetMessageResultInputSchema,
MessageIdSchema,
} from "../../../src/kilocode/cloud/contracts"
import { parseServiceOrigin } from "../../../src/kilocode/cloud/origin"
import { MAX_CLOUD_AGENT_RESPONSE_BYTES } from "../../../src/kilocode/cloud/response-json"
import { createCloudAgentClient } from "../../../src/kilocode/cloud/trpc"
const SESSION = "agent_12345678-1234-1234-1234-123456789abc"
const OTHER_SESSION = "agent_abcdefab-cdef-4abc-8def-abcdefabcdef"
const MESSAGE = "msg_018f1e2d3c4bAbCdEfGhIjKlMn"
const OTHER_MESSAGE = "msg_018f1e2d3c4bZyXwVuTsRqPoNm"
const SESSION_MESSAGE = "msg_018f1e2d3c4bQrStUvWxYzAbCd"
const TOKEN = "secret-bearer-value"
type Seen = {
readonly url: string
readonly auth: string | null
readonly body: string
}
function success(data: unknown) {
return Response.json({ result: { data } })
}
describe("Cloud Agent transport", () => {
test("places bearer auth only in headers and correlates generated message identities", async () => {
const seen: Seen[] = []
const ids: string[] = []
const server = Bun.serve({
hostname: "127.0.0.1",
port: 0,
async fetch(request) {
const url = new URL(request.url)
const body = request.method === "POST" ? await request.text() : ""
seen.push({ url: url.toString(), auth: request.headers.get("authorization"), body })
if (url.pathname === "/trpc/start") {
const input = AgentStartRequestSchema.parse(JSON.parse(body) as unknown)
const id = MessageIdSchema.parse(input.message.id)
ids.push(id)
return success({
cloudAgentSessionId: input.message.prompt === "invalid-session" ? "invalid" : SESSION,
kiloSessionId: "ses_123",
messageId: input.message.prompt === "mismatch" ? OTHER_MESSAGE : id,
delivery: "queued",
})
}
if (url.pathname === "/trpc/send") {
const input = AgentSendRequestSchema.parse(JSON.parse(body) as unknown)
const id = MessageIdSchema.parse(input.message.id)
ids.push(id)
return success({
cloudAgentSessionId:
input.message.prompt === "mismatch-session" ? OTHER_SESSION : input.cloudAgentSessionId,
status: "started",
streamUrl: "wss://cloud-agent.example/stream",
messageId: input.message.prompt === "mismatch-send" ? OTHER_MESSAGE : id,
delivery: "queued",
})
}
if (url.pathname === "/trpc/getMessageResult") {
const raw = url.searchParams.get("input")
const input = GetMessageResultInputSchema.parse(JSON.parse(raw ?? "null") as unknown)
return success({
cloudAgentSessionId: input.messageId === SESSION_MESSAGE ? OTHER_SESSION : input.cloudAgentSessionId,
messageId: input.messageId === OTHER_MESSAGE ? MESSAGE : input.messageId,
status: "failed",
createdAt: 1,
terminalAt: 2,
completionSource: "delivery_failure",
failure: {
stage: "pre_dispatch",
code: "workspace_setup_failed",
subtype: "git_clone_timeout",
attempts: 1,
message: "Repository clone timed out",
retryable: true,
},
})
}
return new Response(null, { status: 404 })
},
})
try {
const agent = createCloudAgentClient({
origin: parseServiceOrigin(server.url.origin, { allowHttpLoopback: true }),
apiKey: TOKEN,
})
const start = await agent.start({
message: { prompt: "Inspect the repository" },
agent: { mode: "code", model: "anthropic/claude-sonnet-4" },
repository: { type: "github", repo: "Kilo-Org/kilocode" },
options: { createdOnPlatform: "kilo-cli" },
})
const send = await agent.send({
cloudAgentSessionId: start.cloudAgentSessionId,
message: { prompt: "Continue" },
})
const result = await agent.getMessageResult({
cloudAgentSessionId: send.cloudAgentSessionId,
messageId: send.messageId,
})
expect(MessageIdSchema.safeParse(start.messageId).success).toBe(true)
expect(MessageIdSchema.safeParse(send.messageId).success).toBe(true)
expect(ids).toEqual([start.messageId, send.messageId])
expect(result.failure).toEqual({
stage: "pre_dispatch",
code: "workspace_setup_failed",
subtype: "git_clone_timeout",
attempts: 1,
message: "Repository clone timed out",
retryable: true,
})
expect(seen).toHaveLength(3)
expect(seen.every((request) => request.auth === `Bearer ${TOKEN}`)).toBe(true)
expect(seen.every((request) => !request.url.includes(TOKEN) && !request.body.includes(TOKEN))).toBe(true)
const error = await agent
.start({
message: { prompt: "mismatch" },
agent: { mode: "code", model: "anthropic/claude-sonnet-4" },
repository: { type: "github", repo: "Kilo-Org/kilocode" },
options: { createdOnPlatform: "kilo-cli" },
})
.then(
() => new Error("Expected start correlation to fail"),
(cause: unknown) => (cause instanceof Error ? cause : new Error("Non-error rejection")),
)
expect(error.message).toBe("Cloud Agent start outcome is unknown; do not retry automatically")
expect(error.message).not.toContain(TOKEN)
const malformed = await agent
.start({
message: { prompt: "invalid-session" },
agent: { mode: "code", model: "anthropic/claude-sonnet-4" },
repository: { type: "github", repo: "Kilo-Org/kilocode" },
options: { createdOnPlatform: "kilo-cli" },
})
.then(
() => new Error("Expected invalid session ID to fail"),
(cause: unknown) => (cause instanceof Error ? cause : new Error("Non-error rejection")),
)
expect(malformed.message).toBe("Cloud Agent start outcome is unknown; do not retry automatically")
const sendError = await agent.send({ cloudAgentSessionId: SESSION, message: { prompt: "mismatch-send" } }).then(
() => new Error("Expected send correlation to fail"),
(cause: unknown) => (cause instanceof Error ? cause : new Error("Non-error rejection")),
)
expect(sendError.message).toBe("Cloud Agent send outcome is unknown; do not retry automatically")
const resultError = await agent.getMessageResult({ cloudAgentSessionId: SESSION, messageId: OTHER_MESSAGE }).then(
() => new Error("Expected result correlation to fail"),
(cause: unknown) => (cause instanceof Error ? cause : new Error("Non-error rejection")),
)
expect(resultError.message).toBe("Cloud Agent returned an invalid response")
const sendSessionError = await agent
.send({ cloudAgentSessionId: SESSION, message: { prompt: "mismatch-session" } })
.then(
() => new Error("Expected send session correlation to fail"),
(cause: unknown) => (cause instanceof Error ? cause : new Error("Non-error rejection")),
)
expect(sendSessionError.message).toBe("Cloud Agent send outcome is unknown; do not retry automatically")
const resultSessionError = await agent
.getMessageResult({ cloudAgentSessionId: SESSION, messageId: SESSION_MESSAGE })
.then(
() => new Error("Expected result session correlation to fail"),
(cause: unknown) => (cause instanceof Error ? cause : new Error("Non-error rejection")),
)
expect(resultSessionError.message).toBe("Cloud Agent returned an invalid response")
} finally {
await server.stop(true)
}
})
test("rejects malformed send responses before they cross the client boundary", async () => {
const server = Bun.serve({
hostname: "127.0.0.1",
port: 0,
fetch() {
return success({
cloudAgentSessionId: "invalid",
status: "started",
streamUrl: "",
messageId: MESSAGE,
delivery: "queued",
})
},
})
try {
const agent = createCloudAgentClient({
origin: parseServiceOrigin(server.url.origin, { allowHttpLoopback: true }),
apiKey: TOKEN,
id: () => MESSAGE,
})
const error = await agent.send({ cloudAgentSessionId: SESSION, message: { prompt: "Continue" } }).then(
() => new Error("Expected send to fail"),
(cause: unknown) => (cause instanceof Error ? cause : new Error("Non-error rejection")),
)
expect(error.message).toBe("Cloud Agent send outcome is unknown; do not retry automatically")
} finally {
await server.stop(true)
}
})
test("treats redirects, oversized bodies, and malformed envelopes as unknown start outcomes", async () => {
const server = Bun.serve({
hostname: "127.0.0.1",
port: 0,
async fetch(request) {
const input = AgentStartRequestSchema.parse((await request.json()) as unknown)
if (input.message.prompt === "redirect") {
return new Response(null, { status: 302, headers: { location: "/elsewhere" } })
}
if (input.message.prompt === "oversized") {
return new Response(null, {
headers: { "content-length": String(MAX_CLOUD_AGENT_RESPONSE_BYTES + 1) },
})
}
if (input.message.prompt === "unavailable") return new Response(null, { status: 503 })
return Response.json({ invalid: true })
},
})
try {
const agent = createCloudAgentClient({
origin: parseServiceOrigin(server.url.origin, { allowHttpLoopback: true }),
apiKey: TOKEN,
})
const start = (prompt: string) =>
agent.start({
message: { prompt },
agent: { mode: "code", model: "anthropic/claude-sonnet-4" },
repository: { type: "github", repo: "Kilo-Org/kilocode" },
options: { createdOnPlatform: "kilo-cli" },
})
for (const prompt of ["redirect", "oversized", "malformed", "unavailable"]) {
const error = await start(prompt).then(
() => new Error("Expected start to fail"),
(cause: unknown) => (cause instanceof Error ? cause : new Error("Non-error rejection")),
)
expect(error.message).toBe("Cloud Agent start outcome is unknown; do not retry automatically")
}
} finally {
await server.stop(true)
}
})
})
@@ -0,0 +1,281 @@
import { describe, expect, test } from "bun:test"
import { streamAgentEvents } from "@/kilocode/cloud/websocket-stream"
function mockWebSocket(
events: ReadonlyArray<
{ type: "message"; data: string | ArrayBuffer } | { type: "error" } | { type: "close"; code?: number }
>,
options?: { onClose?: (code?: number) => void; triggerOnCloseOnClose?: boolean },
) {
return class MockWebSocket {
onmessage: ((event: MessageEvent) => void) | null = null
onerror: (() => void) | null = null
onclose: ((event: CloseEvent) => void) | null = null
constructor(_url: string) {
queueMicrotask(() => {
for (const event of events) {
if (event.type === "message") {
this.onmessage?.(new MessageEvent("message", { data: event.data }))
} else if (event.type === "error") {
this.onerror?.()
return
} else if (event.type === "close") {
this.onclose?.({ code: event.code ?? 1000 } as CloseEvent)
return
}
}
})
}
close(code?: number) {
options?.onClose?.(code)
if (options?.triggerOnCloseOnClose) {
queueMicrotask(() => {
this.onclose?.({ code: code ?? 1000 } as CloseEvent)
})
}
}
}
}
describe("streamAgentEvents", () => {
test("writes WebSocket text messages as lines", async () => {
const lines: string[] = []
const Socket = mockWebSocket([
{ type: "message", data: '{"event":"one"}' },
{ type: "message", data: '{"event":"two"}' },
{ type: "close" },
])
await streamAgentEvents({
streamUrl: "/stream?cloudAgentSessionId=agent_123&ticket=tok",
origin: "https://agent.example",
writeLine: (line) => {
lines.push(line)
},
WebSocket: Socket as unknown as typeof WebSocket,
})
expect(lines).toEqual(['{"event":"one"}', '{"event":"two"}'])
})
test("resolves an absolute wss URL", async () => {
const Socket = mockWebSocket([{ type: "close" }])
const connectUrl: string[] = []
class Tracked extends Socket {
constructor(url: string) {
super(url)
connectUrl.push(url)
}
}
await streamAgentEvents({
streamUrl: "wss://agent.example/stream?ticket=tok",
origin: "https://agent.example",
writeLine: () => {},
WebSocket: Tracked as unknown as typeof WebSocket,
})
expect(connectUrl).toEqual(["wss://agent.example/stream?ticket=tok"])
})
test("rejects an absolute stream URL on another origin", async () => {
const Socket = mockWebSocket([{ type: "close" }])
await expect(
streamAgentEvents({
streamUrl: "wss://other.example/stream?ticket=tok",
origin: "https://agent.example",
writeLine: () => {},
WebSocket: Socket as unknown as typeof WebSocket,
}),
).rejects.toThrow("Invalid stream URL origin")
})
test("converts a relative URL to an absolute wss URL", async () => {
const Socket = mockWebSocket([{ type: "close" }])
const connectUrl: string[] = []
class Tracked extends Socket {
constructor(url: string) {
super(url)
connectUrl.push(url)
}
}
await streamAgentEvents({
streamUrl: "/stream?cloudAgentSessionId=agent_123&ticket=tok",
origin: "https://agent.example",
writeLine: () => {},
WebSocket: Tracked as unknown as typeof WebSocket,
})
expect(connectUrl).toEqual(["wss://agent.example/stream?cloudAgentSessionId=agent_123&ticket=tok"])
})
test("rejects when the WebSocket errors", async () => {
const Socket = mockWebSocket([{ type: "error" }])
await expect(
streamAgentEvents({
streamUrl: "/stream?cloudAgentSessionId=agent_123&ticket=tok",
origin: "https://agent.example",
writeLine: () => {},
WebSocket: Socket as unknown as typeof WebSocket,
}),
).rejects.toThrow("WebSocket stream connection failed")
})
test("rejects when the WebSocket closes abnormally", async () => {
const Socket = mockWebSocket([{ type: "close", code: 1011 }])
await expect(
streamAgentEvents({
streamUrl: "/stream?cloudAgentSessionId=agent_123&ticket=tok",
origin: "https://agent.example",
writeLine: () => {},
WebSocket: Socket as unknown as typeof WebSocket,
}),
).rejects.toThrow("WebSocket stream closed unexpectedly (1011)")
})
test("rejects when the WebSocket stream stalls", async () => {
const Socket = mockWebSocket([])
await expect(
streamAgentEvents({
streamUrl: "/stream?cloudAgentSessionId=agent_123&ticket=tok",
origin: "https://agent.example",
writeLine: () => {},
WebSocket: Socket as unknown as typeof WebSocket,
timeoutMs: 1,
}),
).rejects.toThrow("WebSocket stream timed out")
})
test("resolves 3 seconds after receiving a complete event", async () => {
const lines: string[] = []
const codes: Array<number | undefined> = []
const Socket = mockWebSocket(
[
{ type: "message", data: '{"event":"running"}' },
{ type: "message", data: '{"streamEventType":"complete","data":{"exitCode":0}}' },
],
{ onClose: (code) => codes.push(code), triggerOnCloseOnClose: true },
)
const start = Date.now()
await streamAgentEvents({
streamUrl: "/stream?cloudAgentSessionId=agent_123&ticket=tok",
origin: "https://agent.example",
writeLine: (line) => {
lines.push(line)
},
WebSocket: Socket as unknown as typeof WebSocket,
})
expect(Date.now() - start).toBeGreaterThanOrEqual(3000)
expect(codes).toEqual([1000])
expect(lines).toEqual(['{"event":"running"}', '{"streamEventType":"complete","data":{"exitCode":0}}'])
}, 10_000)
test("flushes slow writes in order before resolving", async () => {
const lines: string[] = []
const Socket = mockWebSocket([
{ type: "message", data: '{"event":"one"}' },
{ type: "message", data: '{"event":"two"}' },
{ type: "close" },
])
await streamAgentEvents({
streamUrl: "/stream?cloudAgentSessionId=agent_123&ticket=tok",
origin: "https://agent.example",
writeLine: async (line) => {
await new Promise((resolve) => setTimeout(resolve, 10))
lines.push(line)
},
WebSocket: Socket as unknown as typeof WebSocket,
})
expect(lines).toEqual(['{"event":"one"}', '{"event":"two"}'])
})
test("flushes slow writes in order before rejecting a transport failure", async () => {
const lines: string[] = []
const Socket = mockWebSocket([
{ type: "message", data: '{"event":"one"}' },
{ type: "message", data: '{"event":"two"}' },
{ type: "close", code: 1011 },
])
await expect(
streamAgentEvents({
streamUrl: "/stream?cloudAgentSessionId=agent_123&ticket=tok",
origin: "https://agent.example",
writeLine: async (line) => {
await new Promise((resolve) => setTimeout(resolve, 10))
lines.push(line)
},
WebSocket: Socket as unknown as typeof WebSocket,
}),
).rejects.toThrow("WebSocket stream closed unexpectedly (1011)")
expect(lines).toEqual(['{"event":"one"}', '{"event":"two"}'])
})
test("bounds transport failure draining when an output write stalls", async () => {
const Socket = mockWebSocket([
{ type: "message", data: '{"event":"one"}' },
{ type: "close", code: 1011 },
])
await expect(
streamAgentEvents({
streamUrl: "/stream?cloudAgentSessionId=agent_123&ticket=tok",
origin: "https://agent.example",
writeLine: () => new Promise(() => {}),
WebSocket: Socket as unknown as typeof WebSocket,
timeoutMs: 10,
}),
).rejects.toThrow("WebSocket stream closed unexpectedly (1011)")
})
test("rejects when a stream output write fails", async () => {
const lines: string[] = []
const Socket = mockWebSocket([
{ type: "message", data: '{"event":"one"}' },
{ type: "message", data: '{"event":"two"}' },
{ type: "close" },
])
await expect(
streamAgentEvents({
streamUrl: "/stream?cloudAgentSessionId=agent_123&ticket=tok",
origin: "https://agent.example",
writeLine: (line) => {
if (lines.length > 0) throw new Error("EPIPE")
lines.push(line)
},
WebSocket: Socket as unknown as typeof WebSocket,
}),
).rejects.toThrow("WebSocket stream output failed")
expect(lines).toEqual(['{"event":"one"}'])
})
test("rejects when queued stream output exceeds the memory bound", async () => {
const line = "x".repeat(1024)
const Socket = mockWebSocket(
Array.from({ length: 9000 }, () => ({ type: "message" as const, data: line })),
)
await expect(
streamAgentEvents({
streamUrl: "/stream?cloudAgentSessionId=agent_123&ticket=tok",
origin: "https://agent.example",
writeLine: () => new Promise(() => {}),
WebSocket: Socket as unknown as typeof WebSocket,
}),
).rejects.toThrow("WebSocket stream output consumer is too slow")
})
})
@@ -1,5 +1,6 @@
import { describe, test, expect } from "bun:test"
import path from "path"
import yargs from "yargs"
import { generateHelp, generateCommandTable } from "../../src/kilocode/help"
import { AcpCommand } from "../../src/cli/cmd/acp"
import { McpCommand } from "../../src/cli/cmd/mcp"
@@ -26,6 +27,7 @@ import { HelpCommand } from "../../src/kilocode/help-command"
import { ProfileCommand } from "../../src/kilocode/cli/cmd/profile"
import { DaemonCommand } from "../../src/kilocode/cli/cmd/daemon"
import { KiloConsoleCommand } from "../../src/kilocode/cli/cmd/console"
import { CloudCommand } from "../../src/kilocode/cli/cmd/cloud"
// Stand-in for TuiThreadCommand — the real one imports @opentui/solid which
// doesn't resolve in the test environment. Only command/describe matter here.
@@ -76,6 +78,7 @@ const commands = [
ProfileCommand,
DaemonCommand,
KiloConsoleCommand,
CloudCommand,
HelpCommand,
CompletionStub,
] as any[]
@@ -140,6 +143,29 @@ describe("kilo help <command>", () => {
})
})
describe("kilo cloud help", () => {
async function parser() {
const cli = yargs([])
.scriptName("kilo cloud")
.exitProcess(false)
.help()
.fail((msg, err) => {
throw err ?? new Error(msg)
})
if (typeof CloudCommand.builder !== "function") throw new Error("cloud command builder is missing")
return await CloudCommand.builder(cli)
}
test("requires a subcommand and exposes only the public Cloud Agent operations", async () => {
const bare = await parser()
await expect(Promise.resolve().then(() => bare.parseAsync([]))).rejects.toThrow()
const help = await (await parser()).getHelp()
const names = [...help.matchAll(/^\s*kilo cloud ([a-z][a-z-]*)\b/gm)].map((match) => match[1])
expect([...new Set(names)].sort()).toEqual(["result", "send", "start", "status"])
})
})
describe("edge cases", () => {
test("output contains no ANSI escape sequences", async () => {
const output = await generateHelp({ all: true, format: "md", commands })