mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-09-24 16:02:55 +08:00
fix: address review feedback
This commit is contained in:
@@ -390,8 +390,8 @@ You can also set options that apply to all models from a provider:
|
||||
| `timeout` | `number \| false` | Request timeout in milliseconds. Defaults to `300000` (5 minutes); set to `false` to disable |
|
||||
| `chunkTimeout` | `number` | Timeout in milliseconds between streamed response chunks. If no chunk arrives within this window, the request is aborted and retried. This catches silent provider dropouts where the TCP connection stays open but SSE streaming stops. Recommended: `15000`–`30000` (15–30 seconds) for providers with unreliable streaming. |
|
||||
|
||||
{% callout type="warning" title="{env:} / {file:} only resolve in trusted config" %}
|
||||
`{env:VAR}` and `{file:...}` references in `apiKey` (or any option) are resolved **only** when the config lives in a trusted location: your global config (`~/.config/kilo`), a config passed via `KILO_CONFIG` / `KILO_CONFIG_CONTENT`, or organization/MDM-managed config. A project-level `kilo.json` / `opencode.json` committed to a repository **cannot** resolve `{env:VAR}` — the reference is ignored and a warning is logged, so a provider configured this way in a repo will not authenticate. This prevents a malicious repository from exfiltrating your secrets to an attacker-controlled `baseURL` just by being opened. `{file:...}` still works in project config, but only for files that resolve inside the project root — references that leave it (absolute paths outside the root, `../` traversal, and symlink escapes) are rejected. Keep provider credentials in your global config.
|
||||
{% callout type="warning" title="{env:} requires trusted config; {file:} is project-confined" %}
|
||||
`{env:VAR}` references in `apiKey` (or any option) resolve **only** when the config lives in a trusted location: your global config (`~/.config/kilo`), a config passed via `KILO_CONFIG` / `KILO_CONFIG_CONTENT`, or organization/MDM-managed config. A project-level `kilo.json` / `opencode.json` committed to a repository **cannot** resolve `{env:VAR}`; if one appears anywhere in that file, Kilo drops the entire file's config and logs a warning, so unrelated settings in the same file stop applying too. This prevents a malicious repository from exfiltrating your secrets to an attacker-controlled `baseURL` just by being opened. `{file:...}` works in trusted config and still works in project config, but only for files that resolve inside the project root — references that leave it (absolute paths outside the root, `../` traversal, and symlink escapes) are rejected. Keep provider credentials in your global config.
|
||||
{% /callout %}
|
||||
|
||||
## Filtering Available Models
|
||||
|
||||
@@ -465,8 +465,8 @@ Use `{env:VARIABLE_NAME}` syntax in config files to reference environment variab
|
||||
}
|
||||
```
|
||||
|
||||
{% callout type="warning" title="Only works in trusted config" %}
|
||||
`{env:VAR}` (and `{file:...}`) references are resolved **only** in trusted config: your global config (`~/.config/kilo`), a config passed via `KILO_CONFIG` / `KILO_CONFIG_CONTENT`, or organization/MDM-managed config. A project-level `kilo.json` / `opencode.json` committed to a repository **cannot** use `{env:VAR}` — the reference is ignored and a warning is logged. This prevents a malicious repository from exfiltrating your secrets to an attacker-controlled `baseURL` simply by being opened. `{file:...}` still works in project config, but only for files that resolve inside the project root — references that leave it (absolute paths outside the root, `../` traversal, and symlink escapes) are rejected.
|
||||
{% callout type="warning" title="{env:} requires trusted config; {file:} is project-confined" %}
|
||||
`{env:VAR}` references resolve **only** in trusted config: your global config (`~/.config/kilo`), a config passed via `KILO_CONFIG` / `KILO_CONFIG_CONTENT`, or organization/MDM-managed config. A project-level `kilo.json` / `opencode.json` committed to a repository **cannot** use `{env:VAR}`; if one appears anywhere in that file, Kilo drops the entire file's config and logs a warning, so unrelated settings in the same file stop applying too. This prevents a malicious repository from exfiltrating your secrets to an attacker-controlled `baseURL` simply by being opened. `{file:...}` works in trusted config and still works in project config, but only for files that resolve inside the project root — references that leave it (absolute paths outside the root, `../` traversal, and symlink escapes) are rejected.
|
||||
{% /callout %}
|
||||
|
||||
For full details on all configuration options including compaction, file watchers, plugins, and experimental features, see the [OpenCode Config documentation](https://opencode.ai/docs/config).
|
||||
|
||||
+99
-39
@@ -16,8 +16,10 @@ import okhttp3.Response
|
||||
import org.apache.commons.compress.archivers.tar.TarArchiveInputStream
|
||||
import org.apache.commons.compress.compressors.gzip.GzipCompressorInputStream
|
||||
import java.io.File
|
||||
import java.io.RandomAccessFile
|
||||
import java.security.MessageDigest
|
||||
import java.time.Instant
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.zip.ZipInputStream
|
||||
import kotlin.math.roundToInt
|
||||
@@ -36,55 +38,113 @@ class KiloCliDownloader(
|
||||
companion object {
|
||||
private val DIGEST = Regex("^sha256:[a-f0-9]{64}$")
|
||||
private val JSON = Json { ignoreUnknownKeys = true }
|
||||
private val LOCKS = ConcurrentHashMap<String, Any>()
|
||||
}
|
||||
|
||||
suspend fun resolve(version: String, force: Boolean = false, onProgress: (CliDownload) -> Unit = {}): File =
|
||||
withContext(Dispatchers.IO) {
|
||||
val platform = KiloCliPlatform.current()
|
||||
val dir = File(File(root, version), platform)
|
||||
val exe = File(dir, "bin/${KiloCliPlatform.exe()}")
|
||||
val done = File(dir, ".complete")
|
||||
val cached = done.takeIf { it.isFile }?.readText()?.trim()
|
||||
val ext = KiloCliPlatform.archive(platform)
|
||||
val archive = File(dir, "kilo-$platform.$ext")
|
||||
locked {
|
||||
val platform = KiloCliPlatform.current()
|
||||
val dir = File(File(root, version), platform)
|
||||
val exe = File(dir, "bin/${KiloCliPlatform.exe()}")
|
||||
val done = File(dir, ".complete")
|
||||
val ext = KiloCliPlatform.archive(platform)
|
||||
|
||||
if (!force && exe.isFile && cached != null && cached.matches(DIGEST)) {
|
||||
log.info("Using cached Kilo CLI $version for $platform at ${exe.absolutePath}")
|
||||
if (!SystemInfo.isWindows) exe.setExecutable(true)
|
||||
prune(version)
|
||||
return@withContext exe
|
||||
}
|
||||
if (!force) {
|
||||
cached(version, platform, exe, done)?.let { return@locked it }
|
||||
}
|
||||
|
||||
val digest = asset(version, platform, ext)
|
||||
val digest = asset(version, platform, ext)
|
||||
val stage = stage(version, platform)
|
||||
try {
|
||||
val archive = File(stage, "kilo-$platform.$ext")
|
||||
val staged = File(stage, "bin/${KiloCliPlatform.exe()}")
|
||||
val complete = File(stage, ".complete")
|
||||
|
||||
if (dir.exists()) {
|
||||
log.info("Deleting cached CLI $version under ${dir.absolutePath}")
|
||||
if (!dir.deleteRecursively()) {
|
||||
throw IllegalStateException("Failed to delete cached Kilo CLI $version under ${dir.absolutePath}")
|
||||
log.info(
|
||||
"Kilo CLI $version for $platform is not cached; downloading new release into ${stage.absolutePath}"
|
||||
)
|
||||
onProgress(CliDownload(0, version, platform))
|
||||
download(version, platform, ext, archive, onProgress)
|
||||
verify(archive, digest)
|
||||
log.info(
|
||||
"Downloaded Kilo CLI $version for $platform to ${archive.absolutePath} (size=${archive.length()} bytes)"
|
||||
)
|
||||
extract(archive, stage)
|
||||
if (!staged.isFile) {
|
||||
throw IllegalStateException("Downloaded CLI archive did not contain bin/${KiloCliPlatform.exe()}")
|
||||
}
|
||||
if (!SystemInfo.isWindows) staged.setExecutable(true)
|
||||
if (archive.exists() && !archive.delete()) {
|
||||
log.warn("Failed to delete extracted Kilo CLI archive ${archive.absolutePath}")
|
||||
}
|
||||
complete.writeText("$digest\n")
|
||||
replace(dir, stage)
|
||||
onProgress(CliDownload(100, version, platform))
|
||||
prune(version)
|
||||
exe
|
||||
} finally {
|
||||
if (stage.exists() && !stage.deleteRecursively()) {
|
||||
log.warn("Failed to delete staged Kilo CLI download ${stage.absolutePath}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!dir.isDirectory && !dir.mkdirs()) {
|
||||
throw IllegalStateException("Failed to create Kilo CLI cache directory ${dir.absolutePath}")
|
||||
}
|
||||
|
||||
log.info("Kilo CLI $version for $platform is not cached; downloading new release into ${dir.absolutePath}")
|
||||
onProgress(CliDownload(0, version, platform))
|
||||
download(version, platform, ext, archive, onProgress)
|
||||
verify(archive, digest)
|
||||
log.info("Downloaded Kilo CLI $version for $platform to ${archive.absolutePath} (size=${archive.length()} bytes)")
|
||||
extract(archive, dir)
|
||||
if (!exe.isFile) throw IllegalStateException("Downloaded CLI archive did not contain bin/${KiloCliPlatform.exe()}")
|
||||
if (!SystemInfo.isWindows) exe.setExecutable(true)
|
||||
if (archive.exists() && !archive.delete()) {
|
||||
log.warn("Failed to delete extracted Kilo CLI archive ${archive.absolutePath}")
|
||||
}
|
||||
done.writeText("$digest\n")
|
||||
onProgress(CliDownload(100, version, platform))
|
||||
prune(version)
|
||||
exe
|
||||
}
|
||||
|
||||
private fun cached(version: String, platform: String, exe: File, done: File): File? {
|
||||
val digest = done.takeIf { it.isFile }?.readText()?.trim()
|
||||
if (!exe.isFile || digest == null || !digest.matches(DIGEST)) return null
|
||||
log.info("Using cached Kilo CLI $version for $platform at ${exe.absolutePath}")
|
||||
if (!SystemInfo.isWindows) exe.setExecutable(true)
|
||||
prune(version)
|
||||
return exe
|
||||
}
|
||||
|
||||
private fun <T> locked(block: () -> T): T {
|
||||
if (!root.isDirectory && !root.mkdirs()) {
|
||||
throw IllegalStateException("Failed to create Kilo CLI cache root ${root.absolutePath}")
|
||||
}
|
||||
val file = File(root, ".lock").canonicalFile
|
||||
val mutex = LOCKS.computeIfAbsent(file.absolutePath) { Any() }
|
||||
return synchronized(mutex) {
|
||||
RandomAccessFile(file, "rw").channel.use { channel ->
|
||||
channel.lock().use { block() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun stage(version: String, platform: String): File {
|
||||
val tmp = File(root, ".tmp")
|
||||
val dir = File(tmp, "$version-$platform-${System.nanoTime()}")
|
||||
if (!dir.isDirectory && !dir.mkdirs()) {
|
||||
throw IllegalStateException("Failed to create Kilo CLI staging directory ${dir.absolutePath}")
|
||||
}
|
||||
return dir
|
||||
}
|
||||
|
||||
private fun replace(dir: File, stage: File) {
|
||||
val parent = dir.parentFile
|
||||
if (!parent.isDirectory && !parent.mkdirs()) {
|
||||
throw IllegalStateException("Failed to create Kilo CLI cache directory ${parent.absolutePath}")
|
||||
}
|
||||
|
||||
val backup = File(parent, ".${dir.name}.backup-${System.nanoTime()}")
|
||||
if (dir.exists() && !dir.renameTo(backup)) {
|
||||
throw IllegalStateException("Failed to move existing Kilo CLI cache ${dir.absolutePath} aside")
|
||||
}
|
||||
if (stage.renameTo(dir)) {
|
||||
if (backup.exists() && !backup.deleteRecursively()) {
|
||||
log.warn("Failed to delete previous Kilo CLI cache ${backup.absolutePath}")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (backup.exists() && !backup.renameTo(dir)) {
|
||||
log.warn("Failed to restore previous Kilo CLI cache ${backup.absolutePath} to ${dir.absolutePath}")
|
||||
}
|
||||
throw IllegalStateException("Failed to install Kilo CLI cache ${stage.absolutePath} to ${dir.absolutePath}")
|
||||
}
|
||||
|
||||
private fun fail(message: String): Nothing {
|
||||
log.warn(message)
|
||||
throw IllegalStateException(message)
|
||||
@@ -248,7 +308,7 @@ class KiloCliDownloader(
|
||||
private fun prune(keep: String) {
|
||||
val entries = root.listFiles() ?: return
|
||||
for (entry in entries) {
|
||||
if (!entry.isDirectory || entry.name == keep) continue
|
||||
if (!entry.isDirectory || entry.name == keep || entry.name.startsWith(".")) continue
|
||||
log.info("Removing stale Kilo CLI version ${entry.absolutePath}")
|
||||
if (!entry.deleteRecursively()) {
|
||||
log.warn("Failed to remove stale Kilo CLI version ${entry.absolutePath}")
|
||||
|
||||
+47
-10
@@ -48,7 +48,12 @@ class KiloCliDownloaderTest {
|
||||
assertEquals("/release/v1.2.3/kilo-${KiloCliPlatform.current()}.${KiloCliPlatform.archive()}", server.takeRequest().path)
|
||||
assertEquals(CliDownload(0, "1.2.3", KiloCliPlatform.current()), seen.first())
|
||||
assertTrue(seen.any { it.percent == 100 && it.version == "1.2.3" && it.platform == KiloCliPlatform.current() })
|
||||
assertContains(log.messages, "INFO: Kilo CLI 1.2.3 for ${KiloCliPlatform.current()} is not cached; downloading new release into ${cli.parentFile.parentFile.absolutePath}")
|
||||
assertTrue(
|
||||
log.messages.any {
|
||||
it.startsWith("INFO: Kilo CLI 1.2.3 for ${KiloCliPlatform.current()} is not cached; downloading new release into ") &&
|
||||
it.contains("/.tmp/")
|
||||
}
|
||||
)
|
||||
|
||||
val cachedProgress = mutableListOf<CliDownload>()
|
||||
val cached = KiloCliDownloader(
|
||||
@@ -113,23 +118,55 @@ class KiloCliDownloaderTest {
|
||||
@Test
|
||||
fun `forced resolve re-downloads and keeps only the active version`() = runBlocking {
|
||||
MockWebServer().use { server ->
|
||||
val bytes = archive()
|
||||
repeat(2) {
|
||||
server.enqueue(metadata(bytes))
|
||||
server.enqueue(MockResponse().setResponseCode(200).setBody(Buffer().write(bytes)))
|
||||
}
|
||||
val first = archive("#!/bin/old\n")
|
||||
val next = archive("#!/bin/new\n")
|
||||
server.enqueue(metadata(first))
|
||||
server.enqueue(MockResponse().setResponseCode(200).setBody(Buffer().write(first)))
|
||||
server.enqueue(metadata(next))
|
||||
server.enqueue(MockResponse().setResponseCode(200).setBody(Buffer().write(next)))
|
||||
val cli = KiloCliDownloader(
|
||||
root = dir,
|
||||
baseUrl = server.url("/release").toString(),
|
||||
api = server.url("/api").toString(),
|
||||
)
|
||||
cli.resolve("1.2.3")
|
||||
val old = cli.resolve("1.2.3")
|
||||
assertEquals("#!/bin/old\n", old.readText())
|
||||
assertEquals(2, server.requestCount)
|
||||
|
||||
val forced = cli.resolve("1.2.3", force = true)
|
||||
assertTrue(forced.isFile)
|
||||
assertEquals("#!/bin/new\n", forced.readText())
|
||||
assertEquals(4, server.requestCount)
|
||||
assertEquals(listOf("1.2.3"), dir.listFiles()?.filter { it.isDirectory }?.map { it.name })
|
||||
assertEquals(listOf("1.2.3"), dir.listFiles()?.filter { it.isDirectory && !it.name.startsWith(".") }?.map { it.name })
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `forced resolve keeps the existing cli when download fails`() = runBlocking {
|
||||
MockWebServer().use { server ->
|
||||
val bytes = archive("#!/bin/old\n")
|
||||
server.enqueue(metadata(bytes))
|
||||
server.enqueue(MockResponse().setResponseCode(200).setBody(Buffer().write(bytes)))
|
||||
server.enqueue(metadata(bytes))
|
||||
server.enqueue(MockResponse().setResponseCode(503).setBody("unavailable"))
|
||||
|
||||
val cli = KiloCliDownloader(
|
||||
root = dir,
|
||||
baseUrl = server.url("/release").toString(),
|
||||
api = server.url("/api").toString(),
|
||||
).resolve("1.2.3")
|
||||
val ex = assertFailsWith<IllegalStateException> {
|
||||
KiloCliDownloader(
|
||||
root = dir,
|
||||
baseUrl = server.url("/release").toString(),
|
||||
api = server.url("/api").toString(),
|
||||
).resolve("1.2.3", force = true)
|
||||
}
|
||||
|
||||
assertContains(ex.message.orEmpty(), "Failed to download")
|
||||
assertTrue(cli.isFile)
|
||||
assertEquals("#!/bin/old\n", cli.readText())
|
||||
assertTrue(File(cli.parentFile.parentFile, ".complete").isFile)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -232,9 +269,9 @@ class KiloCliDownloaderTest {
|
||||
}
|
||||
}
|
||||
|
||||
private fun archive(): ByteArray {
|
||||
private fun archive(script: String = "#!/bin/sh\n"): ByteArray {
|
||||
val files = mapOf(
|
||||
"bin/${KiloCliPlatform.exe()}" to "#!/bin/sh\n".toByteArray(),
|
||||
"bin/${KiloCliPlatform.exe()}" to script.toByteArray(),
|
||||
"bin/kilo-sandbox-mutation-worker.js" to "worker\n".toByteArray(),
|
||||
)
|
||||
if (KiloCliPlatform.archive() == "zip") return zip(files)
|
||||
|
||||
+31
-9
@@ -50,6 +50,9 @@ class KiloAppService internal constructor(
|
||||
/** Core version and platform from the backend, or null if unknown. */
|
||||
@Volatile
|
||||
private var info: CoreInfo? = null
|
||||
private val coreLock = Any()
|
||||
private val coreDone = mutableListOf<(CoreInfo?) -> Unit>()
|
||||
private var coreJob: Job? = null
|
||||
|
||||
val core: CoreInfo? get() = info
|
||||
|
||||
@@ -151,18 +154,37 @@ class KiloAppService internal constructor(
|
||||
|
||||
/** Fetch the pinned Core version and platform and cache it. */
|
||||
fun fetchCoreInfoAsync(done: (CoreInfo?) -> Unit = {}) {
|
||||
info?.let {
|
||||
done(it)
|
||||
val cached = info
|
||||
if (cached != null) {
|
||||
done(cached)
|
||||
return
|
||||
}
|
||||
cs.launch {
|
||||
LOG.info("fetchCoreInfo: requesting Core version and platform")
|
||||
val next = coreInfo()
|
||||
if (next == null) {
|
||||
done(null)
|
||||
return@launch
|
||||
|
||||
synchronized(coreLock) {
|
||||
val current = info
|
||||
if (current != null) {
|
||||
done(current)
|
||||
return
|
||||
}
|
||||
done(next)
|
||||
coreDone.add(done)
|
||||
if (coreJob != null) return
|
||||
coreJob = cs.launch {
|
||||
fetchCoreInfo()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun fetchCoreInfo() {
|
||||
LOG.info("fetchCoreInfo: requesting Core version and platform")
|
||||
val next = coreInfo()
|
||||
val list = synchronized(coreLock) {
|
||||
val items = coreDone.toList()
|
||||
coreDone.clear()
|
||||
coreJob = null
|
||||
items
|
||||
}
|
||||
list.forEach { it(next) }
|
||||
if (next != null) {
|
||||
LOG.info("fetchCoreInfo: Core version is ${next.version} (${next.platform})")
|
||||
}
|
||||
}
|
||||
|
||||
+2
-2
@@ -174,9 +174,9 @@ class PromptPanel(
|
||||
ed.scrollPane.border = JBUI.Borders.empty()
|
||||
ed.scrollPane.viewportBorder = JBUI.Borders.empty(
|
||||
0,
|
||||
JBUI.scale(SessionUiStyle.View.Prompt.SHELL_VERTICAL_PADDING),
|
||||
JBUI.scale(SessionUiStyle.View.Prompt.EDITOR_HORIZONTAL_INSET),
|
||||
0,
|
||||
JBUI.scale(SessionUiStyle.View.Prompt.SHELL_VERTICAL_PADDING),
|
||||
JBUI.scale(SessionUiStyle.View.Prompt.EDITOR_HORIZONTAL_INSET),
|
||||
)
|
||||
ed.backgroundColor = style.editorScheme.defaultBackground
|
||||
ed.scrollPane.background = style.editorScheme.defaultBackground
|
||||
|
||||
+2
@@ -78,6 +78,8 @@ object SessionUiStyle {
|
||||
const val CONTROL_GAP = 4
|
||||
const val SHELL_VERTICAL_PADDING = 6
|
||||
const val SHELL_HORIZONTAL_PADDING = 8
|
||||
// Horizontal editor inset intentionally matches vertical shell padding to balance text and chrome.
|
||||
const val EDITOR_HORIZONTAL_INSET = SHELL_VERTICAL_PADDING
|
||||
|
||||
fun separator(): Color = JBColor.namedColor(
|
||||
"EditorTabs.underTabsBorderColor",
|
||||
|
||||
+44
@@ -7,11 +7,14 @@ import ai.kilocode.rpc.dto.ProfileBalanceDto
|
||||
import ai.kilocode.rpc.dto.ProfileDto
|
||||
import ai.kilocode.rpc.dto.ProfileOrganizationDto
|
||||
import com.intellij.testFramework.fixtures.BasePlatformTestCase
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.yield
|
||||
import kotlinx.coroutines.withTimeout
|
||||
|
||||
/**
|
||||
* Service-level tests for [KiloAppService] profile/login/logout/org operations.
|
||||
@@ -49,6 +52,47 @@ class KiloAppServiceTest : BasePlatformTestCase() {
|
||||
currentOrgId: String? = null,
|
||||
) = ProfileDto(email = email, name = name, organizations = orgs, balance = balance, currentOrgId = currentOrgId)
|
||||
|
||||
private suspend fun waitUntil(done: () -> Boolean) {
|
||||
withTimeout(5_000) {
|
||||
while (!done()) yield()
|
||||
}
|
||||
}
|
||||
|
||||
fun `test fetchCoreInfoAsync dedupes in flight requests`() = runBlocking(Dispatchers.Default) {
|
||||
rpc.cliInfoGate = CompletableDeferred()
|
||||
val seen = mutableListOf<CoreInfo?>()
|
||||
|
||||
app.fetchCoreInfoAsync { seen.add(it) }
|
||||
app.fetchCoreInfoAsync { seen.add(it) }
|
||||
waitUntil { rpc.cliVersionCalls == 1 }
|
||||
|
||||
assertEquals(1, rpc.cliVersionCalls)
|
||||
assertEquals(0, rpc.cliPlatformCalls)
|
||||
rpc.cliInfoGate!!.complete(Unit)
|
||||
waitUntil { seen.size == 2 }
|
||||
|
||||
assertEquals(1, rpc.cliVersionCalls)
|
||||
assertEquals(1, rpc.cliPlatformCalls)
|
||||
assertEquals(listOf(CoreInfo("1.0.0", "darwin-arm64"), CoreInfo("1.0.0", "darwin-arm64")), seen)
|
||||
}
|
||||
|
||||
fun `test fetchCoreInfoAsync retries after failure`() = runBlocking(Dispatchers.Default) {
|
||||
val seen = mutableListOf<CoreInfo?>()
|
||||
rpc.cliInfoError = RuntimeException("core failed")
|
||||
|
||||
app.fetchCoreInfoAsync { seen.add(it) }
|
||||
waitUntil { seen.size == 1 }
|
||||
|
||||
assertNull(seen.single())
|
||||
assertNull(app.core)
|
||||
rpc.cliInfoError = null
|
||||
app.fetchCoreInfoAsync { seen.add(it) }
|
||||
waitUntil { seen.size == 2 }
|
||||
|
||||
assertEquals(CoreInfo("1.0.0", "darwin-arm64"), seen.last())
|
||||
assertEquals(2, rpc.cliVersionCalls)
|
||||
}
|
||||
|
||||
// ------ refreshProfile ------
|
||||
|
||||
fun `test refreshProfile updates app state profile on success`() = runBlocking(Dispatchers.Default) {
|
||||
|
||||
+2
-2
@@ -175,13 +175,13 @@ class PromptPanelTest : BasePlatformTestCase() {
|
||||
assertSame(editor, applied)
|
||||
}
|
||||
|
||||
fun `test prompt editor horizontal insets match top padding`() {
|
||||
fun `test prompt editor horizontal insets use dedicated prompt inset`() {
|
||||
val panel = PromptPanel(project = project, onSend = { _, _ -> }, onAbort = {}, onEnhance = { _, _ -> })
|
||||
|
||||
realize(panel, 260, 400)
|
||||
val editor = (panel.defaultFocusedComponent as EditorTextField).getEditor(false)!!
|
||||
val ins = editor.scrollPane.viewportBorder.getBorderInsets(editor.scrollPane)
|
||||
val pad = JBUI.scale(SessionUiStyle.View.Prompt.SHELL_VERTICAL_PADDING)
|
||||
val pad = JBUI.scale(SessionUiStyle.View.Prompt.EDITOR_HORIZONTAL_INSET)
|
||||
|
||||
assertEquals(pad, ins.left)
|
||||
assertEquals(pad, ins.right)
|
||||
|
||||
+11
@@ -33,6 +33,12 @@ class FakeAppRpcApi : KiloAppRpcApi {
|
||||
var health = HealthDto(healthy = true, version = "1.0.0")
|
||||
var cliVersion = "1.0.0"
|
||||
var cliPlatform = "darwin-arm64"
|
||||
var cliInfoGate: CompletableDeferred<Unit>? = null
|
||||
var cliInfoError: Exception? = null
|
||||
var cliVersionCalls = 0
|
||||
private set
|
||||
var cliPlatformCalls = 0
|
||||
private set
|
||||
var models = ModelStateDto()
|
||||
val selections = mutableListOf<ModelSelectionUpdateDto>()
|
||||
val cleared = mutableListOf<String>()
|
||||
@@ -71,11 +77,16 @@ class FakeAppRpcApi : KiloAppRpcApi {
|
||||
|
||||
override suspend fun cliVersion(): String {
|
||||
assertNotEdt("cliVersion")
|
||||
cliVersionCalls += 1
|
||||
cliInfoGate?.await()
|
||||
cliInfoError?.let { throw it }
|
||||
return cliVersion
|
||||
}
|
||||
|
||||
override suspend fun cliPlatform(): String {
|
||||
assertNotEdt("cliPlatform")
|
||||
cliPlatformCalls += 1
|
||||
cliInfoError?.let { throw it }
|
||||
return cliPlatform
|
||||
}
|
||||
|
||||
|
||||
@@ -47,4 +47,19 @@ describe("splitConfigByScope", () => {
|
||||
})
|
||||
expect(split.project).toEqual({})
|
||||
})
|
||||
|
||||
it("writes sandbox writable paths to global config", () => {
|
||||
const split = splitConfigByScope({
|
||||
experimental: {
|
||||
sandbox_writable_paths: ["/tmp/kilo"],
|
||||
},
|
||||
})
|
||||
|
||||
expect(split.global).toEqual({
|
||||
experimental: {
|
||||
sandbox_writable_paths: ["/tmp/kilo"],
|
||||
},
|
||||
})
|
||||
expect(split.project).toEqual({})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { describe, expect, it } from "bun:test"
|
||||
import { cleanupSpeechCapture } from "../../webview-ui/src/components/speech-to-text/speech-cleanup"
|
||||
|
||||
describe("speech-to-text button", () => {
|
||||
it("cancels active capture on component cleanup", () => {
|
||||
let cancels = 0
|
||||
|
||||
cleanupSpeechCapture({
|
||||
active: () => true,
|
||||
cancel: () => {
|
||||
cancels += 1
|
||||
},
|
||||
})
|
||||
|
||||
expect(cancels).toBe(1)
|
||||
})
|
||||
|
||||
it("does not cancel idle capture on component cleanup", () => {
|
||||
let cancels = 0
|
||||
|
||||
cleanupSpeechCapture({
|
||||
active: () => false,
|
||||
cancel: () => {
|
||||
cancels += 1
|
||||
},
|
||||
})
|
||||
|
||||
expect(cancels).toBe(0)
|
||||
})
|
||||
})
|
||||
@@ -1394,7 +1394,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
<WandSparkles size={16} class={enhancing() ? "enhance-spinner" : ""} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Show when={canUseSpeech()}>
|
||||
<Show when={canUseSpeech() || speech.active()}>
|
||||
<SpeechToTextButton speech={speech} disabled={isDisabled()} start={startSpeech} label={language.t} />
|
||||
</Show>
|
||||
<Show
|
||||
|
||||
+2
-3
@@ -3,6 +3,7 @@ import { Tooltip } from "@kilocode/kilo-ui/tooltip"
|
||||
import { Spinner } from "@kilocode/kilo-ui/spinner"
|
||||
import { onCleanup, type Component } from "solid-js"
|
||||
import type { SpeechToText } from "./useSpeechToText"
|
||||
import { cleanupSpeechCapture } from "./speech-cleanup"
|
||||
|
||||
type Props = {
|
||||
speech: SpeechToText
|
||||
@@ -41,9 +42,7 @@ export const SpeechToTextButton: Component<Props> = (props) => {
|
||||
props.start()
|
||||
}
|
||||
|
||||
onCleanup(() => {
|
||||
if (props.speech.active()) props.speech.cancel()
|
||||
})
|
||||
onCleanup(() => cleanupSpeechCapture(props.speech))
|
||||
|
||||
return (
|
||||
<Tooltip value={label()} placement="top">
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import type { SpeechToText } from "./useSpeechToText"
|
||||
|
||||
export function cleanupSpeechCapture(speech: Pick<SpeechToText, "active" | "cancel">) {
|
||||
if (speech.active()) speech.cancel()
|
||||
}
|
||||
@@ -1738,11 +1738,8 @@ function StepFinishPart(props: { last: boolean; part: StepFinishPart; message: A
|
||||
const { theme } = useTheme()
|
||||
const info = useContext(RoutedModelMeta.Context)
|
||||
const routed = createMemo(() => {
|
||||
if (props.message.providerID !== "kilo") return undefined
|
||||
if (!props.message.modelID.startsWith("kilo-auto/")) return undefined
|
||||
const model = props.part.model
|
||||
if (!model) return undefined
|
||||
if (model.providerID === props.message.providerID && model.modelID === props.message.modelID) return undefined
|
||||
if (!RoutedModelMeta.routed(model, props.message)) return undefined
|
||||
return RoutedModelMeta.label(ctx.providers(), model)
|
||||
})
|
||||
const consumed = createMemo(() => info().consumed.has(props.part.id))
|
||||
|
||||
@@ -299,7 +299,7 @@ function resolveMotion(text: string, pos: number, key: string, count: number, aw
|
||||
case "$": {
|
||||
let t = pos
|
||||
for (let i = 0; i < reps; i++) t = lineEnd(text, lineEnd(text, t) + (i === 0 ? 0 : 1))
|
||||
return { target: lineEnd(text, t), inclusive: true, linewise: false }
|
||||
return { target: Math.max(lineStart(text, t), lineEnd(text, t) - 1), inclusive: true, linewise: false }
|
||||
}
|
||||
case "G": {
|
||||
let target = 0
|
||||
|
||||
@@ -39,10 +39,10 @@ export namespace RoutedModelMeta {
|
||||
return KiloRoutedModel.displayName(text)
|
||||
}
|
||||
|
||||
function routed(model: StepFinishPart["model"], message: Message) {
|
||||
export function routed(model: StepFinishPart["model"], message: Message) {
|
||||
if (!model) return undefined
|
||||
if (message.providerID !== "kilo") return undefined
|
||||
if (!message.modelID.startsWith("kilo-auto/")) return undefined
|
||||
if (!KiloRoutedModel.routedSelection(message.modelID)) return undefined
|
||||
if (model.providerID === message.providerID && model.modelID === message.modelID) return undefined
|
||||
return model
|
||||
}
|
||||
|
||||
@@ -47,10 +47,15 @@ export namespace KiloRoutedModel {
|
||||
input: { providerID: ProviderID; modelID: string; selected?: string },
|
||||
) {
|
||||
if (input.providerID !== ProviderID.kilo) return undefined
|
||||
if (!input.modelID.startsWith("kilo-auto/") && !input.modelID.includes("fable")) return undefined
|
||||
if (!routedSelection(input.modelID)) return undefined
|
||||
const model = read(meta, input.providerID)
|
||||
if (!model) return undefined
|
||||
if (model.modelID === input.modelID || model.modelID === input.selected) return undefined
|
||||
return model
|
||||
}
|
||||
|
||||
export function routedSelection(modelID: string) {
|
||||
const id = modelID.trim()
|
||||
return id.startsWith("kilo-auto/") || /(?:^|[/.])claude-fable(?:[-.:/]|$)/.test(id)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -199,6 +199,28 @@ describe("vim edits", () => {
|
||||
expect(doc.text).toBe("bar baz")
|
||||
})
|
||||
|
||||
test("d$ deletes line content without consuming the newline", () => {
|
||||
const doc = new MockDoc("abc\ndef", 0)
|
||||
const state = createVimState("normal")
|
||||
feed(doc, state, "d$")
|
||||
expect(doc.text).toBe("\ndef")
|
||||
})
|
||||
|
||||
test("c$ changes line content without consuming the newline", () => {
|
||||
const doc = new MockDoc("abc\ndef", 0)
|
||||
const state = createVimState("normal")
|
||||
feed(doc, state, "c$")
|
||||
expect(doc.text).toBe("\ndef")
|
||||
expect(state.mode).toBe("insert")
|
||||
})
|
||||
|
||||
test("y$ yanks line content without the newline", () => {
|
||||
const doc = new MockDoc("abc\ndef", 0)
|
||||
const state = createVimState("normal")
|
||||
feed(doc, state, "y$")
|
||||
expect(state.register).toEqual({ text: "abc", linewise: false })
|
||||
})
|
||||
|
||||
test("dd deletes the current line", () => {
|
||||
const doc = new MockDoc("one\ntwo\nthree", 4)
|
||||
const state = createVimState("normal")
|
||||
|
||||
@@ -243,6 +243,51 @@ describe("kilocode indexing config", () => {
|
||||
})
|
||||
|
||||
describe("kilocode sandbox writable paths config", () => {
|
||||
test("honors sandbox_writable_paths from global config", async () => {
|
||||
await using globalTmp = await tmpdir()
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
|
||||
const prev = Global.Path.config
|
||||
;(Global.Path as { config: string }).config = globalTmp.path
|
||||
await clear()
|
||||
await disposeAllInstances()
|
||||
|
||||
try {
|
||||
await writeConfig(globalTmp.path, {
|
||||
$schema: "https://app.kilo.ai/config.json",
|
||||
experimental: { sandbox_writable_paths: ["/tmp/global"] },
|
||||
})
|
||||
|
||||
await provideTestInstance({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const config = await load()
|
||||
expect(config.experimental?.sandbox_writable_paths).toEqual(["/tmp/global"])
|
||||
},
|
||||
})
|
||||
} finally {
|
||||
;(Global.Path as { config: string }).config = prev
|
||||
await clear()
|
||||
await disposeAllInstances()
|
||||
}
|
||||
})
|
||||
|
||||
test("drops sandbox_writable_paths from project config", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
|
||||
await writeConfig(tmp.path, {
|
||||
experimental: { sandbox_writable_paths: ["/tmp/project"] },
|
||||
})
|
||||
|
||||
await provideTestInstance({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const config = await load()
|
||||
expect(config.experimental?.sandbox_writable_paths).toBeUndefined()
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("honors sandbox_writable_paths from global config only, ignoring project config", async () => {
|
||||
await using globalTmp = await tmpdir()
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
|
||||
@@ -103,7 +103,7 @@ describe("session routed model", () => {
|
||||
expect(KiloRoutedModel.displayName("o3")).toBe("o3")
|
||||
})
|
||||
|
||||
test("shows compact labels only for Kilo auto selections", () => {
|
||||
test("shows compact labels only for routed Kilo selections", () => {
|
||||
const model = { providerID: "openai", modelID: "gpt-5.5" }
|
||||
const parts = [reason, finish(model)]
|
||||
|
||||
@@ -115,6 +115,13 @@ describe("session routed model", () => {
|
||||
expect(routed.footer).toBe("gpt-5.5")
|
||||
expect(routed.consumed.has("finish")).toBe(true)
|
||||
|
||||
const fable = RoutedModelMeta.info(undefined, parts, false, {
|
||||
providerID: "kilo",
|
||||
modelID: "anthropic/claude-fable-5",
|
||||
})
|
||||
expect(fable.labels.get("reasoning")).toBe("gpt-5.5")
|
||||
expect(fable.footer).toBe("gpt-5.5")
|
||||
|
||||
const explicit = RoutedModelMeta.info(undefined, parts, false, {
|
||||
providerID: "openai",
|
||||
modelID: "gpt-5.5",
|
||||
@@ -169,7 +176,7 @@ describe("session routed model", () => {
|
||||
expect(routed.consumed.has("last")).toBe(false)
|
||||
})
|
||||
|
||||
test("reads routed model only for selected Kilo auto models", () => {
|
||||
test("reads routed model only for selected Kilo routed models", () => {
|
||||
const meta = { kilocode: { routedModelID: "openai/gpt-5.5-20260423" } }
|
||||
|
||||
expect(
|
||||
@@ -182,6 +189,22 @@ describe("session routed model", () => {
|
||||
modelID: ModelID.make("openai/gpt-5.5-20260423"),
|
||||
})
|
||||
|
||||
expect(
|
||||
KiloRoutedModel.readAuto(meta, {
|
||||
providerID: ProviderID.kilo,
|
||||
modelID: "anthropic.claude-fable-5",
|
||||
}),
|
||||
).toEqual({
|
||||
providerID: ProviderID.kilo,
|
||||
modelID: ModelID.make("openai/gpt-5.5-20260423"),
|
||||
})
|
||||
|
||||
expect(
|
||||
KiloRoutedModel.readAuto(meta, {
|
||||
providerID: ProviderID.kilo,
|
||||
modelID: "openai/affable-model",
|
||||
}),
|
||||
).toBeUndefined()
|
||||
expect(
|
||||
KiloRoutedModel.readAuto(meta, {
|
||||
providerID: ProviderID.kilo,
|
||||
|
||||
Reference in New Issue
Block a user