diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index d0f567d9a5..bdfe2e18a1 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -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' }} diff --git a/.github/workflows/typecheck.yml b/.github/workflows/typecheck.yml index 6c7ae42095..f49ea2bd27 100644 --- a/.github/workflows/typecheck.yml +++ b/.github/workflows/typecheck.yml @@ -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 diff --git a/AGENTS.md b/AGENTS.md index 31f9c961b7..914d394946 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9bb4a77599..d31f9bf690 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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: diff --git a/package.json b/package.json index 8869820ef3..ac9b966c35 100644 --- a/package.json +++ b/package.json @@ -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'", diff --git a/packages/kilo-jetbrains/README.md b/packages/kilo-jetbrains/README.md index cde3e081de..f8f7150705 100644 --- a/packages/kilo-jetbrains/README.md +++ b/packages/kilo-jetbrains/README.md @@ -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 --- diff --git a/packages/kilo-jetbrains/backend/build.gradle.kts b/packages/kilo-jetbrains/backend/build.gradle.kts index 97fc04cb09..224eb3e5a5 100644 --- a/packages/kilo-jetbrains/backend/build.gradle.kts +++ b/packages/kilo-jetbrains/backend/build.gradle.kts @@ -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) diff --git a/packages/kilo-jetbrains/build-tasks/src/main/kotlin/normalization/NormalizeOpenApiSpecConfig.kt b/packages/kilo-jetbrains/build-tasks/src/main/kotlin/normalization/NormalizeOpenApiSpecConfig.kt deleted file mode 100644 index 66c2b555f8..0000000000 --- a/packages/kilo-jetbrains/build-tasks/src/main/kotlin/normalization/NormalizeOpenApiSpecConfig.kt +++ /dev/null @@ -1,24 +0,0 @@ -package normalization - -internal data class DuplicateTagRule( - val original: String, - val dedups: List, -) - -internal data class TagDedup( - val name: String, - val ops: List = emptyList(), -) - -internal val duplicateTagRules = listOf( - DuplicateTagRule( - original = "pty", - dedups = listOf( - TagDedup(name = "pty"), - TagDedup( - name = "pty-connect", - ops = listOf("pty.connect"), - ), - ), - ), -) diff --git a/packages/kilo-jetbrains/build-tasks/src/main/kotlin/normalization/OpenApiSpecNormalizer.kt b/packages/kilo-jetbrains/build-tasks/src/main/kotlin/normalization/OpenApiSpecNormalizer.kt index 989fd18b28..bf5e418e69 100644 --- a/packages/kilo-jetbrains/build-tasks/src/main/kotlin/normalization/OpenApiSpecNormalizer.kt +++ b/packages/kilo-jetbrains/build-tasks/src/main/kotlin/normalization/OpenApiSpecNormalizer.kt @@ -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> { - val rules = rules() - val counts = mutableMapOf() - val renames = mutableMapOf() - 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> { + 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): JsonObject { - val ops = renames.values.flatMap { rename -> - rename.dedup.ops.map { id -> id to rename } - }.toMap() - val hits = mutableMapOf() - 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): JsonObject { + return rewriteElement(element, map) as JsonObject + } + + private fun rewriteElement(element: JsonElement, map: Map): 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: [, {"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 { - 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() + 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, - ) } diff --git a/packages/kilo-jetbrains/build-tasks/src/test/kotlin/normalization/OpenApiSpecNormalizerTest.kt b/packages/kilo-jetbrains/build-tasks/src/test/kotlin/normalization/OpenApiSpecNormalizerTest.kt index 191bc97310..5f999c7462 100644 --- a/packages/kilo-jetbrains/build-tasks/src/test/kotlin/normalization/OpenApiSpecNormalizerTest.kt +++ b/packages/kilo-jetbrains/build-tasks/src/test/kotlin/normalization/OpenApiSpecNormalizerTest.kt @@ -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 { - 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 diff --git a/packages/opencode/test/session/prompt.test.ts b/packages/opencode/test/session/prompt.test.ts index 64161047bc..8af306ef25 100644 --- a/packages/opencode/test/session/prompt.test.ts +++ b/packages/opencode/test/session/prompt.test.ts @@ -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)