diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index d0f567d9a52..bdfe2e18a15 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 6c7ae420953..f49ea2bd277 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/package.json b/package.json index 22a42e9fe0f..d80c3ada927 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/backend/build.gradle.kts b/packages/kilo-jetbrains/backend/build.gradle.kts index 97fc04cb09b..224eb3e5a57 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/OpenApiSpecNormalizer.kt b/packages/kilo-jetbrains/build-tasks/src/main/kotlin/normalization/OpenApiSpecNormalizer.kt index b0b19ee831a..58b655d8330 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 @@ -15,10 +15,13 @@ internal object OpenApiSpecNormalizer { // 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, dotMap) = remapDotSchemas(root) val stripped = stripTags(noDotsRoot) val deduped = dedupRootTags(stripped) - return encode(deduped) + val fixed = fixProfileNullable(deduped) + return encode(fixed) } private fun encode(obj: JsonObject): String { @@ -101,6 +104,60 @@ internal object OpenApiSpecNormalizer { return JsonObject(root + mapOf("paths" to stripped)) } + /** + * 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 newSchema = JsonObject(schema + mapOf("properties" to fixed)) + val newApp = JsonObject( + (getOp["responses"]!!.let { it as JsonObject }["200"]!!.let { it as JsonObject }["content"]!!.let { it as JsonObject }["application/json"]!! + .let { it as JsonObject }) + mapOf("schema" to newSchema) + ) + val newContent = JsonObject( + (getOp["responses"]!!.let { it as JsonObject }["200"]!!.let { it as JsonObject }["content"]!! + .let { it as JsonObject }) + mapOf("application/json" to newApp) + ) + val new200 = JsonObject( + (getOp["responses"]!!.let { it as JsonObject }["200"]!!.let { it as JsonObject }) + mapOf("content" to newContent) + ) + val newResponses = JsonObject( + (getOp["responses"]!!.let { it as JsonObject }) + 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)) + } + /** * Deduplicate the root-level "tags" array by name — the spec validator * rejects repeated tag names even when they describe different things. 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 513f7b113d7..5f999c74625 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 @@ -104,6 +104,107 @@ class OpenApiSpecNormalizerTest { 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 = """