mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-08-30 17:14:40 +08:00
Cloud Agent - Add kilo cloud command for running asynchronous cloud agent tasks (#11849)
This commit is contained in:
@@ -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 })
|
||||
|
||||
Reference in New Issue
Block a user