mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-08-30 17:14:40 +08:00
Merge pull request #10463 from Kilo-Org/panoramic-existence
fix(jetbrains): fix build/tests after OpenCode merge, document Java 21 setup
This commit is contained in:
@@ -82,7 +82,7 @@ jobs:
|
||||
turbo-${{ runner.os }}-
|
||||
|
||||
- name: Run unit tests
|
||||
run: bun turbo test:ci --filter=!@kilocode/kilo-jetbrains # kilocode_change
|
||||
run: bun turbo test:ci
|
||||
env:
|
||||
KILO_EXPERIMENTAL_DISABLE_FILEWATCHER: ${{ runner.os == 'Windows' && 'true' || 'false' }}
|
||||
|
||||
|
||||
@@ -17,5 +17,13 @@ jobs:
|
||||
- name: Setup Bun
|
||||
uses: ./.github/actions/setup-bun
|
||||
|
||||
# kilocode_change start
|
||||
- name: Setup Java
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: "21"
|
||||
# kilocode_change end
|
||||
|
||||
- name: Run typecheck
|
||||
run: bun typecheck
|
||||
|
||||
@@ -12,7 +12,7 @@ Kilo CLI is an open source AI coding agent that generates code from natural lang
|
||||
- **Dev**: `bun run dev` (runs from root) or `bun run --cwd packages/opencode --conditions=browser src/index.ts`
|
||||
- **Dev with params**: `bun dev -- help`
|
||||
- **Extension**: `bun run extension` (build + launch VS Code with the extension in dev mode). Pass `--no-build` to skip the build.
|
||||
- **Typecheck**: `bun turbo typecheck` (uses `tsgo`, not `tsc`)
|
||||
- **Typecheck**: `bun turbo typecheck` (uses `tsgo`, not `tsc`). Includes the JetBrains plugin — requires Java 21. Check with `java -version` before running. If missing, install via SDKMAN: `sdk install java 21-tem && sdk use java 21-tem`. If SDKMAN is not installed, see https://sdkman.io/install.
|
||||
- **Test**: `bun test` from `packages/opencode/` (NOT from root -- root blocks tests)
|
||||
- **Single test**: `bun test ./test/tool/tool-define.test.ts` from `packages/opencode/`
|
||||
- **CLI build artifact size check**: after `bun run script/build.ts --single --skip-install` in `packages/opencode/`, use `du -h dist/*/*/bin/kilo` (scoped package output lives under `dist/@kilocode/`)
|
||||
@@ -34,6 +34,7 @@ Before saying an implementation is ready, run the smallest relevant checks that
|
||||
| CLI | From `packages/opencode/`: `bun run typecheck`, `bun test` or targeted `bun test ./path/to/file.test.ts` |
|
||||
| VS Code extension | From `packages/kilo-vscode/`: `bun run typecheck`, `bun run lint`, `bun run test:unit` or `bun run test` |
|
||||
| Extension build/package | From `packages/kilo-vscode/`: `bun run compile` or `bun run package` when touching build, packaging, SDK, or webview integration paths |
|
||||
| JetBrains plugin | From `packages/kilo-jetbrains/`: `./gradlew typecheck`, `./gradlew test`. Requires Java 21 — check first with `java -version`. Install via SDKMAN if missing: `sdk install java 21-tem && sdk use java 21-tem`. |
|
||||
| CI-only guards | Run affected guards documented above, such as `bun run knip`, `bun run check-kilocode-change`, `bun run script/check-opencode-annotations.ts`, or source link extraction |
|
||||
|
||||
Never run root `bun test`; the root script prints `do not run tests from root` and exits with code 1. Use package-level tests instead.
|
||||
|
||||
+43
-1
@@ -14,9 +14,34 @@ There are lots of ways to contribute to the project:
|
||||
|
||||
The Kilo Community is [on Discord](https://kilo.ai/discord).
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- **Bun 1.3.13+** — required for all packages.
|
||||
- **Java 21** — required by the JetBrains plugin. The root `bun turbo typecheck` and `bun turbo test:ci` commands include `@kilocode/kilo-jetbrains` and will fail without Java 21.
|
||||
|
||||
The preferred way to install Java is via [SDKMAN](https://sdkman.io/install):
|
||||
|
||||
```bash
|
||||
# Install SDKMAN (if not already installed)
|
||||
curl -s "https://get.sdkman.io" | bash
|
||||
|
||||
# Install and activate Java 21 (Eclipse Temurin)
|
||||
sdk install java 21-tem
|
||||
sdk use java 21-tem
|
||||
|
||||
# Verify
|
||||
java -version
|
||||
```
|
||||
|
||||
If you don't plan to work on the JetBrains plugin, you can still run non-JetBrains checks directly:
|
||||
|
||||
```bash
|
||||
bun turbo typecheck --filter=!@kilocode/kilo-jetbrains
|
||||
```
|
||||
|
||||
## Developing Kilo CLI
|
||||
|
||||
- **Requirements:** Bun 1.3.13+
|
||||
- **Requirements:** Bun 1.3.13+, Java 21 (see [Prerequisites](#prerequisites) above)
|
||||
- Install dependencies and start the dev server from the repo root:
|
||||
|
||||
```bash
|
||||
@@ -34,6 +59,23 @@ bun run extension # Build + launch in dev mode
|
||||
|
||||
This auto-detects VS Code on macOS, Linux, and Windows. Override with `--app-path PATH` or `VSCODE_EXEC_PATH`. Use `--insiders` to prefer Insiders, `--workspace PATH` to open a specific folder, or `--clean` to reset cached state.
|
||||
|
||||
### Developing the JetBrains Plugin
|
||||
|
||||
Requires Java 21 (see [Prerequisites](#prerequisites)). From `packages/kilo-jetbrains/`:
|
||||
|
||||
```bash
|
||||
./gradlew typecheck # Compile-check all Kotlin sources
|
||||
./gradlew test # Run all tests (backend + frontend)
|
||||
./gradlew runIde # Launch sandboxed IntelliJ with the plugin
|
||||
```
|
||||
|
||||
Or via the root turbo filter to run only JetBrains checks from the repo root:
|
||||
|
||||
```bash
|
||||
bun turbo typecheck --filter=@kilocode/kilo-jetbrains
|
||||
bun turbo test:ci --filter=@kilocode/kilo-jetbrains
|
||||
```
|
||||
|
||||
### Running against a different directory
|
||||
|
||||
By default, `bun dev` runs Kilo CLI in the `packages/opencode` directory. To run it against a different directory or repository:
|
||||
|
||||
+1
-1
@@ -9,7 +9,7 @@
|
||||
"dev": "bun run --cwd packages/opencode --conditions=browser src/index.ts",
|
||||
"dev:storybook": "bun --cwd packages/storybook storybook",
|
||||
"lint": "oxlint",
|
||||
"typecheck": "bun turbo typecheck --filter=!@kilocode/kilo-jetbrains",
|
||||
"typecheck": "bun turbo typecheck",
|
||||
"postinstall": "bun run --cwd packages/opencode fix-node-pty && bun run script/setup-git.ts",
|
||||
"prepare": "husky",
|
||||
"random": "echo 'Random script'",
|
||||
|
||||
@@ -9,7 +9,17 @@ AI coding agent plugin for JetBrains IDEs.
|
||||
### Prerequisites
|
||||
|
||||
- **Bun** -- used to build CLI binaries and run build scripts
|
||||
- **JDK 21+** -- required by Gradle and the IntelliJ Platform SDK
|
||||
- **JDK 21+** -- required by Gradle and the IntelliJ Platform SDK. Check with `java -version`. The preferred way to install is via [SDKMAN](https://sdkman.io/install):
|
||||
|
||||
```bash
|
||||
# Install SDKMAN (if not already installed)
|
||||
curl -s "https://get.sdkman.io" | bash
|
||||
|
||||
# Install and activate Java 21 (Eclipse Temurin)
|
||||
sdk install java 21-tem
|
||||
sdk use java 21-tem
|
||||
```
|
||||
|
||||
- **IntelliJ IDEA** -- to run the plugin in a sandboxed IDE
|
||||
|
||||
---
|
||||
|
||||
@@ -86,6 +86,10 @@ tasks.named("compileKotlin") {
|
||||
dependsOn(fixGeneratedApi)
|
||||
}
|
||||
|
||||
tasks.named("compileTestKotlin") {
|
||||
dependsOn(fixGeneratedApi)
|
||||
}
|
||||
|
||||
val cliDir = layout.buildDirectory.dir("generated/cli/cli")
|
||||
val production = providers.gradleProperty("production").map { it.toBoolean() }.orElse(false)
|
||||
|
||||
|
||||
-24
@@ -1,24 +0,0 @@
|
||||
package normalization
|
||||
|
||||
internal data class DuplicateTagRule(
|
||||
val original: String,
|
||||
val dedups: List<TagDedup>,
|
||||
)
|
||||
|
||||
internal data class TagDedup(
|
||||
val name: String,
|
||||
val ops: List<String> = emptyList(),
|
||||
)
|
||||
|
||||
internal val duplicateTagRules = listOf(
|
||||
DuplicateTagRule(
|
||||
original = "pty",
|
||||
dedups = listOf(
|
||||
TagDedup(name = "pty"),
|
||||
TagDedup(
|
||||
name = "pty-connect",
|
||||
ops = listOf("pty.connect"),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
+137
-83
@@ -11,105 +11,159 @@ internal object OpenApiSpecNormalizer {
|
||||
fun normalize(raw: String): String {
|
||||
val root = Json.parseToJsonElement(raw) as? JsonObject
|
||||
?: throw GradleException("OpenAPI spec root must be a JSON object.")
|
||||
val tags = root["tags"] as? JsonArray ?: return raw
|
||||
val (fixedTags, renames) = fixTags(tags)
|
||||
if (renames.isEmpty()) return raw
|
||||
val paths = root["paths"] as? JsonObject
|
||||
?: throw GradleException("OpenAPI spec paths must be a JSON object.")
|
||||
val fixed = JsonObject(
|
||||
root + mapOf(
|
||||
"tags" to fixedTags,
|
||||
"paths" to fixPaths(paths, renames),
|
||||
)
|
||||
)
|
||||
// Step 1: Remove duplicate dot-notation schemas and remap their $refs to
|
||||
// camelCase equivalents so the spec remains self-consistent.
|
||||
// Step 2: Strip operation-level tags so all routes land in DefaultApi.
|
||||
// Step 3: Deduplicate the root-level tags array.
|
||||
// Step 4: Fix nullable fields in the /kilo/profile response that Effect's
|
||||
// OpenAPI generator incorrectly emits as non-nullable.
|
||||
val (noDotsRoot, _) = remapDotSchemas(root)
|
||||
val stripped = stripTags(noDotsRoot)
|
||||
val deduped = dedupRootTags(stripped)
|
||||
val fixed = fixProfileNullable(deduped)
|
||||
return encode(fixed)
|
||||
}
|
||||
|
||||
private fun encode(obj: JsonObject): String {
|
||||
val json = Json { prettyPrint = true }
|
||||
.encodeToString(JsonElement.serializer(), fixed)
|
||||
.encodeToString(JsonElement.serializer(), obj)
|
||||
return "$json\n"
|
||||
}
|
||||
|
||||
private fun fixTags(tags: JsonArray): Pair<JsonArray, Map<String, Rename>> {
|
||||
val rules = rules()
|
||||
val counts = mutableMapOf<String, Int>()
|
||||
val renames = mutableMapOf<String, Rename>()
|
||||
val fixed = tags.map { tag ->
|
||||
val item = tag as? JsonObject ?: return@map tag
|
||||
val name = text(item["name"]) ?: return@map tag
|
||||
val rule = rules[name] ?: return@map tag
|
||||
val index = counts.getOrDefault(name, 0)
|
||||
counts[name] = index + 1
|
||||
if (index >= rule.dedups.size) {
|
||||
throw GradleException("Missing final OpenAPI tag name for duplicate '$name' at index $index.")
|
||||
/**
|
||||
* Find schemas whose names contain dots (e.g. "Event.tui.command.execute").
|
||||
* If a camelCase equivalent (e.g. "EventTuiCommandExecute") exists in the
|
||||
* same component map, remove the dot schema and rewrite every `$ref` that
|
||||
* points to it to use the camelCase name instead.
|
||||
*/
|
||||
private fun remapDotSchemas(root: JsonObject): Pair<JsonObject, Map<String, String>> {
|
||||
val components = root["components"] as? JsonObject ?: return root to emptyMap()
|
||||
val schemas = components["schemas"] as? JsonObject ?: return root to emptyMap()
|
||||
|
||||
// Build a map of dot-name → camelCase-name for schemas that have a
|
||||
// camelCase duplicate in the same spec.
|
||||
val dotMap = schemas.keys
|
||||
.filter { "." in it }
|
||||
.mapNotNull { dot ->
|
||||
val camel = dot.split(".").joinToString("") { w -> w.replaceFirstChar { c -> c.uppercase() } }
|
||||
if (camel in schemas) dot to camel else null
|
||||
}
|
||||
val dedup = rule.dedups[index]
|
||||
if (index == 0) return@map tag
|
||||
renames[dedup.name] = Rename(rule.original, dedup)
|
||||
JsonObject(item + ("name" to JsonPrimitive(dedup.name)))
|
||||
}
|
||||
rules.forEach { (name, rule) ->
|
||||
val count = counts[name] ?: 0
|
||||
if (count > 1 && count != rule.dedups.size) {
|
||||
throw GradleException("Duplicate OpenAPI tag '$name' has $count entries but ${rule.dedups.size} final names.")
|
||||
}
|
||||
}
|
||||
return JsonArray(fixed) to renames
|
||||
.toMap()
|
||||
|
||||
if (dotMap.isEmpty()) return root to emptyMap()
|
||||
|
||||
// Remove dot schemas.
|
||||
val cleaned = JsonObject(schemas.filterKeys { it !in dotMap })
|
||||
val noDotsComponents = JsonObject(components + mapOf("schemas" to cleaned))
|
||||
val noDotsRoot = JsonObject(root + mapOf("components" to noDotsComponents))
|
||||
|
||||
// Rewrite $ref strings throughout the whole spec.
|
||||
val rewritten = rewriteRefs(noDotsRoot, dotMap)
|
||||
return rewritten to dotMap
|
||||
}
|
||||
|
||||
private fun fixPaths(paths: JsonObject, renames: Map<String, Rename>): JsonObject {
|
||||
val ops = renames.values.flatMap { rename ->
|
||||
rename.dedup.ops.map { id -> id to rename }
|
||||
}.toMap()
|
||||
val hits = mutableMapOf<String, Int>()
|
||||
val fixed = JsonObject(paths.mapValues { (_, item) ->
|
||||
/**
|
||||
* Recursively rewrite every JsonPrimitive `$ref` value that matches a
|
||||
* dot-notation schema name, replacing it with the camelCase equivalent.
|
||||
*/
|
||||
private fun rewriteRefs(element: JsonElement, map: Map<String, String>): JsonObject {
|
||||
return rewriteElement(element, map) as JsonObject
|
||||
}
|
||||
|
||||
private fun rewriteElement(element: JsonElement, map: Map<String, String>): JsonElement =
|
||||
when (element) {
|
||||
is JsonObject -> JsonObject(element.mapValues { (key, value) ->
|
||||
if (key == "\$ref" && value is JsonPrimitive) {
|
||||
val ref = value.content
|
||||
val prefix = "#/components/schemas/"
|
||||
if (ref.startsWith(prefix)) {
|
||||
val name = ref.removePrefix(prefix)
|
||||
val replaced = map[name]
|
||||
if (replaced != null) JsonPrimitive("$prefix$replaced") else value
|
||||
} else value
|
||||
} else rewriteElement(value, map)
|
||||
})
|
||||
is JsonArray -> JsonArray(element.map { rewriteElement(it, map) })
|
||||
else -> element
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the "tags" field from every operation so that openapi-generator
|
||||
* collects all operations into a single DefaultApi class.
|
||||
*/
|
||||
private fun stripTags(root: JsonObject): JsonObject {
|
||||
val paths = root["paths"] as? JsonObject ?: return root
|
||||
val stripped = JsonObject(paths.mapValues { (_, item) ->
|
||||
val path = item as? JsonObject ?: return@mapValues item
|
||||
JsonObject(path.mapValues { (_, op) ->
|
||||
val obj = op as? JsonObject ?: return@mapValues op
|
||||
val id = text(obj["operationId"]) ?: return@mapValues op
|
||||
val rename = ops[id] ?: return@mapValues op
|
||||
hits[id] = hits.getOrDefault(id, 0) + 1
|
||||
fixOp(obj, rename)
|
||||
if ("tags" !in obj) return@mapValues op
|
||||
JsonObject(obj.filterKeys { it != "tags" })
|
||||
})
|
||||
})
|
||||
ops.keys.forEach { id ->
|
||||
val count = hits[id] ?: 0
|
||||
if (count != 1) {
|
||||
throw GradleException("Expected one OpenAPI operation '$id' for tag normalization, found $count.")
|
||||
}
|
||||
}
|
||||
return fixed
|
||||
return JsonObject(root + mapOf("paths" to stripped))
|
||||
}
|
||||
|
||||
private fun fixOp(op: JsonObject, rename: Rename): JsonObject {
|
||||
val tags = op["tags"] as? JsonArray
|
||||
?: throw GradleException("OpenAPI operation must declare tags before tag normalization.")
|
||||
val count = tags.count { tag -> text(tag) == rename.from }
|
||||
if (count != 1) {
|
||||
throw GradleException("Expected one '${rename.from}' operation tag, found $count.")
|
||||
}
|
||||
return JsonObject(op + ("tags" to JsonArray(tags.map { tag ->
|
||||
if (text(tag) != rename.from) return@map tag
|
||||
JsonPrimitive(rename.dedup.name)
|
||||
})))
|
||||
/**
|
||||
* Fix the `/kilo/profile` GET 200 response schema: Effect's OpenAPI generator
|
||||
* emits `balance` and `currentOrgId` as non-nullable required fields even
|
||||
* though the server schema is `Schema.NullOr(...)`. Wrap each non-nullable
|
||||
* property in `anyOf: [<original-schema>, {"type": "null"}]` so the generated
|
||||
* Kotlin model uses a nullable type. Already-nullable properties (those that
|
||||
* already have `anyOf` containing `{"type":"null"}`) are left untouched.
|
||||
*/
|
||||
private fun fixProfileNullable(root: JsonObject): JsonObject {
|
||||
val paths = root["paths"] as? JsonObject ?: return root
|
||||
val profileItem = paths["/kilo/profile"] as? JsonObject ?: return root
|
||||
val getOp = profileItem["get"] as? JsonObject ?: return root
|
||||
val schema = getOp["responses"]
|
||||
?.let { it as? JsonObject }?.get("200")
|
||||
?.let { it as? JsonObject }?.get("content")
|
||||
?.let { it as? JsonObject }?.get("application/json")
|
||||
?.let { it as? JsonObject }?.get("schema")
|
||||
as? JsonObject ?: return root
|
||||
val props = schema["properties"] as? JsonObject ?: return root
|
||||
|
||||
val nullable = setOf("balance", "currentOrgId")
|
||||
val fixed = JsonObject(props.mapValues { (key, value) ->
|
||||
if (key !in nullable) return@mapValues value
|
||||
val obj = value as? JsonObject ?: return@mapValues value
|
||||
// Skip if already wrapped (has anyOf containing {type:null}).
|
||||
val existing = obj["anyOf"] as? JsonArray
|
||||
if (existing != null && existing.any {
|
||||
(it as? JsonObject)?.get("type")?.let { t -> (t as? JsonPrimitive)?.content } == "null"
|
||||
}) return@mapValues value
|
||||
JsonObject(mapOf("anyOf" to JsonArray(listOf(obj, JsonObject(mapOf("type" to JsonPrimitive("null")))))))
|
||||
})
|
||||
|
||||
// Rebuild nested objects up to root.
|
||||
val responses = getOp["responses"]!! as JsonObject
|
||||
val resp200 = responses["200"]!! as JsonObject
|
||||
val content = resp200["content"]!! as JsonObject
|
||||
val appJson = content["application/json"]!! as JsonObject
|
||||
val newSchema = JsonObject(schema + mapOf("properties" to fixed))
|
||||
val newApp = JsonObject(appJson + mapOf("schema" to newSchema))
|
||||
val newContent = JsonObject(content + mapOf("application/json" to newApp))
|
||||
val new200 = JsonObject(resp200 + mapOf("content" to newContent))
|
||||
val newResponses = JsonObject(responses + mapOf("200" to new200))
|
||||
val newGet = JsonObject(getOp + mapOf("responses" to newResponses))
|
||||
val newProfile = JsonObject(profileItem + mapOf("get" to newGet))
|
||||
val newPaths = JsonObject(paths + mapOf("/kilo/profile" to newProfile))
|
||||
return JsonObject(root + mapOf("paths" to newPaths))
|
||||
}
|
||||
|
||||
private fun rules(): Map<String, DuplicateTagRule> {
|
||||
if (duplicateTagRules.map { it.original }.toSet().size != duplicateTagRules.size) {
|
||||
throw GradleException("OpenAPI duplicate tag config must not repeat original tag names.")
|
||||
}
|
||||
return duplicateTagRules.associateBy { rule ->
|
||||
if (rule.dedups.size < 2 || rule.dedups.first().name != rule.original) {
|
||||
throw GradleException("OpenAPI tag rule '${rule.original}' must keep the original tag first.")
|
||||
}
|
||||
if (rule.dedups.map { it.name }.toSet().size != rule.dedups.size) {
|
||||
throw GradleException("OpenAPI tag rule '${rule.original}' must use unique dedup names.")
|
||||
}
|
||||
rule.original
|
||||
/**
|
||||
* Deduplicate the root-level "tags" array by name — the spec validator
|
||||
* rejects repeated tag names even when they describe different things.
|
||||
*/
|
||||
private fun dedupRootTags(root: JsonObject): JsonObject {
|
||||
val tags = root["tags"] as? JsonArray ?: return root
|
||||
val seen = mutableSetOf<String>()
|
||||
val deduped = tags.filter { tag ->
|
||||
val name = (tag as? JsonObject)?.let { (it["name"] as? JsonPrimitive)?.content }
|
||||
?: return@filter true
|
||||
seen.add(name)
|
||||
}
|
||||
return JsonObject(root + mapOf("tags" to JsonArray(deduped)))
|
||||
}
|
||||
|
||||
private fun text(value: JsonElement?) = (value as? JsonPrimitive)?.content
|
||||
|
||||
private data class Rename(
|
||||
val from: String,
|
||||
val dedup: TagDedup,
|
||||
)
|
||||
}
|
||||
|
||||
+190
-46
@@ -5,15 +5,13 @@ import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import org.gradle.api.GradleException
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFailsWith
|
||||
import kotlin.test.assertTrue
|
||||
import kotlin.test.assertNull
|
||||
|
||||
class OpenApiSpecNormalizerTest {
|
||||
@Test
|
||||
fun `renames configured duplicate tags and linked operations`() {
|
||||
fun `strips tags from all operations`() {
|
||||
val raw = """
|
||||
{
|
||||
"paths": {
|
||||
@@ -23,61 +21,207 @@ class OpenApiSpecNormalizerTest {
|
||||
"operationId": "pty.list"
|
||||
}
|
||||
},
|
||||
"/pty/{ptyID}/connect": {
|
||||
"/session": {
|
||||
"post": {
|
||||
"tags": ["session"],
|
||||
"operationId": "session.create"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
""".trimIndent()
|
||||
|
||||
val root = obj(OpenApiSpecNormalizer.normalize(raw))
|
||||
val paths = obj(root["paths"])
|
||||
val pty = obj(obj(paths["/pty"])["get"])
|
||||
val session = obj(obj(paths["/session"])["post"])
|
||||
|
||||
assertNull(pty["tags"], "tags should be stripped from pty operation")
|
||||
assertNull(session["tags"], "tags should be stripped from session operation")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `leaves operations without tags unchanged`() {
|
||||
val raw = """
|
||||
{
|
||||
"paths": {
|
||||
"/health": {
|
||||
"get": {
|
||||
"tags": ["pty"],
|
||||
"operationId": "pty.connect"
|
||||
"operationId": "health.get"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
""".trimIndent()
|
||||
|
||||
val root = obj(OpenApiSpecNormalizer.normalize(raw))
|
||||
val paths = obj(root["paths"])
|
||||
val health = obj(obj(paths["/health"])["get"])
|
||||
|
||||
assertNull(health["tags"])
|
||||
assertEquals("health.get", text(health["operationId"]))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `removes dot schemas and rewrites refs to camelCase equivalents`() {
|
||||
val raw = """
|
||||
{
|
||||
"paths": {
|
||||
"/tui/publish": {
|
||||
"post": {
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"${'$'}ref": "#/components/schemas/Event.tui.command.execute"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"components": {
|
||||
"schemas": {
|
||||
"EventTuiCommandExecute": { "type": "object" },
|
||||
"Event.tui.command.execute": { "type": "object" },
|
||||
"Session": { "type": "object" }
|
||||
}
|
||||
}
|
||||
}
|
||||
""".trimIndent()
|
||||
|
||||
val root = obj(OpenApiSpecNormalizer.normalize(raw))
|
||||
val schemas = obj(obj(root["components"])["schemas"])
|
||||
|
||||
assertNull(schemas["Event.tui.command.execute"], "dot schema should be removed")
|
||||
assert("EventTuiCommandExecute" in schemas) { "camelCase schema should be kept" }
|
||||
assert("Session" in schemas) { "non-dot schema should be kept" }
|
||||
|
||||
// Check that the $ref was rewritten
|
||||
val post = obj(obj(obj(obj(root["paths"])["/tui/publish"])["post"])["requestBody"])
|
||||
val schema = obj(obj(obj(post["content"])["application/json"])["schema"])
|
||||
assertEquals("#/components/schemas/EventTuiCommandExecute", text(schema["\$ref"]))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `makes balance and currentOrgId nullable in kilo profile response`() {
|
||||
val raw = """
|
||||
{
|
||||
"paths": {
|
||||
"/kilo/profile": {
|
||||
"get": {
|
||||
"operationId": "kilo.profile",
|
||||
"responses": {
|
||||
"200": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"profile": { "type": "object", "properties": { "email": { "type": "string" } }, "required": ["email"], "additionalProperties": false },
|
||||
"balance": { "type": "object", "properties": { "balance": { "type": "number" } }, "required": ["balance"], "additionalProperties": false },
|
||||
"currentOrgId": { "type": "string" }
|
||||
},
|
||||
"required": ["profile", "balance", "currentOrgId"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
""".trimIndent()
|
||||
|
||||
val root = obj(OpenApiSpecNormalizer.normalize(raw))
|
||||
val schema = obj(obj(obj(obj(obj(obj(root["paths"])["/kilo/profile"])["get"])["responses"])["200"])["content"])
|
||||
val props = obj(obj(obj(schema["application/json"])["schema"])["properties"])
|
||||
|
||||
// balance must be anyOf [object, null]
|
||||
val balance = obj(props["balance"])
|
||||
val balanceAnyOf = arr(balance["anyOf"])
|
||||
assertEquals(2, balanceAnyOf.size, "balance should have anyOf with 2 entries")
|
||||
val balanceTypes = balanceAnyOf.map { (it as? JsonObject)?.get("type").let { t -> (t as? JsonPrimitive)?.content } }
|
||||
assert("null" in balanceTypes) { "balance anyOf should include null but got $balanceTypes" }
|
||||
assert(balanceAnyOf.any { it is JsonObject && "properties" in it }) { "balance anyOf should include the object schema" }
|
||||
|
||||
// currentOrgId must be anyOf [string, null]
|
||||
val orgId = obj(props["currentOrgId"])
|
||||
val orgIdAnyOf = arr(orgId["anyOf"])
|
||||
assertEquals(2, orgIdAnyOf.size, "currentOrgId should have anyOf with 2 entries")
|
||||
val orgIdTypes = orgIdAnyOf.map { (it as? JsonObject)?.get("type").let { t -> (t as? JsonPrimitive)?.content } }
|
||||
assert("null" in orgIdTypes) { "currentOrgId anyOf should include null but got $orgIdTypes" }
|
||||
assert("string" in orgIdTypes) { "currentOrgId anyOf should include string but got $orgIdTypes" }
|
||||
|
||||
// profile must remain unchanged (not wrapped in anyOf)
|
||||
val profile = obj(props["profile"])
|
||||
assertNull(profile["anyOf"], "profile should not be wrapped in anyOf")
|
||||
assertEquals("object", text(profile["type"]))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `leaves already-nullable fields unchanged in kilo profile response`() {
|
||||
// If balance already has anyOf (i.e. the spec was generated correctly), normalizer must not double-wrap it.
|
||||
val raw = """
|
||||
{
|
||||
"paths": {
|
||||
"/kilo/profile": {
|
||||
"get": {
|
||||
"operationId": "kilo.profile",
|
||||
"responses": {
|
||||
"200": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"profile": { "type": "object", "properties": { "email": { "type": "string" } }, "required": ["email"], "additionalProperties": false },
|
||||
"balance": { "anyOf": [{ "type": "object", "properties": { "balance": { "type": "number" } }, "required": ["balance"], "additionalProperties": false }, { "type": "null" }] },
|
||||
"currentOrgId": { "anyOf": [{ "type": "string" }, { "type": "null" }] }
|
||||
},
|
||||
"required": ["profile", "balance", "currentOrgId"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
""".trimIndent()
|
||||
|
||||
val root = obj(OpenApiSpecNormalizer.normalize(raw))
|
||||
val schema = obj(obj(obj(obj(obj(obj(root["paths"])["/kilo/profile"])["get"])["responses"])["200"])["content"])
|
||||
val props = obj(obj(obj(schema["application/json"])["schema"])["properties"])
|
||||
|
||||
// balance must still have exactly 2 anyOf entries (not wrapped again)
|
||||
val balance = obj(props["balance"])
|
||||
val balanceAnyOf = arr(balance["anyOf"])
|
||||
assertEquals(2, balanceAnyOf.size, "balance should still have exactly 2 anyOf entries, not be double-wrapped")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `deduplicates root-level tags array`() {
|
||||
val raw = """
|
||||
{
|
||||
"paths": {},
|
||||
"tags": [
|
||||
{ "name": "pty", "description": "PTY routes." },
|
||||
{ "name": "pty", "description": "PTY WebSocket route." }
|
||||
{ "name": "pty", "description": "PTY WebSocket route." },
|
||||
{ "name": "session", "description": "Session routes." }
|
||||
]
|
||||
}
|
||||
""".trimIndent()
|
||||
|
||||
val root = obj(OpenApiSpecNormalizer.normalize(raw))
|
||||
val tags = arr(root["tags"]).map { text(obj(it)["name"]) }
|
||||
val paths = obj(root["paths"])
|
||||
val pty = obj(obj(paths["/pty"])["get"])
|
||||
val link = obj(obj(paths["/pty/{ptyID}/connect"])["get"])
|
||||
|
||||
assertEquals(listOf("pty", "pty-connect"), tags)
|
||||
assertEquals(listOf("pty"), arr(pty["tags"]).map(::text))
|
||||
assertEquals(listOf("pty-connect"), arr(link["tags"]).map(::text))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `keeps specs without duplicate configured tags unchanged`() {
|
||||
val raw = """
|
||||
{
|
||||
"tags": [
|
||||
{ "name": "pty", "description": "PTY routes." }
|
||||
]
|
||||
}
|
||||
""".trimIndent()
|
||||
|
||||
assertEquals(raw, OpenApiSpecNormalizer.normalize(raw))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `fails when configured operation for renamed tag is absent`() {
|
||||
val raw = """
|
||||
{
|
||||
"paths": {},
|
||||
"tags": [
|
||||
{ "name": "pty", "description": "PTY routes." },
|
||||
{ "name": "pty", "description": "PTY WebSocket route." }
|
||||
]
|
||||
}
|
||||
""".trimIndent()
|
||||
|
||||
val err = assertFailsWith<GradleException> {
|
||||
OpenApiSpecNormalizer.normalize(raw)
|
||||
}
|
||||
|
||||
assertTrue(err.message?.contains("Expected one OpenAPI operation 'pty.connect'") == true)
|
||||
assertEquals(listOf("pty", "session"), tags, "duplicate pty tag should be removed")
|
||||
}
|
||||
|
||||
private fun obj(raw: String) = Json.parseToJsonElement(raw) as JsonObject
|
||||
|
||||
@@ -1610,7 +1610,20 @@ unix(
|
||||
|
||||
const run = yield* prompt.loop({ sessionID: chat.id }).pipe(Effect.forkChild)
|
||||
yield* llm.wait(1)
|
||||
yield* Effect.sleep(150)
|
||||
// kilocode_change start
|
||||
yield* waitFor(
|
||||
"large bash output",
|
||||
sessions.messages({ sessionID: chat.id }).pipe(
|
||||
Effect.map((msgs) => {
|
||||
const part = msgs.flatMap((msg) => msg.parts).find((part) => part.type === "tool")
|
||||
if (part?.type !== "tool") return
|
||||
if (part.state.status !== "running") return
|
||||
if (!String(part.state.metadata?.output ?? "").includes("03999")) return
|
||||
return part
|
||||
}),
|
||||
),
|
||||
)
|
||||
// kilocode_change end
|
||||
yield* prompt.cancel(chat.id)
|
||||
|
||||
const exit = yield* Fiber.await(run)
|
||||
|
||||
Reference in New Issue
Block a user