Merge remote-tracking branch 'origin/main' into johnnyeric/kilo-opencode-v1.17.13

# Conflicts:
#	packages/core/src/filesystem/search.ts
#	packages/opencode/src/command/index.ts
This commit is contained in:
Johnny Eric Amancio
2026-08-04 18:36:43 +02:00
79 changed files with 5603 additions and 448 deletions
+6
View File
@@ -0,0 +1,6 @@
---
"@kilocode/cli": patch
"kilo-code": patch
---
Prevent VS Code sessions and Agent Manager worktrees from starting unused file watchers and defer file indexing until search is used.
+5
View File
@@ -0,0 +1,5 @@
---
"kilo-code": patch
---
Fix Agent Manager progress indicators when multiple projects are expanded.
@@ -0,0 +1,5 @@
---
"kilo-code": patch
---
Scope Agent Manager session events and Git status to the active project, including edits inside nested repositories.
+5
View File
@@ -0,0 +1,5 @@
---
"@kilocode/kilo-jetbrains": patch
---
Avoid GitHub API rate-limit failures when the JetBrains plugin downloads the pinned Kilo CLI.
+5
View File
@@ -0,0 +1,5 @@
---
"kilo-code": patch
---
Allow Agent Manager project headers to collapse or expand their project body, and persist that state across panel opens and VS Code restarts.
+5
View File
@@ -0,0 +1,5 @@
---
"kilo-code": patch
---
Keep Agent Manager worktree rows isolated when projects contain identical raw worktree IDs.
@@ -0,0 +1,5 @@
---
"kilo-code": patch
---
Remove the redundant selected-project indicator from Agent Manager.
+5
View File
@@ -0,0 +1,5 @@
---
"@kilocode/cli": minor
---
Import conversation history from Claude Code and OpenAI Codex sessions with the /resume-claude and /resume-codex slash commands.
@@ -0,0 +1,5 @@
---
"kilo-code": patch
---
Show only one empty-state panel after removing the last Agent Manager worktree.
+5
View File
@@ -0,0 +1,5 @@
---
"kilo-code": patch
---
Persist the Agent Manager inspector width and share it between the terminal and diff viewer.
+5
View File
@@ -0,0 +1,5 @@
---
"kilo-code": patch
---
Keep multi-project worktree rename inputs focused while selection updates are still settling.
+84 -47
View File
@@ -2,7 +2,7 @@ export * as FileSystemSearch from "./search"
import { makeLocationNode } from "../effect/app-node"
import path from "path"
import { Context, Effect, Layer, Scope } from "effect"
import { Context, Duration, Effect, Layer, Scope } from "effect"
import { Fff } from "#fff"
import fuzzysort from "fuzzysort"
import { FileSystem } from "../filesystem"
@@ -78,11 +78,13 @@ export const ripgrepLayer = Layer.effect(
})
.pipe(
Effect.map((result) =>
result.items.map((entry) => // kilocode_change - validate wraps results in SearchResult
FileSystem.Entry.make({
...entry,
path: RelativePath.make(path.relative(location.directory, path.resolve(cwd, entry.path))),
}),
result.items.map(
// kilocode_change - validate wraps results in SearchResult
(entry) =>
FileSystem.Entry.make({
...entry,
path: RelativePath.make(path.relative(location.directory, path.resolve(cwd, entry.path))),
}),
),
),
Effect.orDie,
@@ -105,14 +107,16 @@ export const ripgrepLayer = Layer.effect(
})
.pipe(
Effect.map((result) =>
result.items.map((match) => // kilocode_change - validate wraps results in SearchResult
FileSystem.Match.make({
...match,
entry: FileSystem.Entry.make({
...match.entry,
path: RelativePath.make(path.relative(location.directory, path.resolve(cwd, match.entry.path))),
result.items.map(
// kilocode_change - validate wraps results in SearchResult
(match) =>
FileSystem.Match.make({
...match,
entry: FileSystem.Entry.make({
...match.entry,
path: RelativePath.make(path.relative(location.directory, path.resolve(cwd, match.entry.path))),
}),
}),
}),
),
),
Effect.orDie,
@@ -162,36 +166,65 @@ export const fffLayer = Layer.effect(
return real !== undefined && FSUtil.contains(root.path, real)
})
// kilocode_change end
const result = yield* Effect.try({
try: () =>
Fff.create({
basePath: location.directory,
aiMode: true,
...scanning(location.directory), // kilocode_change - permit broad scanning only at the exact boundary.
}),
catch: (cause) => cause,
}).pipe(
Effect.catch((error) => Effect.logWarning("failed to initialize fff", { error }).pipe(Effect.as(undefined))),
// kilocode_change start - defer FFF until search because other location consumers do not need its native index.
const scope = yield* Scope.Scope
const release = (entry: { finder: { destroy(): void }; closed: boolean }) =>
Effect.sync(() => {
if (entry.closed) return
entry.closed = true
entry.finder.destroy()
}).pipe(Effect.ignore)
const make = Effect.uninterruptible(
Effect.gen(function* () {
const result = yield* Effect.try({
try: () =>
Fff.create({
basePath: location.directory,
aiMode: true,
disableMmapCache: true,
disableContentIndexing: true,
...scanning(location.directory),
}),
catch: (cause) => cause,
}).pipe(Effect.orDie)
if (!result.ok) return yield* Effect.die(result.error)
const entry = { finder: result.value, closed: false }
yield* Scope.addFinalizer(scope, release(entry))
return entry
}),
)
if (!result?.ok) {
if (result) yield* Effect.logWarning("failed to initialize fff", { error: result.error })
return Service.of({
find: () => Effect.succeed([]),
glob: () => Effect.succeed([]),
grep: () => Effect.succeed([]),
})
}
yield* Effect.addFinalizer(() => Effect.sync(() => result.value.destroy()).pipe(Effect.ignore))
const [load, invalidate] = yield* Effect.cachedInvalidateWithTTL(
make.pipe(
Effect.flatMap((entry) =>
Effect.promise(() => entry.finder.waitForScan())
.pipe(
Effect.orDie,
Effect.onError(() => release(entry)),
)
.pipe(
Effect.flatMap((scan) => {
if (!scan.ok || !scan.value) return Effect.die(new Error("FFF initial scan did not complete"))
return Effect.succeed(entry)
}),
),
),
),
Duration.infinity,
)
const get = load.pipe(Effect.onError(() => invalidate))
yield* Scope.addFinalizer(scope, invalidate)
// kilocode_change end
return Service.of({
glob: (input) =>
// kilocode_change start
Effect.gen(function* () {
const { root, target } = yield* inspect(input.path)
// kilocode_change end
const result = yield* get
// kilocode_change end
const prefix = input.path?.replaceAll("\\", "/").replace(/\/$/, "")
// kilocode_change start
const found = yield* Effect.sync(() =>
result.value.glob(prefix ? `${prefix}/${input.pattern}` : input.pattern, {
result.finder.glob(prefix ? `${prefix}/${input.pattern}` : input.pattern, {
pageIndex: 0,
pageSize: input.limit,
}),
@@ -213,17 +246,19 @@ export const fffLayer = Layer.effect(
// kilocode_change start
Effect.gen(function* () {
const { root, target } = yield* inspect(input.path)
// kilocode_change end
const result = yield* get
// kilocode_change end
const prefix = input.path?.replaceAll("\\", "/").replace(/\/$/, "")
// kilocode_change start
const found = yield* Effect.sync(() =>
result.value.grep(
[prefix ? `${prefix}/**` : undefined, input.include, input.pattern]
.filter((value) => value !== undefined)
.join(" "),
{ mode: "regex", pageSize: input.limit, timeBudgetMs: 1_500 },
),
// kilocode_change end
const found = yield* Effect.sync(
() =>
result.finder.grep(
[prefix ? `${prefix}/**` : undefined, input.include, input.pattern]
.filter((value) => value !== undefined)
.join(" "),
{ mode: "regex", pageSize: input.limit, timeBudgetMs: 1_500 },
),
// kilocode_change end
)
if (!found.ok) throw found.error
// kilocode_change start
@@ -249,11 +284,13 @@ export const fffLayer = Layer.effect(
// kilocode_change end
}),
find: (input) =>
Effect.sync(() => {
Effect.gen(function* () {
// kilocode_change - load the native index only for an actual search.
const result = yield* get // kilocode_change
const options = { pageIndex: 0, pageSize: input.limit ?? 50 }
const items = (() => {
if (input.type === "file") {
const found = result.value.fileSearch(input.query.trim(), options)
const found = result.finder.fileSearch(input.query.trim(), options)
if (!found.ok) throw found.error
return found.value.items.map((item, index) => ({
path: item.relativePath,
@@ -262,7 +299,7 @@ export const fffLayer = Layer.effect(
}))
}
if (input.type === "directory") {
const found = result.value.directorySearch(input.query.trim(), options)
const found = result.finder.directorySearch(input.query.trim(), options)
if (!found.ok) throw found.error
return found.value.items.map((item, index) => ({
path: item.relativePath,
@@ -270,7 +307,7 @@ export const fffLayer = Layer.effect(
score: found.value.scores[index]?.total ?? 0,
}))
}
const found = result.value.mixedSearch(input.query.trim(), options)
const found = result.finder.mixedSearch(input.query.trim(), options)
if (!found.ok) throw found.error
return found.value.items.map((item, index) => ({
path: item.item.relativePath,
+87
View File
@@ -1,7 +1,16 @@
import { describe, expect, test } from "bun:test"
import { FileFinder, type InitOptions } from "@ff-labs/fff-bun"
import "@opencode-ai/core/filesystem"
import { Fff } from "@opencode-ai/core/filesystem/fff.bun"
import { FSUtil } from "@opencode-ai/core/fs-util"
import os from "os"
import path from "path"
import { Context, Effect, Layer, Scope } from "effect"
import { scanning } from "@opencode-ai/core/kilocode/fff"
import { Location } from "@opencode-ai/core/location"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { location } from "../fixture/location"
import { tmpdir } from "../fixture/tmpdir"
describe("FFF scanning boundaries", () => {
test("enables filesystem-root scanning only at the exact root", () => {
@@ -25,3 +34,81 @@ describe("FFF scanning boundaries", () => {
})
})
})
describe("FFF lifecycle", () => {
test("retries a failed first search and reuses one picker", async () => {
if (!Fff.available()) return
const dir = await tmpdir()
const create = FileFinder.create
const calls = { create: 0, destroy: 0, opts: undefined as InitOptions | undefined }
try {
FileFinder.create = (opts) => {
calls.create++
if (calls.create === 1) return { ok: false, error: "transient failure" }
calls.opts = opts
const result = create(opts)
if (!result.ok) return result
const destroy = result.value.destroy.bind(result.value)
result.value.destroy = () => {
calls.destroy++
destroy()
}
return result
}
await Effect.runPromise(
Effect.acquireUseRelease(
Scope.make(),
(scope) =>
Effect.gen(function* () {
const { FileSystemSearch } = yield* Effect.promise(() => import("@opencode-ai/core/filesystem/search"))
const layer = FileSystemSearch.fffLayer.pipe(
Layer.provide(FSUtil.defaultLayer),
Layer.provide(
Layer.succeed(
Location.Service,
Location.Service.of(
location(
{ directory: AbsolutePath.make(dir.path) },
{ vcs: { type: "git", store: AbsolutePath.make(path.join(dir.path, ".git")) } },
),
),
),
),
)
const context = yield* Layer.buildWithScope(layer, scope)
const service = Context.get(context, FileSystemSearch.Service)
expect(calls.create).toBe(0)
const first = yield* Effect.exit(
Effect.all(
[
service.find({ query: "", type: "file", limit: 1 }),
service.find({ query: "", type: "file", limit: 1 }),
],
{ concurrency: "unbounded" },
),
)
expect(first._tag).toBe("Failure")
expect(calls.create).toBe(1)
yield* service.find({ query: "", type: "file", limit: 1 })
expect(calls.create).toBe(2)
expect(calls.opts?.disableMmapCache).toBe(true)
expect(calls.opts?.disableContentIndexing).toBe(true)
yield* service.find({ query: "", type: "file", limit: 1 })
expect(calls.create).toBe(2)
expect(calls.destroy).toBe(0)
}),
(scope, exit) => Scope.close(scope, exit),
),
)
expect(calls.destroy).toBe(1)
} finally {
FileFinder.create = create
await dir[Symbol.asyncDispose]()
}
})
})
+12 -6
View File
@@ -81,17 +81,21 @@ const attachCollecting = Effect.fn("PtySessionTest.attachCollecting")(function*
return { attachment, output, ended }
})
const waitForOutput = (output: Queue.Queue<string>, text: string) =>
Effect.gen(function* () {
let received = ""
// kilocode_change start - preserve collected PTY output in timeout diagnostics
const waitForOutput = (output: Queue.Queue<string>, text: string) => {
let received = ""
const pull = Effect.gen(function* () {
while (!received.includes(text)) received += yield* Queue.take(output)
return received
}).pipe(
})
return pull.pipe(
Effect.timeoutOrElse({
duration: PTY_TEST_TIMEOUT, // kilocode_change
orElse: () => Effect.fail(new Error(`timeout waiting for output containing ${JSON.stringify(text)}`)),
orElse: () => Effect.fail(new Error(`timeout waiting for output containing ${JSON.stringify(text)}, received ${JSON.stringify(received)}`)),
}),
)
}
// kilocode_change end
describe("pty", () => {
it.live("returns typed not found errors for missing sessions", () =>
@@ -144,11 +148,13 @@ describe("pty", () => {
)
// (script terminals forward raw output to xterm without transcoding).
// The child must outlive its output: an immediate exit can race bun-pty's
// reader thread and drop trailing bytes under load, so print then sleep.
ptyTest("round-trips non-ASCII output byte-identically", () =>
Effect.gen(function* () {
const pty = yield* Pty.Service
const marker = "café-über-北京-🚀"
const info = yield* createPty("sh", ["-c", "printf 'caf\\303\\251-\\303\\274ber-\\345\\214\\227\\344\\272\\254-\\360\\237\\232\\200\\n'"])
const info = yield* createPty("sh", ["-c", "printf 'caf\\303\\251-\\303\\274ber-\\345\\214\\227\\344\\272\\254-\\360\\237\\232\\200\\n'; sleep 5"])
const attached = yield* attachCollecting(info.id)
expect(yield* waitForOutput(attached.output, marker)).toContain(marker)
}),
@@ -18,7 +18,7 @@ If you'd like to migrate your memory bank content to AGENTS.md:
1. Examine the contents in `.kilocode/rules/memory-bank/`
2. Move that content into your project's `AGENTS.md` file (or ask Kilo to do it for you)
{% /callout %}
{% /callout %}
## What is AGENTS.md?
@@ -20,9 +20,11 @@ val rawSpec = layout.buildDirectory.file("generated/openapi-spec/openapi.raw.jso
val generatedSpec = layout.buildDirectory.file("generated/openapi-spec/openapi.json")
val generatedProps = layout.buildDirectory.dir("generated/kilo-props")
val generatedCli = layout.buildDirectory.dir("generated/kilo-cli-res")
val generatedChecksums = layout.buildDirectory.dir("generated/kilo-cli-checksums")
val pinned = providers.gradleProperty("kilo.cli.pinned").map { it.trim().toBoolean() }.orElse(true)
val repoCli = pinned.map { !it }
val bundled = providers.gradleProperty("kilo.cli.bundled").map { it.trim().toBoolean() }.orElse(false)
val downloadsCli = repoCli.zip(bundled) { repo, bundle -> !repo && !bundle }
val repoRootDir = rootProject.layout.projectDirectory.dir("../opencode")
val pinnedCliVersion = providers.fileContents(rootProject.layout.projectDirectory.file("package.json")).asText.map { text ->
@@ -33,6 +35,7 @@ val pinnedCliVersion = providers.fileContents(rootProject.layout.projectDirector
sourceSets {
main {
resources.srcDir(generatedProps)
if (downloadsCli.get()) resources.srcDir(generatedChecksums)
if (repoCli.get() || bundled.get()) resources.srcDir(generatedCli)
kotlin.srcDir(generatedApi)
}
@@ -104,6 +107,16 @@ val stageBundledCli by tasks.registering(StageBundledCliTask::class) {
archive.set(generatedCli.map { it.file("kilo-cli.zip") })
}
val writeCliChecksums by tasks.registering(WriteCliChecksumsTask::class) {
description = "Write pinned Kilo CLI checksums"
cliVersion.set(pinnedCliVersion)
token.set(
providers.environmentVariable("GH_TOKEN")
.orElse(providers.environmentVariable("GITHUB_TOKEN"))
)
checksums.set(generatedChecksums.map { it.file("kilo-cli-checksums.properties") })
}
val normalizeOpenApiSpec by tasks.registering(NormalizeOpenApiSpecTask::class) {
description = "Normalize upstream CLI OpenAPI metadata before Kotlin client generation"
dependsOn(generateOpenApiSpec)
@@ -158,6 +171,7 @@ val fixGeneratedApi by tasks.registering(FixGeneratedApiTask::class) {
tasks.named("compileKotlin") {
dependsOn(fixGeneratedApi, writeKiloProperties)
if (downloadsCli.get()) dependsOn(writeCliChecksums)
if (repoCli.get()) dependsOn(stageRepoCli)
if (bundled.get()) dependsOn(stageBundledCli)
inputs.dir(generatedApi)
@@ -165,6 +179,7 @@ tasks.named("compileKotlin") {
tasks.named("processResources") {
dependsOn(writeKiloProperties)
if (downloadsCli.get()) dependsOn(writeCliChecksums)
if (repoCli.get()) dependsOn(stageRepoCli)
if (bundled.get()) dependsOn(stageBundledCli)
}
@@ -0,0 +1,19 @@
package ai.kilocode.backend.cli
import java.util.Properties
object KiloCliChecksums {
private const val RESOURCE = "kilo-cli-checksums.properties"
private val values by lazy {
val stream = KiloCliChecksums::class.java.classLoader.getResourceAsStream(RESOURCE)
?: return@lazy emptyMap()
stream.use {
Properties().apply { load(it) }
.entries
.associate { item -> item.key.toString() to item.value.toString() }
}
}
fun load(): Map<String, String> = values
}
@@ -35,6 +35,7 @@ class KiloCliDownloader(
private val root: File = File(PathManager.getSystemPath(), "kilo/cli"),
private val baseUrl: String = "https://github.com/Kilo-Org/kilocode/releases/download",
private val api: String = "https://api.github.com/repos/Kilo-Org/kilocode/releases/tags",
private val digests: Map<String, String> = KiloCliChecksums.load(),
private val lockTimeoutMs: Long = LOCK_TIMEOUT_MS,
) {
companion object {
@@ -64,7 +65,7 @@ class KiloCliDownloader(
cached(version, platform, exe, done)?.let { return@locked it }
}
val digest = asset(version, platform, ext)
val digest = digest(version, platform, ext)
val stage = stage(version, platform)
try {
val archive = File(stage, "kilo-$platform.$ext")
@@ -198,6 +199,17 @@ class KiloCliDownloader(
throw IllegalStateException(message)
}
private fun digest(version: String, platform: String, ext: String): String {
val digest = digests[platform]
if (digest == null) return asset(version, platform, ext)
if (digest.matches(DIGEST)) {
log.info("Using bundled Kilo CLI checksum for $version $platform")
return digest
}
log.warn("Ignoring malformed bundled Kilo CLI checksum for $platform: $digest")
return asset(version, platform, ext)
}
private fun asset(version: String, platform: String, ext: String): String {
val name = "kilo-$platform.$ext"
val url = "${api.trimEnd('/')}/v$version"
@@ -4,6 +4,7 @@ import com.intellij.openapi.util.SystemInfo
import com.intellij.util.system.CpuArch
internal object KiloCliPlatform {
// Keep supported OS/architecture pairs in sync with Gradle CLI staging task platform lists.
fun current(): String {
val os = when {
SystemInfo.isMac -> "darwin"
@@ -30,8 +30,8 @@ class KiloCliDownloaderTest {
fun `downloads extracts and caches pinned cli`() = runBlocking {
MockWebServer().use { server ->
val bytes = archive()
server.enqueue(metadata(bytes))
server.enqueue(MockResponse().setResponseCode(200).setBody(Buffer().write(bytes)))
val digests = digests(bytes)
val seen = mutableListOf<CliDownload>()
val log = TestLog()
val cli = KiloCliDownloader(
@@ -39,13 +39,13 @@ class KiloCliDownloaderTest {
root = dir,
baseUrl = server.url("/release").toString(),
api = server.url("/api").toString(),
digests = digests,
).resolve("1.2.3", onProgress = { seen.add(it) })
assertTrue(cli.isFile)
assertEquals(File(File(dir, "1.2.3"), KiloCliPlatform.current()).absolutePath, cli.parentFile.parentFile.absolutePath)
assertEquals("#!/bin/sh\n", cli.readText())
assertTrue(File(cli.parentFile, "kilo-sandbox-mutation-worker.js").isFile)
assertEquals("/api/v1.2.3", server.takeRequest().path)
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() })
@@ -65,34 +65,83 @@ class KiloCliDownloaderTest {
root = dir,
baseUrl = server.url("/release").toString(),
api = server.url("/api").toString(),
digests = digests,
).resolve("1.2.3", onProgress = { cachedProgress.add(it) })
assertEquals(cli.absolutePath, cached.absolutePath)
assertEquals(2, server.requestCount)
assertEquals(1, server.requestCount)
assertTrue(cachedProgress.isEmpty())
assertContains(log.messages, "INFO: Using cached Kilo CLI 1.2.3 for ${KiloCliPlatform.current()} at ${cli.absolutePath}")
File(cli.parentFile.parentFile, ".complete").writeText("ok\n")
server.enqueue(metadata(bytes))
server.enqueue(MockResponse().setResponseCode(200).setBody(Buffer().write(bytes)))
val stale = KiloCliDownloader(
log = log,
root = dir,
baseUrl = server.url("/release").toString(),
api = server.url("/api").toString(),
digests = digests,
).resolve("1.2.3")
assertEquals(cli.absolutePath, stale.absolutePath)
assertEquals(4, server.requestCount)
assertEquals(2, server.requestCount)
server.enqueue(metadata(bytes))
server.enqueue(MockResponse().setResponseCode(200).setBody(Buffer().write(bytes)))
val forced = KiloCliDownloader(
log = log,
root = dir,
baseUrl = server.url("/release").toString(),
api = server.url("/api").toString(),
digests = digests,
).resolve("1.2.3", force = true)
assertEquals(cli.absolutePath, forced.absolutePath)
assertEquals(6, server.requestCount)
assertEquals(3, server.requestCount)
}
}
@Test
fun `falls back to github metadata when bundled checksum is missing`() = runBlocking {
MockWebServer().use { server ->
val bytes = archive()
server.enqueue(metadata(bytes))
server.enqueue(MockResponse().setResponseCode(200).setBody(Buffer().write(bytes)))
val cli = KiloCliDownloader(
root = dir,
baseUrl = server.url("/release").toString(),
api = server.url("/api").toString(),
digests = mapOf("other" to "sha256:${"a".repeat(64)}"),
).resolve("1.2.3")
assertTrue(cli.isFile)
assertEquals("/api/v1.2.3", server.takeRequest().path)
assertEquals("/release/v1.2.3/kilo-${KiloCliPlatform.current()}.${KiloCliPlatform.archive()}", server.takeRequest().path)
assertEquals(2, server.requestCount)
}
}
@Test
fun `falls back to github metadata when bundled checksum is malformed`() = runBlocking {
MockWebServer().use { server ->
val bytes = archive()
server.enqueue(metadata(bytes))
server.enqueue(MockResponse().setResponseCode(200).setBody(Buffer().write(bytes)))
val log = TestLog()
val cli = KiloCliDownloader(
log = log,
root = dir,
baseUrl = server.url("/release").toString(),
api = server.url("/api").toString(),
digests = mapOf(KiloCliPlatform.current() to "not-a-digest"),
).resolve("1.2.3")
assertTrue(cli.isFile)
assertContains(
log.messages,
"WARN: Ignoring malformed bundled Kilo CLI checksum for ${KiloCliPlatform.current()}: not-a-digest"
)
assertEquals("/api/v1.2.3", server.takeRequest().path)
assertEquals("/release/v1.2.3/kilo-${KiloCliPlatform.current()}.${KiloCliPlatform.archive()}", server.takeRequest().path)
assertEquals(2, server.requestCount)
}
}
@@ -110,6 +159,7 @@ class KiloCliDownloaderTest {
root = dir,
baseUrl = server.url("/release").toString(),
api = server.url("/api").toString(),
digests = emptyMap(),
).resolve("1.2.3")
assertTrue(cli.isFile)
@@ -132,6 +182,7 @@ class KiloCliDownloaderTest {
root = dir,
baseUrl = server.url("/release").toString(),
api = server.url("/api").toString(),
digests = emptyMap(),
)
val old = cli.resolve("1.2.3")
assertEquals("#!/bin/old\n", old.readText())
@@ -158,12 +209,14 @@ class KiloCliDownloaderTest {
root = dir,
baseUrl = server.url("/release").toString(),
api = server.url("/api").toString(),
digests = emptyMap(),
).resolve("1.2.3")
val ex = assertFailsWith<IllegalStateException> {
KiloCliDownloader(
root = dir,
baseUrl = server.url("/release").toString(),
api = server.url("/api").toString(),
digests = emptyMap(),
).resolve("1.2.3", force = true)
}
@@ -186,6 +239,7 @@ class KiloCliDownloaderTest {
root = dir,
baseUrl = server.url("/release").toString(),
api = server.url("/api").toString(),
digests = emptyMap(),
).resolve("1.2.3")
}
@@ -194,6 +248,28 @@ class KiloCliDownloaderTest {
}
}
@Test
fun `rejects cli archive with mismatched bundled checksum without fetching metadata`() = runBlocking {
MockWebServer().use { server ->
val bytes = archive()
server.enqueue(MockResponse().setResponseCode(200).setBody(Buffer().write(bytes)))
val ex = assertFailsWith<IllegalStateException> {
KiloCliDownloader(
root = dir,
baseUrl = server.url("/release").toString(),
api = server.url("/api").toString(),
digests = mapOf(KiloCliPlatform.current() to "sha256:${sha256("different".toByteArray())}"),
).resolve("1.2.3")
}
assertContains(ex.message.orEmpty(), "digest mismatch")
assertEquals("/release/v1.2.3/kilo-${KiloCliPlatform.current()}.${KiloCliPlatform.archive()}", server.takeRequest().path)
assertEquals(1, server.requestCount)
assertFalse(File(File(File(dir, "1.2.3"), KiloCliPlatform.current()), ".complete").exists())
}
}
@Test
fun `fails clearly and logs when the release has no matching asset`() = runBlocking {
MockWebServer().use { server ->
@@ -209,6 +285,7 @@ class KiloCliDownloaderTest {
root = dir,
baseUrl = server.url("/release").toString(),
api = server.url("/api").toString(),
digests = emptyMap(),
).resolve("1.2.3")
}
val name = "kilo-${KiloCliPlatform.current()}.${KiloCliPlatform.archive()}"
@@ -233,6 +310,7 @@ class KiloCliDownloaderTest {
root = dir,
baseUrl = server.url("/release").toString(),
api = server.url("/api").toString(),
digests = emptyMap(),
).resolve("1.2.3")
}
assertContains(ex.message.orEmpty(), "has no digest yet")
@@ -261,6 +339,7 @@ class KiloCliDownloaderTest {
root = dir,
baseUrl = server.url("/release").toString(),
api = server.url("/api").toString(),
digests = emptyMap(),
).resolve("1.2.3")
}
@@ -284,6 +363,7 @@ class KiloCliDownloaderTest {
KiloCliDownloader(
log = log,
root = dir,
digests = emptyMap(),
lockTimeoutMs = 50,
).resolve("1.2.3")
}
@@ -307,6 +387,8 @@ class KiloCliDownloaderTest {
private fun metadata(bytes: ByteArray) = metadata("sha256:${sha256(bytes)}")
private fun digests(bytes: ByteArray) = mapOf(KiloCliPlatform.current() to "sha256:${sha256(bytes)}")
private fun metadata(digest: String) = MockResponse().setResponseCode(200).setBody(
"""{"assets":[{"name":"kilo-${KiloCliPlatform.current()}.${KiloCliPlatform.archive()}","digest":"$digest"}]}"""
)
@@ -7,8 +7,8 @@ import org.gradle.api.Project
* `id("build-tasks")` resolves in `backend/build.gradle.kts`.
*
* The real value lives in the custom task classes this composite build
* provides: [NormalizeOpenApiSpecTask], [FixGeneratedApiTask], and
* [GenerateOpenApiSpecTask].
* provides: [NormalizeOpenApiSpecTask], [FixGeneratedApiTask],
* [GenerateOpenApiSpecTask], and [WriteCliChecksumsTask].
*/
class BuildTasksPlugin : Plugin<Project> {
override fun apply(target: Project) {}
@@ -0,0 +1,128 @@
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import org.gradle.api.DefaultTask
import org.gradle.api.GradleException
import org.gradle.api.file.RegularFileProperty
import org.gradle.api.provider.Property
import org.gradle.api.tasks.Input
import org.gradle.api.tasks.Internal
import org.gradle.api.tasks.OutputFile
import org.gradle.api.tasks.TaskAction
import java.net.HttpURLConnection
import java.net.URI
import java.time.Instant
abstract class WriteCliChecksumsTask : DefaultTask() {
companion object {
private val DIGEST = Regex("^sha256:[a-f0-9]{64}$")
private val JSON = Json { ignoreUnknownKeys = true }
private const val API = "https://api.github.com/repos/Kilo-Org/kilocode/releases/tags"
// Keep in sync with KiloCliPlatform.current() and StageBundledCliTask.PLATFORMS.
private val PLATFORMS = listOf(
"darwin-arm64",
"darwin-x64",
"linux-arm64",
"linux-x64",
"windows-arm64",
"windows-x64",
)
}
@get:Input
abstract val cliVersion: Property<String>
@get:Internal
abstract val token: Property<String>
@get:OutputFile
abstract val checksums: RegularFileProperty
@TaskAction
fun run() {
val ver = cliVersion.get()
val assets = assets(ver)
val values = PLATFORMS.associateWith { platform ->
val name = "kilo-$platform.${ext(platform)}"
assets[name] ?: throw GradleException("Kilo CLI release $ver did not include $name")
}
val out = checksums.get().asFile
out.parentFile.mkdirs()
out.writeText(
values.entries
.sortedBy { it.key }
.joinToString(separator = "\n", postfix = "\n") { item -> "${item.key}=${item.value}" }
)
}
private fun assets(ver: String): Map<String, String> {
val url = "$API/v$ver"
logger.lifecycle("Fetching pinned Kilo CLI release checksums from $url")
val conn = connect(url)
try {
val code = conn.responseCode
if (code !in 200..299) fail(conn, code, "Failed to fetch pinned Kilo CLI release checksums")
val body = conn.inputStream.bufferedReader().use { it.readText() }
return JSON.parseToJsonElement(body).jsonObject["assets"]?.jsonArray
?.associate { item ->
val obj = item.jsonObject
val name = obj["name"]?.jsonPrimitive?.contentOrNull
val digest = obj["digest"]?.jsonPrimitive?.contentOrNull
if (name.isNullOrBlank() || digest.isNullOrBlank()) return@associate "" to ""
name to digest
}
?.filter { it.key.isNotEmpty() }
?.mapValues { item ->
val digest = item.value
if (!digest.matches(DIGEST)) {
throw GradleException("Pinned Kilo CLI release $ver asset ${item.key} has invalid digest")
}
digest
}
?: emptyMap()
} finally {
conn.disconnect()
}
}
private fun connect(url: String): HttpURLConnection {
val conn = URI(url).toURL().openConnection() as HttpURLConnection
conn.connectTimeout = 30_000
conn.readTimeout = 120_000
conn.instanceFollowRedirects = true
conn.setRequestProperty("Accept", "application/vnd.github+json")
token.getOrNull()
?.trim()
?.takeIf { it.isNotEmpty() }
?.let { conn.setRequestProperty("Authorization", "Bearer $it") }
return conn
}
private fun fail(conn: HttpURLConnection, code: Int, msg: String): Nothing {
val info = rate(conn)
val body = runCatching { conn.errorStream?.bufferedReader()?.use { it.readText() } }
.getOrNull()
?.take(500)
val detail = if (body.isNullOrBlank()) "" else ": $body"
if (limited(conn, code)) {
throw GradleException("GitHub API rate limit exceeded while fetching Kilo CLI checksums ($info)$detail")
}
throw GradleException("$msg: HTTP $code from ${conn.url} ($info)$detail")
}
private fun rate(conn: HttpURLConnection): String {
val reset = conn.getHeaderField("X-RateLimit-Reset")
?.toLongOrNull()
?.let { Instant.ofEpochSecond(it).toString() }
return "limit=${conn.getHeaderField("X-RateLimit-Limit")} remaining=${conn.getHeaderField("X-RateLimit-Remaining")} " +
"used=${conn.getHeaderField("X-RateLimit-Used")} reset=$reset retryAfter=${conn.getHeaderField("Retry-After")}"
}
private fun limited(conn: HttpURLConnection, code: Int) =
code == 429 || (code == 403 && conn.getHeaderField("X-RateLimit-Remaining") == "0")
private fun ext(platform: String) = if (platform.startsWith("linux-")) "tar.gz" else "zip"
}
+1 -1
View File
@@ -8,7 +8,7 @@
"test": "./gradlew test",
"test:ci": "bun script/test-ci.ts"
},
"version": "7.4.17",
"version": "7.4.20",
"dependencies": {},
"devDependencies": {},
"peerDependencies": {}
+91 -9
View File
@@ -50,7 +50,6 @@ import {
import { GitOps } from "./agent-manager/GitOps"
import { GitStatsPoller, type LocalStats } from "./agent-manager/GitStatsPoller"
import { diffSummary as localDiffSummary } from "./agent-manager/local-diff"
import { getWorkspaceRoot } from "./review-utils"
import { createMarketplaceRemover, removeMcp } from "./kilo-provider/remove-config-item"
import { AgentRequirementsController } from "./kilo-provider/agent-requirements-controller"
import type { RemoteStatusService } from "./services/RemoteStatusService"
@@ -441,6 +440,8 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
private statsGitOps: GitOps | null = null
private cachedStats: unknown = null
private cachedGitRepo = false
private cachedGitDirectory: string | undefined
private gitStatusRevision = 0
private onBeforeMessage: ((msg: Record<string, unknown>) => Promise<Record<string, unknown> | null>) | null = null
@@ -1664,6 +1665,8 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
// Subscribe to SSE events for this webview (filtered by tracked sessions)
this.unsubscribeEvent = this.connectionService.onEventFiltered(
(payload, directory) => {
if (directory && !this.isCurrentProjectDirectory(directory)) return false
if (!directory && isEventFromForeignProject(payload, this.projectID)) return false
const event = unwrapSyncEvent(payload)
if (!event) return false
@@ -1675,6 +1678,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
// message.part.* events are always session-scoped; drop if session unknown.
if (!sessionId) return !isSessionScopedPartEvent(event.type)
if (!directory && !this.isCurrentProjectSession(sessionId)) return false
if (event.type === "session.created" && this.matchesPendingFollowup(event.properties.info)) {
return true
@@ -1821,15 +1825,12 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
this.memory.fetch(),
this.seedSessionStatusMap(),
])
this.cachedGitRepo = await hasGit(this.client!, this.getWorkspaceDirectory())
this.postMessage({ type: "gitStatus", repo: this.cachedGitRepo })
await this.refreshGitStatus(this.getWorkspaceDirectory())
this.sendNotificationSettings()
this.sendTimelineSetting()
this.postMessage(buildThroughputSettingMessage())
this.postMessage({ type: "extensionDataReady" })
if (this.cachedGitRepo) this.startStatsPolling()
console.log("[Kilo New] KiloProvider: ✅ initializeConnection completed successfully")
} catch (error) {
console.error("[Kilo New] KiloProvider: ❌ Failed to initialize connection:", error)
@@ -1890,6 +1891,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
/** Non-blocking: refresh session metadata + status for the webview after switching. */
private refreshSessionDetails(sessionID: string, dir: string, signal?: AbortSignal): void {
if (!this.client) return
void this.refreshGitStatus(dir)
const revision = this.revisions.get(sessionID)
const refresh = (this.refreshes.get(sessionID) ?? 0) + 1
this.refreshes.set(sessionID, refresh)
@@ -2105,10 +2107,12 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
private async flushPendingSessionRefresh(reason: string): Promise<void> {
if (!this.pendingSessionRefresh) return
console.log("[Kilo New] KiloProvider: 🔄 Flushing deferred sessions refresh", { reason })
const scope = this.opts.projectQualifier?.()?.projectId
if (scope !== undefined) this.projectID = undefined
const ctx = this.sessionRefreshContext
try {
const resolved = await flushPendingSessionRefreshUtil(ctx)
if (resolved) this.projectID = resolved
if (resolved && scope === this.opts.projectQualifier?.()?.projectId) this.projectID = resolved
} catch (error) {
console.error("[Kilo New] KiloProvider: Failed to flush session refresh:", error)
}
@@ -2119,10 +2123,12 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
* Handle loading all sessions.
*/
private async handleLoadSessions(): Promise<void> {
const scope = this.opts.projectQualifier?.()?.projectId
if (scope !== undefined) this.projectID = undefined
const ctx = this.sessionRefreshContext
try {
const resolved = await loadSessionsUtil(ctx)
if (resolved) this.projectID = resolved
if (resolved && scope === this.opts.projectQualifier?.()?.projectId) this.projectID = resolved
} catch (error) {
console.error("[Kilo New] KiloProvider: Failed to load sessions:", error)
this.postMessage({
@@ -4306,9 +4312,10 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
// Drop session events from other projects before any tracking logic.
// This must come first: the trackedSessionIds guard below would otherwise
// let a foreign session through if it was accidentally tracked.
if (!isLegacySyncEvent(event) && isEventFromForeignProject(event, this.projectID)) return
if (directory && !this.isCurrentProjectDirectory(directory)) return
if (
this.projectID &&
(!this.opts.projectQualifier || !directory) &&
(event.type === "session.created" || event.type === "session.updated") &&
event.properties.info.projectID !== undefined &&
event.properties.info.projectID !== null &&
@@ -4367,6 +4374,8 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
)
return
if (event.type === "message.part.updated") this.refreshGitStatusFromPart(event, sessionID)
if (event.type === "session.updated" && typeof event.properties.info.cost === "number") {
const cost = this.costs.setSessionCost(event.properties.sessionID, event.properties.info.cost)
this.requestCostAlert(event.properties.sessionID, cost)
@@ -4737,6 +4746,79 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
return undefined
}
private isCurrentProjectDirectory(directory: string): boolean {
if (!this.opts.projectQualifier?.()) return true
const dirs = [this.getRootDirectory(), ...(this.opts.worktreeDirectories?.() ?? [])]
return dirs.some((dir) => sameDirectory(dir, directory))
}
private isCurrentProjectSession(sessionID: string): boolean {
if (!this.opts.projectQualifier || !this.opts.routeService) return true
const directory = this.opts.routeService.trySessionDirectory(sessionID)
return !directory || this.isCurrentProjectDirectory(directory)
}
private refreshGitStatusFromPart(
event: Extract<ProviderEvent, { type: "message.part.updated" }>,
sessionID?: string,
) {
const part = event.properties.part as {
type?: string
metadata?: Record<string, unknown>
state?: { status?: string; input?: Record<string, unknown>; metadata?: Record<string, unknown> }
}
if (part.type !== "tool" || part.state?.status !== "completed") return
const values = [part.metadata?.filepath, part.state?.metadata?.filepath, part.state?.input?.filePath]
const file = values.find((value): value is string => typeof value === "string" && value.length > 0)
if (!file) return
const base = this.getWorkspaceDirectory(sessionID)
const value = file.split(",")[0].trim()
const pathName = path.isAbsolute(value) ? value : path.resolve(base, value)
const directory = path.dirname(pathName)
if (!this.isCurrentProjectGitDirectory(directory, sessionID)) return
void this.refreshGitStatus(directory)
}
private isCurrentProjectGitDirectory(directory: string, sessionID?: string): boolean {
const roots = this.opts.projectQualifier?.()
? [this.getRootDirectory(), ...(this.opts.worktreeDirectories?.() ?? [])]
: [this.getWorkspaceDirectory(sessionID)]
return roots.some((root) => {
const rel = path.relative(canonicalizePath(root), canonicalizePath(directory))
return rel === "" || (!path.isAbsolute(rel) && rel !== ".." && !rel.startsWith(`..${path.sep}`))
})
}
public async refreshGitStatus(directory = this.getWorkspaceDirectory()): Promise<void> {
const client = this.client
if (!client) return
const revision = ++this.gitStatusRevision
const repo = await hasGit(client, directory)
const root = await this.resolveGitRoot(directory)
if (revision !== this.gitStatusRevision) return
const found = repo || root !== undefined
const target = root ?? directory
if (!this.cachedGitDirectory || !sameDirectory(this.cachedGitDirectory, target)) this.cachedStats = null
this.cachedGitDirectory = target
this.cachedGitRepo = found
this.postMessage({ type: "gitStatus", repo: found })
if (found) {
if (!this.statsPoller) this.startStatsPolling()
return
}
this.statsPoller?.stop()
this.statsGitOps?.dispose()
this.statsPoller = null
this.statsGitOps = null
}
private async resolveGitRoot(directory: string): Promise<string | undefined> {
const git = this.statsGitOps ?? new GitOps({ log: () => {} })
const root = await git.root(directory)
if (!this.statsGitOps) git.dispose()
return root
}
private getContextDirectory(): string {
return resolveContextDirectory({
currentSessionID: this.currentSession?.id,
@@ -4846,7 +4928,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
this.statsGitOps = git
this.statsPoller = new GitStatsPoller({
getWorktrees: () => [],
getWorkspaceRoot: () => getWorkspaceRoot(),
getWorkspaceRoot: () => this.cachedGitDirectory ?? this.getWorkspaceDirectory(this.currentSession?.id),
localDiff: (dir, base) => localDiffSummary(git, dir, base),
git,
onStats: () => {},
@@ -395,6 +395,7 @@ export class AgentManagerProvider implements Disposable {
this.panelSessions.clear()
void ctx.sessions.abortSessions(ids).catch((err) => this.log("Failed to abort sessions on panel close:", err))
this.statsPoller.stop()
this.projectPollers.dispose()
this.prBridge.poller.stop()
this.diffs.stop()
this.activeSessionId = undefined
@@ -450,12 +451,14 @@ export class AgentManagerProvider implements Disposable {
/** Initialize an expanded background project and push its state (no panel wiring). */
private initExpanded(ctx: ProjectContext): void {
void initContextState(ctx, (...args) => this.log(...args)).then((result) => {
if (!result.current) return
registerProjectSessions(ctx, this.panel?.sessions)
this.pushState(ctx)
this.projectPollers.sync(this.contexts)
})
void initContextState(ctx, (...args) => this.log(...args))
.then((result) => {
if (!result.current) return
registerProjectSessions(ctx, this.panel?.sessions)
this.pushState(ctx)
this.projectPollers.sync(this.contexts)
})
.catch((err) => this.log("Failed to initialize expanded project:", err))
}
// Message interceptor
@@ -1047,6 +1050,7 @@ export class AgentManagerProvider implements Disposable {
if (!req) return
if (directory) {
req.directory = directory
req.projectId ??= this.contexts.byDirectory(directory)?.id
}
void this.startToolRequest(req)
}
@@ -1555,7 +1559,11 @@ export class AgentManagerProvider implements Disposable {
void this.sendRepoInfo()
if (!reactivateProject(ctx, this.panel?.sessions, (c) => this.pushState(c)))
this.stateReady = this.initializeState()
else this.projectPollers.sync(this.contexts)
else {
this.panel?.sessions.refreshSessions()
this.projectPollers.sync(this.contexts)
}
this.panel?.sessions.refreshGitStatus?.()
}
private onWorkspaceChanged(): void {
if (this.contexts.syncPinned()) {
@@ -1567,11 +1575,20 @@ export class AgentManagerProvider implements Disposable {
}
private pushProjects(): void {
const projects = this.contexts.snapshots()
this.postToWebview({
type: "agentManager.projects",
multiProject: this.host.multiProject(),
projects: this.contexts.snapshots(),
projects,
})
if (this.panel) {
for (const project of projects) {
if (project.active || !project.expanded || !project.trusted || project.missing) continue
if (this.contexts.get(project.id)) continue
const ctx = this.contexts.expand(project.id)
if (ctx) this.initExpanded(ctx)
}
}
this.projectPollers.sync(this.contexts)
}
@@ -171,6 +171,10 @@ export class GitOps {
return this.raw(["rev-parse", "--abbrev-ref", "HEAD"], cwd).catch(() => "")
}
async root(cwd: string): Promise<string | undefined> {
return this.raw(["rev-parse", "--show-toplevel"], cwd).catch(() => undefined)
}
/**
* Resolve the remote name for a branch. Checks (in order):
* 1. The configured upstream's remote (e.g. upstream from `upstream/main`)
@@ -66,6 +66,8 @@ export interface SessionProvider {
isSessionRouteAmbiguous?(sessionId: string): boolean
/** Exact directory for a project-qualified session ref, or undefined. */
routeSessionDirectoryFor?(ref: SessionRef): string | undefined
/** Re-check Git capability for the active project/session directory. */
refreshGitStatus?(): void
dispose(): void
}
@@ -32,6 +32,7 @@ interface ContextsOptions {
registry: {
list(): StoredProject[]
get(id: string): StoredProject | undefined
expanded?(id: string): boolean | undefined
}
/** Registry trust lookup for non-pinned projects. */
trusted: (id: string) => boolean
@@ -44,7 +45,7 @@ interface ContextsOptions {
export class ProjectContexts {
private readonly contexts = new Map<string, ProjectContext>()
private activeId: string | undefined
private readonly expanded = new Set<string>()
private readonly expansion = new Map<string, boolean>()
constructor(private readonly opts: ContextsOptions) {}
@@ -83,7 +84,7 @@ export class ProjectContexts {
const pinned = this.pinned()
if (pinned) {
this.activeId = pinned.id
this.expanded.add(pinned.id)
this.rememberExpansion(pinned.id, true)
return pinned
}
if (!this.opts.enabled()) return undefined
@@ -91,7 +92,7 @@ export class ProjectContexts {
if (!first) return undefined
const ctx = this.ensure(first.id, first.root, false)
this.activeId = ctx.id
this.expanded.add(ctx.id)
this.rememberExpansion(ctx.id, false)
return ctx
}
@@ -140,7 +141,12 @@ export class ProjectContexts {
}
isExpanded(id: string): boolean {
return this.expanded.has(id)
const value = this.expansion.get(id)
if (value !== undefined) return value
const stored = this.opts.registry.expanded?.(id)
if (stored !== undefined) return stored
const root = this.opts.workspaceRoot()
return root !== undefined && projectIdFor(canonicalizePath(root)) === id
}
/** Make a project the active context and expand it. Returns undefined when not allowed. */
@@ -148,6 +154,7 @@ export class ProjectContexts {
const ctx = this.usableCtx(id)
if (!ctx) return undefined
this.activeId = id
this.rememberExpansion(id, false)
return ctx
}
@@ -155,12 +162,12 @@ export class ProjectContexts {
expand(id: string): ProjectContext | undefined {
const ctx = this.usableCtx(id)
if (!ctx) return undefined
this.expanded.add(id)
this.expansion.set(id, true)
return ctx
}
collapse(id: string): void {
this.expanded.delete(id)
this.expansion.set(id, false)
if (this.isActive(id)) return
this.contexts.get(id)?.suspend()
}
@@ -169,10 +176,10 @@ export class ProjectContexts {
disable(): ProjectContext | undefined {
const pinned = this.pinned()
this.activeId = pinned?.id
if (pinned) this.expanded.add(pinned.id)
if (pinned) this.expansion.set(pinned.id, true)
for (const ctx of this.contexts.values()) {
if (ctx.pinned) continue
this.expanded.delete(ctx.id)
this.expansion.set(ctx.id, false)
ctx.suspend()
// Match remove()/syncPinned(): drop the routes too, otherwise the shared
// route service accumulates entries for every disabled project.
@@ -194,7 +201,7 @@ export class ProjectContexts {
async remove(id: string): Promise<boolean> {
const ctx = this.contexts.get(id)
if (!ctx || ctx.pinned) return false
this.expanded.delete(id)
this.expansion.delete(id)
if (this.activeId === id) this.activeId = undefined
this.contexts.delete(id)
this.opts.remove?.(id)
@@ -215,7 +222,7 @@ export class ProjectContexts {
for (const [id, ctx] of [...this.contexts]) {
if (!ctx.pinned) continue
this.contexts.delete(id)
this.expanded.delete(id)
this.expansion.delete(id)
if (this.activeId === id) this.activeId = undefined
this.opts.remove?.(id)
ctx.suspend()
@@ -241,26 +248,32 @@ export class ProjectContexts {
const id = ctx?.id ?? stored!.id
const root = ctx?.root ?? stored!.root
const pinned = ctx?.pinned ?? false
const missing = ctx ? ctx.missing() : !(this.opts.deps.exists ?? fs.existsSync)(root)
return {
id,
root,
label: stored?.label || path.basename(root) || root,
pinned,
active: this.isActive(id),
expanded: this.isExpanded(id),
expanded: !missing && this.isExpanded(id),
initialized: ctx?.loaded ?? false,
trusted: pinned || (stored?.trusted ?? false),
missing: ctx ? ctx.missing() : !(this.opts.deps.exists ?? fs.existsSync)(root),
missing,
}
}
private rememberExpansion(id: string, fallback: boolean): void {
if (this.expansion.has(id)) return
this.expansion.set(id, this.opts.registry.expanded?.(id) ?? fallback)
}
async dispose(): Promise<void> {
for (const ctx of this.contexts.values()) {
this.opts.remove?.(ctx.id)
await ctx.dispose()
}
this.contexts.clear()
this.expanded.clear()
this.expansion.clear()
this.activeId = undefined
}
}
@@ -189,9 +189,15 @@ function selectProject(id: string, deps: ProjectMessageDeps): void {
async function setExpanded(id: string, expanded: boolean, deps: ProjectMessageDeps): Promise<void> {
if (disabled(deps)) return
const ctx = expanded ? deps.contexts.usable(id) : deps.contexts.resolve(id)
if (!ctx) {
deps.push()
return
}
await deps.registry.setExpanded(id, expanded)
if (expanded) {
const ctx = deps.contexts.expand(id)
if (ctx) deps.expand(ctx)
const next = deps.contexts.expand(id)
if (next) deps.expand(next)
}
if (!expanded) deps.contexts.collapse(id)
deps.push()
@@ -1,10 +1,10 @@
/**
* ProjectRegistry — global catalog of additional Agent Manager projects.
*
* The registry persists only *additional* projects: repositories the user
* explicitly added through the Agent Manager project picker. The pinned
* default project is always derived from the current VS Code workspace at
* runtime and is never stored here.
* The registry persists *additional* projects: repositories the user explicitly
* added through the Agent Manager project picker. The pinned default project is
* always derived from the current VS Code workspace at runtime; only its
* accordion preference is stored by id.
*
* Storage is injected so the registry stays free of VS Code imports and can
* be unit-tested with an in-memory store. The file is versioned; corrupt or
@@ -31,11 +31,20 @@ export interface StoredProject {
/** Whether project-controlled scripts may execute for this project. */
trusted: boolean
addedAt: string
/** Whether this project accordion should render its body. */
expanded?: boolean
}
interface RegistryFile {
version: 1
projects: StoredProject[]
/** Expansion state for the pinned workspace project, which is not a catalog entry. */
pinnedExpanded?: Record<string, boolean>
}
interface ParsedRegistry {
projects: StoredProject[]
pinnedExpanded: Record<string, boolean>
}
export interface RegistryStorage {
@@ -53,16 +62,17 @@ function valid(entry: unknown): entry is StoredProject {
typeof e.root === "string" &&
typeof e.order === "number" &&
typeof e.trusted === "boolean" &&
typeof e.addedAt === "string"
typeof e.addedAt === "string" &&
(e.expanded === undefined || typeof e.expanded === "boolean")
)
}
function parse(raw: unknown, log: (msg: string) => void): StoredProject[] {
if (!raw || typeof raw !== "object") return []
function parse(raw: unknown, log: (msg: string) => void): ParsedRegistry {
if (!raw || typeof raw !== "object") return { projects: [], pinnedExpanded: {} }
const file = raw as Partial<RegistryFile>
if (file.version !== VERSION || !Array.isArray(file.projects)) {
if (file.version !== undefined) log("project registry has an unsupported shape, starting empty")
return []
return { projects: [], pinnedExpanded: {} }
}
const seen = new Set<string>()
const out: StoredProject[] = []
@@ -72,11 +82,18 @@ function parse(raw: unknown, log: (msg: string) => void): StoredProject[] {
seen.add(entry.id)
out.push(entry)
}
return out.sort((a, b) => a.order - b.order)
const pinnedExpanded: Record<string, boolean> = {}
if (file.pinnedExpanded && typeof file.pinnedExpanded === "object") {
for (const [id, expanded] of Object.entries(file.pinnedExpanded)) {
if (typeof expanded === "boolean") pinnedExpanded[id] = expanded
}
}
return { projects: out.sort((a, b) => a.order - b.order), pinnedExpanded }
}
export class ProjectRegistry {
private projects: StoredProject[] | undefined
private pinnedExpanded: Record<string, boolean> | undefined
private queue: Promise<void> = Promise.resolve()
constructor(
@@ -85,20 +102,26 @@ export class ProjectRegistry {
) {}
private load(): StoredProject[] {
this.projects ??= parse(this.storage.read(), this.log)
if (!this.projects) {
const parsed = parse(this.storage.read(), this.log)
this.projects = parsed.projects
this.pinnedExpanded = parsed.pinnedExpanded
}
return this.projects
}
/** Fresh re-read + validate/dedupe/sort, used by every mutation. */
private fresh(): StoredProject[] {
private fresh(): ParsedRegistry {
return parse(this.storage.read(), this.log)
}
/** Persist the next catalog and update the cache only after the write succeeds. */
private async write(next: StoredProject[]): Promise<void> {
private async write(next: StoredProject[], pinnedExpanded: Record<string, boolean>): Promise<void> {
const file: RegistryFile = { version: VERSION, projects: next }
if (Object.keys(pinnedExpanded).length > 0) file.pinnedExpanded = pinnedExpanded
await this.storage.write(file)
this.projects = next
this.pinnedExpanded = pinnedExpanded
}
/** Serialize mutations within this instance; a failed mutation does not poison the queue. */
@@ -119,6 +142,14 @@ export class ProjectRegistry {
return this.load().find((p) => p.id === id)
}
/** Return explicit expansion state, if one has been persisted. */
expanded(id: string): boolean | undefined {
const project = this.get(id)
if (project) return project.expanded
this.load()
return this.pinnedExpanded?.[id]
}
/** Register an additional project. Throws when the id is already registered. */
add(input: { id: string; root: string; label?: string }): Promise<StoredProject> {
return this.run(() => this.doAdd(input))
@@ -126,8 +157,9 @@ export class ProjectRegistry {
private async doAdd(input: { id: string; root: string; label?: string }): Promise<StoredProject> {
const current = this.fresh()
if (current.find((p) => p.id === input.id)) throw new Error("That repository is already registered as a project.")
const order = current.reduce((max, p) => Math.max(max, p.order), 0) + 1
if (current.projects.find((p) => p.id === input.id))
throw new Error("That repository is already registered as a project.")
const order = current.projects.reduce((max, p) => Math.max(max, p.order), 0) + 1
const project: StoredProject = {
id: input.id,
root: input.root,
@@ -136,7 +168,7 @@ export class ProjectRegistry {
trusted: false,
addedAt: new Date().toISOString(),
}
await this.write([...current, project])
await this.write([...current.projects, project], current.pinnedExpanded)
return project
}
@@ -147,9 +179,11 @@ export class ProjectRegistry {
private async doRemove(id: string): Promise<boolean> {
const current = this.fresh()
const next = current.filter((p) => p.id !== id)
if (next.length === current.length) return false
await this.write(next)
const next = current.projects.filter((p) => p.id !== id)
if (next.length === current.projects.length) return false
const pinnedExpanded = { ...current.pinnedExpanded }
delete pinnedExpanded[id]
await this.write(next, pinnedExpanded)
return true
}
@@ -159,8 +193,11 @@ export class ProjectRegistry {
private async doSetTrusted(id: string, trusted: boolean): Promise<boolean> {
const current = this.fresh()
if (!current.find((p) => p.id === id)) return false
await this.write(current.map((p) => (p.id === id ? { ...p, trusted } : p)))
if (!current.projects.find((p) => p.id === id)) return false
await this.write(
current.projects.map((p) => (p.id === id ? { ...p, trusted } : p)),
current.pinnedExpanded,
)
return true
}
@@ -170,8 +207,29 @@ export class ProjectRegistry {
private async doSetLabel(id: string, label: string | undefined): Promise<boolean> {
const current = this.fresh()
if (!current.find((p) => p.id === id)) return false
await this.write(current.map((p) => (p.id === id ? { ...p, label } : p)))
if (!current.projects.find((p) => p.id === id)) return false
await this.write(
current.projects.map((p) => (p.id === id ? { ...p, label } : p)),
current.pinnedExpanded,
)
return true
}
setExpanded(id: string, expanded: boolean): Promise<boolean> {
return this.run(() => this.doSetExpanded(id, expanded))
}
private async doSetExpanded(id: string, expanded: boolean): Promise<boolean> {
const current = this.fresh()
if (current.projects.some((p) => p.id === id)) {
await this.write(
current.projects.map((p) => (p.id === id ? { ...p, expanded } : p)),
current.pinnedExpanded,
)
return true
}
const pinnedExpanded = { ...current.pinnedExpanded, [id]: expanded }
await this.write(current.projects, pinnedExpanded)
return true
}
}
@@ -50,6 +50,7 @@ export async function createMultiVersion(
// Notify webview that multi-version creation has started
host.post({
type: "agentManager.multiVersionProgress",
projectId: ctx.id,
status: "creating",
total: versions,
completed: 0,
@@ -78,6 +79,7 @@ export async function createMultiVersion(
// Update progress
host.post({
type: "agentManager.multiVersionProgress",
projectId: ctx.id,
status: "creating",
total: versions,
completed: created.length,
@@ -86,11 +88,19 @@ export async function createMultiVersion(
}
// Phase 2: Send the initial prompt to all sessions, or clear busy state if no text.
await sendInitialPrompts(host, created, models, { providerID, modelID }, { text, agent, variant: msg.variant, files })
await sendInitialPrompts(
host,
ctx.id,
created,
models,
{ providerID, modelID },
{ text, agent, variant: msg.variant, files },
)
// Notify completion
host.post({
type: "agentManager.multiVersionProgress",
projectId: ctx.id,
status: "done",
total: versions,
completed: created.length,
@@ -172,6 +182,7 @@ async function createVersion(
if (earlyProviderID && earlyModelID) {
host.post({
type: "agentManager.setSessionModel",
projectId: ctx.id,
sessionId: session.id,
providerID: earlyProviderID,
modelID: earlyModelID,
@@ -232,6 +243,7 @@ async function reconcileSandbox(
/** Fan the initial prompt out to every created session, throttled between sends. */
async function sendInitialPrompts(
host: MultiVersionHost,
projectId: string,
created: CreatedVersion[],
models: VersionSpec["models"],
resolved: { providerID: string | undefined; modelID: string | undefined },
@@ -254,7 +266,7 @@ async function sendInitialPrompts(
modelID: msg.modelID,
})
}
host.post({ type: "agentManager.sendInitialMessage", ...msg })
host.post({ type: "agentManager.sendInitialMessage", projectId, ...msg })
if (input.text && i < messages.length - 1) {
await new Promise((resolve) => setTimeout(resolve, 300))
}
@@ -19,6 +19,7 @@ export interface ToolTask {
export interface ToolRequest {
requestID: string
projectId?: string
sessionID?: string
directory?: string
sandboxInheritanceToken?: string
@@ -234,7 +235,14 @@ export async function startFromTool(deps: ToolDeps, req: ToolRequest): Promise<v
const state = { ok: 0 }
const source = { sandboxInheritanceToken: req.sandboxInheritanceToken }
deps.post({ type: "agentManager.multiVersionProgress", status: "creating", total, completed: 0, groupId })
deps.post({
type: "agentManager.multiVersionProgress",
projectId: req.projectId,
status: "creating",
total,
completed: 0,
groupId,
})
for (let i = 0; i < req.tasks.length; i++) {
const task = req.tasks[i]!
try {
@@ -248,10 +256,24 @@ export async function startFromTool(deps: ToolDeps, req: ToolRequest): Promise<v
deps.log("Agent Manager tool task failed", msg)
deps.post({ type: "error", message: `Agent Manager tool task failed: ${msg}` })
}
deps.post({ type: "agentManager.multiVersionProgress", status: "creating", total, completed: state.ok, groupId })
deps.post({
type: "agentManager.multiVersionProgress",
projectId: req.projectId,
status: "creating",
total,
completed: state.ok,
groupId,
})
}
deps.post({ type: "agentManager.multiVersionProgress", status: "done", total, completed: state.ok, groupId })
deps.post({
type: "agentManager.multiVersionProgress",
projectId: req.projectId,
status: "done",
total,
completed: state.ok,
groupId,
})
if (state.ok === 0) deps.error(`Failed to start any Agent Manager sessions for request ${req.requestID}.`)
deps.log(`Agent Manager tool request ${req.requestID} complete: ${state.ok}/${total}`)
}
@@ -299,6 +321,7 @@ export function parseToolRequest(value: unknown): ToolRequest | undefined {
if (parsed.length !== limited.length) return undefined
return {
requestID: typeof value.requestID === "string" ? value.requestID : `am-${Date.now()}`,
projectId: typeof value.projectId === "string" ? value.projectId : undefined,
sessionID: typeof value.sessionID === "string" ? value.sessionID : undefined,
directory: typeof value.directory === "string" ? value.directory : undefined,
sandboxInheritanceToken:
@@ -258,6 +258,8 @@ interface SessionClosedMessage {
interface MultiVersionProgressMessage {
type: "agentManager.multiVersionProgress"
/** Owning project; absent in single-project mode. */
projectId?: string
status: "creating" | "done"
total: number
completed: number
@@ -266,6 +268,8 @@ interface MultiVersionProgressMessage {
interface SetSessionModelMessage {
type: "agentManager.setSessionModel"
/** Owning project; absent in single-project mode. */
projectId?: string
sessionId: string
providerID: string
modelID: string
@@ -273,6 +277,8 @@ interface SetSessionModelMessage {
interface SendInitialMessage {
type: "agentManager.sendInitialMessage"
/** Owning project; absent in single-project mode. */
projectId?: string
sessionId: string
worktreeId: string
text?: string
@@ -791,6 +797,7 @@ interface FileSourceIn {
interface SendMessageIn {
type: "sendMessage"
projectId?: string
text: string
messageID?: string
sessionID?: string
@@ -151,6 +151,7 @@ export class VscodeHost implements Host {
unregisterSessionRoute: (ref) => provider.unregisterSessionRoute(ref),
isSessionRouteAmbiguous: (sessionId) => provider.isSessionRouteAmbiguous(sessionId),
routeSessionDirectoryFor: (ref) => provider.routeSessionDirectoryFor(ref),
refreshGitStatus: () => void provider.refreshGitStatus(),
dispose: () => provider.dispose(),
}
@@ -661,7 +661,10 @@ export function mapCloudSessionMessageToWebviewMessage(message: CloudSessionMess
* Returns true when the event carries a projectID that does not match the expected one.
* When expectedProjectID is undefined (not yet resolved), nothing is filtered.
*/
export function isEventFromForeignProject(event: StreamEvent, expectedProjectID: string | undefined): boolean {
export function isEventFromForeignProject(
event: StreamEvent | SyncPayload,
expectedProjectID: string | undefined,
): boolean {
if (!expectedProjectID || event.type !== "sync") return false
if (event.name === "session.created.1" || event.name === "session.deleted.1") {
return event.data.info.projectID !== expectedProjectID
@@ -1,8 +1,8 @@
import type { KiloClient } from "@kilocode/sdk/v2/client"
export async function hasGit(client: KiloClient, directory: string): Promise<boolean> {
return client.project
.current({ directory })
return Promise.resolve()
.then(() => client.project.current({ directory }))
.then((r) => r.data?.vcs === "git")
.catch(() => false)
}
@@ -29,7 +29,12 @@ export function resolveIndexingEnv(folders: readonly WorkspaceFolderLike[] | und
}
export function resolveManagedServerEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
return { ...env, KILO_DISABLE_CHANNEL_DB: "true" }
return {
...env,
KILO_DISABLE_CHANNEL_DB: "true",
// VS Code does not consume the backend's file.watcher.updated events.
KILO_EXPERIMENTAL_DISABLE_FILEWATCHER: "true",
}
}
export class ServerManager {
@@ -38,6 +38,7 @@ const TSX_FILES = [
path.join(ROOT, "webview-ui/agent-manager/ApplyDialog.tsx"),
path.join(ROOT, "webview-ui/agent-manager/WorktreeItem.tsx"),
path.join(ROOT, "webview-ui/agent-manager/SectionHeader.tsx"),
path.join(ROOT, "webview-ui/agent-manager/SidebarSectionHeader.tsx"),
path.join(ROOT, "webview-ui/agent-manager/SidebarSearchMenu.tsx"),
path.join(ROOT, "webview-ui/agent-manager/SidebarToggleButton.tsx"),
path.join(ROOT, "webview-ui/agent-manager/WorktreeSectionActions.tsx"),
@@ -1,6 +1,7 @@
import { describe, expect, it } from "bun:test"
import { Window } from "happy-dom"
import { focusQuestionOption, hasQuestionOption } from "../../webview-ui/agent-manager/focus"
import { focusQuestionOption, hasQuestionOption, preservesTextFocus } from "../../webview-ui/agent-manager/focus"
import { isTextControl } from "../../webview-ui/src/utils/focus"
describe("Agent Manager focus", () => {
it("focuses the first enabled question option", () => {
@@ -53,4 +54,21 @@ describe("Agent Manager focus", () => {
dock.setAttribute("inert", "")
expect(hasQuestionOption(root)).toBe(false)
})
it("preserves focus for an active editable control", () => {
const window = new Window()
const rename = window.document.createElement("input")
rename.className = "am-worktree-rename-input"
const prompt = window.document.createElement("textarea")
prompt.className = "prompt-input"
const editor = window.document.createElement("div")
editor.contentEditable = "plaintext-only"
const button = window.document.createElement("button")
expect(isTextControl(rename)).toBe(true)
expect(preservesTextFocus(rename)).toBe(true)
expect(preservesTextFocus(prompt)).toBe(false)
expect(isTextControl(editor)).toBe(true)
expect(isTextControl(button)).toBe(false)
})
})
@@ -5,6 +5,7 @@ describe("Agent Manager initial message", () => {
it("forwards the selected variant to sendMessage", () => {
const msg = initialMessage({
type: "agentManager.sendInitialMessage",
projectId: "project-a",
sessionId: "session-a",
worktreeId: "wt-a",
text: "Fix it",
@@ -16,6 +17,7 @@ describe("Agent Manager initial message", () => {
expect(msg).toEqual({
type: "sendMessage",
projectId: "project-a",
text: "Fix it",
sessionID: "session-a",
providerID: "anthropic",
@@ -1,8 +1,10 @@
import { expect, test } from "bun:test"
import { readFileSync } from "node:fs"
import { resolve } from "node:path"
import { clampPanelWidth, maxPanelWidth, minPanelWidth } from "../../webview-ui/agent-manager/side-panel-layout"
const css = readFileSync(resolve(import.meta.dir, "../../webview-ui/agent-manager/agent-manager.css"), "utf8")
const app = readFileSync(resolve(import.meta.dir, "../../webview-ui/agent-manager/AgentManagerApp.tsx"), "utf8")
test("xterm owns the padding used by FitAddon", () => {
const host = css.match(/\.am-terminal-host\s*\{([^}]*)\}/)?.[1]
@@ -13,3 +15,22 @@ test("xterm owns the padding used by FitAddon", () => {
expect(host).not.toMatch(/\bpadding\s*:/)
expect(term).toMatch(/\bpadding\s*:\s*8px\s*;/)
})
test("uses one persisted width for the diff and terminal inspector", () => {
expect(app).toContain("persisted?.sidePanelWidth")
expect(app).toContain("setPanelWidth(pendingSideWidth!)")
expect(app).not.toContain("diffWidth")
expect(app).not.toContain("terminalWidth")
})
test("clamps the restored inspector width to the shared layout bounds", () => {
expect(clampPanelWidth(undefined, 1200)).toBe(600)
expect(clampPanelWidth(500, 1200)).toBe(500)
expect(clampPanelWidth(1000, 1000)).toBe(maxPanelWidth(1000))
expect(clampPanelWidth(100, 1200)).toBe(minPanelWidth(1200))
expect(clampPanelWidth("invalid", 1200)).toBe(600)
expect(minPanelWidth(400)).toBe(200)
expect(maxPanelWidth(400)).toBe(320)
expect(clampPanelWidth(undefined, 400)).toBe(200)
expect(clampPanelWidth(360, 400)).toBe(320)
})
@@ -17,11 +17,14 @@ function stored(id: string, trusted = false): StoredProject {
}
}
function setup(opts: { workspace?: string; enabled?: boolean; projects?: StoredProject[] } = {}) {
function setup(
opts: { workspace?: string; enabled?: boolean; projects?: StoredProject[]; expanded?: Record<string, boolean> } = {},
) {
const registryProjects = opts.projects ?? []
const registry = {
list: () => registryProjects,
get: (id: string) => registryProjects.find((p) => p.id === id),
expanded: (id: string) => opts.expanded?.[id],
}
const created: string[] = []
const contexts = new ProjectContexts({
@@ -207,6 +210,34 @@ describe("ProjectContexts", () => {
expect(list[1]!.initialized).toBe(false)
})
it("keeps the pinned project expanded before active state is initialized", () => {
const { contexts } = setup({ workspace: WORKSPACE })
expect(contexts.isExpanded(PINNED)).toBe(true)
})
it("hydrates persisted project expansion without initializing the project", () => {
const extra = stored("prj-extra", true)
const { contexts } = setup({
workspace: WORKSPACE,
enabled: true,
projects: [extra],
expanded: { [extra.id]: true },
})
const list = contexts.snapshots()
expect(list[1]!.expanded).toBe(true)
expect(list[1]!.initialized).toBe(false)
expect(contexts.get(extra.id)).toBeUndefined()
})
it("hydrates a persisted collapsed pinned project", () => {
const { contexts } = setup({ workspace: WORKSPACE, expanded: { [PINNED]: false } })
expect(contexts.snapshots()[0]!.expanded).toBe(false)
})
it("hides registry projects from snapshots when the flag is off", () => {
const extra = stored("prj-extra", true)
const { contexts } = setup({ workspace: WORKSPACE, enabled: false, projects: [extra] })
@@ -4,7 +4,7 @@ import * as os from "os"
import * as path from "path"
import { execFileSync } from "child_process"
import { handleProjectMessage, type ProjectMessageDeps } from "../../src/agent-manager/project/messages"
import { ProjectRegistry } from "../../src/agent-manager/project/registry"
import { ProjectRegistry, type RegistryStorage } from "../../src/agent-manager/project/registry"
import { ProjectContexts } from "../../src/agent-manager/project/contexts"
import { projectIdFor } from "../../src/agent-manager/project/paths"
import type { AgentManagerInMessage } from "../../src/agent-manager/types"
@@ -20,12 +20,13 @@ function gitRepo(): string {
function setup(opts: { enabled?: boolean; workspace?: string } = {}) {
let stored: unknown
let pickResult: string | undefined
const registry = new ProjectRegistry({
const storage: RegistryStorage = {
read: () => stored,
write: (value) => {
stored = value
},
})
}
const registry = new ProjectRegistry(storage)
const contexts = new ProjectContexts({
workspaceRoot: () => opts.workspace ?? WORKSPACE,
registry,
@@ -63,7 +64,7 @@ function setup(opts: { enabled?: boolean; workspace?: string } = {}) {
const pick = (dir: string | undefined) => {
pickResult = dir
}
return { registry, contexts, deps, calls, pick }
return { registry, contexts, deps, calls, pick, storage }
}
function msg(type: string, extra: Record<string, unknown> = {}): AgentManagerInMessage {
@@ -170,6 +171,36 @@ describe("handleProjectMessage", () => {
expect(calls.expand).toEqual([id])
})
it("persists project expansion state across registry instances", async () => {
const repo = gitRepo()
const { deps, registry, storage, calls } = setup()
const id = projectIdFor(repo)
await registry.add({ id, root: repo })
await registry.setTrusted(id, true)
await handleProjectMessage(msg("agentManager.setProjectExpanded", { projectId: id, expanded: true }), deps)
const restored = new ProjectRegistry(storage)
expect(restored.expanded(id)).toBe(true)
expect(restored.get(id)?.expanded).toBe(true)
await handleProjectMessage(msg("agentManager.setProjectExpanded", { projectId: id, expanded: false }), deps)
expect(new ProjectRegistry(storage).expanded(id)).toBe(false)
expect(calls.push).toBe(2)
})
it("persists the pinned project expansion state without adding it to the catalog", async () => {
const { deps, registry, storage } = setup()
const id = projectIdFor(WORKSPACE)
await handleProjectMessage(msg("agentManager.setProjectExpanded", { projectId: id, expanded: false }), deps)
const restored = new ProjectRegistry(storage)
expect(restored.expanded(id)).toBe(false)
expect(registry.list()).toEqual([])
})
it("does not initialize untrusted projects on expand", async () => {
const repo = gitRepo()
const { deps, registry, calls } = setup()
@@ -0,0 +1,59 @@
import { describe, expect, it } from "bun:test"
import { createProjectStore } from "../../webview-ui/agent-manager/project/store"
import { clearMultiVersionBusy, markMultiVersionBusy } from "../../webview-ui/agent-manager/project/progress"
const state = (projectId: string) => ({
type: "agentManager.state" as const,
projectId,
worktrees: [
{
id: "same",
branch: `${projectId}-same`,
path: `/repo/${projectId}/same`,
parentBranch: "main",
createdAt: "2026-01-01",
groupId: "group",
},
],
sessions: [{ id: `${projectId}-session`, worktreeId: "same", createdAt: "2026-01-01" }],
sections: [],
})
describe("multi-project progress state", () => {
it("updates only the owning project's grouped worktrees", () => {
const first = createProjectStore("a")
const second = createProjectStore("b")
first.applyState(state("a"))
second.applyState(state("b"))
first.setBusy(new Map([["same", { reason: "setting-up" as const }]]))
second.setBusy(new Map([["same", { reason: "setting-up" as const }]]))
clearMultiVersionBusy(second, "group")
expect(first.busy().has("same")).toBe(true)
expect(second.busy().has("same")).toBe(false)
second.setBusy(new Map([["same", { reason: "deleting" as const }]]))
clearMultiVersionBusy(second, "group")
expect(second.busy().get("same")?.reason).toBe("deleting")
})
it("marks a newly created grouped worktree as busy in its project store", () => {
const store = createProjectStore("a")
store.applyState(state("a"))
markMultiVersionBusy(store, "a-session")
expect(store.busy().get("same")?.reason).toBe("setting-up")
})
it("does not replace deletion progress when marking a grouped worktree", () => {
const store = createProjectStore("a")
store.applyState(state("a"))
store.setBusy(new Map([["same", { reason: "deleting" as const }]]))
markMultiVersionBusy(store, "a-session")
expect(store.busy().get("same")?.reason).toBe("deleting")
})
})
@@ -58,6 +58,23 @@ describe("GitOps", () => {
})
})
describe("root", () => {
it("resolves the nearest enclosing repository", async () => {
const git = ops(async (args) => {
if (args[0] === "rev-parse" && args[1] === "--show-toplevel") return "/workspace/frontend"
return ""
})
expect(await git.root("/workspace/frontend/src")).toBe("/workspace/frontend")
})
it("returns undefined outside a repository", async () => {
const git = ops(async () => {
throw new Error("not a git repo")
})
expect(await git.root("/workspace")).toBeUndefined()
})
})
describe("resolveRemote", () => {
it("uses upstream remote when upstream is configured", async () => {
const git = ops(async (args) => {
@@ -8,8 +8,12 @@ type Internals = {
webview: { postMessage: (message: unknown) => Promise<unknown> } | null
trackedSessionIds: Set<string>
currentSession: Session | null
projectID: string | undefined
isWebviewReady: boolean
pendingFollowup: { dir: string; time: number } | null
handleLoadMessages: (sessionID: string) => Promise<void>
handleEvent: (event: Event, directory?: string) => void
refreshGitStatus: (directory?: string) => Promise<void>
initializeConnection: () => Promise<void>
syncWebviewState: () => Promise<void>
flushPendingSessionRefresh: () => Promise<void>
@@ -43,6 +47,18 @@ function created(input: { id: string; directory: string; parentID?: string }): E
} as Event
}
function info(input: { id: string; projectID: string; directory: string }): Session {
return {
id: input.id,
slug: `${input.id}-slug`,
projectID: input.projectID,
directory: input.directory,
title: "Session",
version: "1",
time: { created: 1, updated: 1 },
}
}
function connection() {
let filter: ((event: Event) => boolean) | undefined
let listener: ((event: Event) => void) | undefined
@@ -80,6 +96,135 @@ function connection() {
}
describe("KiloProvider follow-up sessions", () => {
it("scopes shared session events to the active project directory", () => {
const service = connection()
const provider = new KiloProvider({} as never, service as never, undefined, {
rootDirectory: () => "/repo/project-b",
projectQualifier: () => ({ projectId: "project-b" }),
})
const internal = provider as unknown as Internals
const sent: unknown[] = []
const sharedID = "ses-shared"
internal.webview = {
postMessage: async (message: unknown) => {
sent.push(message)
return true
},
}
internal.isWebviewReady = true
internal.currentSession = info({ id: sharedID, projectID: "backend-project-b", directory: "/repo/project-b" })
internal.projectID = "backend-project-a"
internal.trackedSessionIds.add(sharedID)
// A background project's event must not overwrite the active project's
// transcript when both instances expose the same raw session key.
internal.handleEvent(
{
type: "message.updated",
properties: {
sessionID: sharedID,
info: {
id: "msg-project-a",
sessionID: sharedID,
role: "assistant",
time: { created: 1 },
},
},
} as Event,
"/repo/project-a",
)
expect(sent).toEqual([])
// Switching projects can briefly leave the backend project identity stale;
// the active directory is the authoritative scope during that transition.
internal.handleEvent(
{
type: "session.created",
properties: { sessionID: sharedID, info: internal.currentSession },
} as Event,
"/repo/project-b",
)
expect(sent).toContainEqual({
type: "sessionCreated",
session: {
id: sharedID,
title: "Session",
createdAt: new Date(1).toISOString(),
updatedAt: new Date(1).toISOString(),
parentID: null,
revert: null,
summary: null,
},
})
})
it("refreshes Git from the file path in a completed edit tool part", () => {
const service = connection()
const provider = new KiloProvider({} as never, service as never, undefined, {
rootDirectory: () => "/workspace",
projectQualifier: () => ({ projectId: "workspace" }),
})
const internal = provider as unknown as Internals
const dirs: string[] = []
const sessionID = "ses-edit"
internal.currentSession = info({ id: sessionID, projectID: "backend-workspace", directory: "/workspace" })
internal.trackedSessionIds.add(sessionID)
internal.refreshGitStatus = async (directory) => {
if (directory) dirs.push(directory)
}
internal.handleEvent(
{
type: "message.part.updated",
properties: {
sessionID,
part: {
type: "tool",
state: { status: "completed" },
metadata: { filepath: "/workspace/frontend/src/app.ts" },
},
},
} as Event,
"/workspace",
)
expect(dirs).toEqual(["/workspace/frontend/src"])
})
it("ignores completed tool paths outside the active project", () => {
const service = connection()
const provider = new KiloProvider({} as never, service as never, undefined, {
rootDirectory: () => "/workspace",
projectQualifier: () => ({ projectId: "workspace" }),
})
const internal = provider as unknown as Internals
const dirs: string[] = []
const sessionID = "ses-external-edit"
internal.currentSession = info({ id: sessionID, projectID: "backend-workspace", directory: "/workspace" })
internal.trackedSessionIds.add(sessionID)
internal.refreshGitStatus = async (directory) => {
if (directory) dirs.push(directory)
}
internal.handleEvent(
{
type: "message.part.updated",
properties: {
sessionID,
part: {
type: "tool",
state: { status: "completed" },
metadata: { filepath: "/other-repo/src/app.ts" },
},
},
} as Event,
"/workspace",
)
expect(dirs).toEqual([])
})
it("ignores subagents before adopting pending follow-up sessions", async () => {
const service = connection()
const provider = new KiloProvider({} as never, service as never)
@@ -1,4 +1,7 @@
import { describe, it, expect } from "bun:test"
import * as fs from "fs/promises"
import * as os from "os"
import * as path from "path"
import { ProjectRouteService } from "../../src/agent-manager/project/route"
// vscode mock is provided by the shared preload (tests/setup/vscode-mock.ts)
@@ -11,8 +14,9 @@ type SessionGetParams = { sessionID: string; directory: string }
* session.get records every call so tests can assert which directory was
* queried. Mirrors the shape used by kilo-provider-session-refresh.test.ts.
*/
function mockConnection(getImpl?: (p: SessionGetParams) => Promise<unknown>) {
function mockConnection(getImpl?: (p: SessionGetParams) => Promise<unknown>, vcs = "git") {
const calls: SessionGetParams[] = []
const projectCalls: string[] = []
const client = {
session: {
get: async (p: SessionGetParams) => {
@@ -32,6 +36,12 @@ function mockConnection(getImpl?: (p: SessionGetParams) => Promise<unknown>) {
},
list: async () => ({ data: [] }),
},
project: {
current: async (p: { directory: string }) => {
projectCalls.push(p.directory)
return { data: { vcs } }
},
},
provider: { list: async () => ({ data: { all: [], connected: {}, default: {} } }) },
app: {
agents: async () => ({ data: [] }),
@@ -47,6 +57,7 @@ function mockConnection(getImpl?: (p: SessionGetParams) => Promise<unknown>) {
let current: typeof client | null = client
return {
calls,
projectCalls,
connection: {
connect: async () => {
current = client
@@ -77,11 +88,27 @@ function mockConnection(getImpl?: (p: SessionGetParams) => Promise<unknown>) {
}
}
async function withNestedRepo(run: (root: string) => Promise<void>): Promise<void> {
const base = await fs.mkdtemp(path.join(os.tmpdir(), "kilo-nested-repo-"))
const root = path.join(base, "frontend")
await fs.mkdir(root)
const result = Bun.spawnSync({ cmd: ["git", "init"], cwd: root, stdout: "pipe", stderr: "pipe" })
if (result.exitCode !== 0) throw new Error(Buffer.from(result.stderr).toString())
try {
await run(root)
} finally {
await fs.rm(base, { recursive: true, force: true })
}
}
type ProviderInternals = {
client: unknown
connectionState: "connecting" | "connected" | "disconnected" | "error"
initConnectionPromise: Promise<void> | null
isWebviewReady: boolean
webview: { postMessage: (message: unknown) => Promise<unknown> } | null
startStatsPolling: () => void
refreshGitStatus: (directory?: string) => Promise<void>
handleSendCommand: (
command: string,
args: string,
@@ -111,6 +138,49 @@ function connect(internal: ProviderInternals): void {
}
describe("KiloProvider route integration", () => {
it("finds a nested Git root when the workspace parent is not a repo", async () => {
await withNestedRepo(async (root) => {
const source = path.join(root, "src")
await fs.mkdir(source)
const { connection, projectCalls } = mockConnection(undefined, "none")
const provider = new KiloProvider({} as never, connection, undefined, {
rootDirectory: () => source,
})
const internal = provider as unknown as ProviderInternals
const sent: unknown[] = []
internal.connectionState = "connected"
internal.initConnectionPromise = Promise.resolve()
internal.isWebviewReady = true
internal.startStatsPolling = () => {}
internal.webview = { postMessage: async (message) => sent.push(message) }
await internal.refreshGitStatus(source)
expect(projectCalls).toEqual([source])
expect(sent).toContainEqual({ type: "gitStatus", repo: true })
})
})
it("checks Git capability in the active project directory", async () => {
const { connection, projectCalls } = mockConnection()
const provider = new KiloProvider({} as never, connection, undefined, {
rootDirectory: () => "/workspace/parent/project-b",
projectQualifier: () => ({ projectId: "project-b" }),
})
const internal = provider as unknown as ProviderInternals
const sent: unknown[] = []
internal.connectionState = "connected"
internal.initConnectionPromise = Promise.resolve()
internal.isWebviewReady = true
internal.startStatsPolling = () => {}
internal.webview = { postMessage: async (message) => sent.push(message) }
await internal.refreshGitStatus()
expect(projectCalls).toEqual(["/workspace/parent/project-b"])
expect(sent).toContainEqual({ type: "gitStatus", repo: true })
})
it("resolves a unique Local session route to its exact project root", async () => {
const routes = new ProjectRouteService()
routes.registerProject("a", "/repo/a", 1)
@@ -9,11 +9,20 @@ type State = "connecting" | "connected" | "disconnected" | "error"
type ProviderInternals = {
connectionState: State
pendingSessionRefresh: boolean
projectID: string | undefined
webview: { postMessage: (message: unknown) => Promise<unknown> } | null
initializeConnection: () => Promise<void>
handleLoadSessions: () => Promise<void>
}
function deferred<T>() {
let resolve: (value: T) => void = () => {}
const promise = new Promise<T>((done) => {
resolve = done
})
return { promise, resolve }
}
function createContext(overrides?: Partial<SessionRefreshContext>): SessionRefreshContext & { sent: unknown[] } {
const sent: unknown[] = []
return {
@@ -100,6 +109,40 @@ function createConnection(client: ReturnType<typeof createClient>) {
}
describe("KiloProvider pending session refresh", () => {
it("does not let a late listing restore the previous project's identity", async () => {
const client = createClient()
const pending = new Map<string, ReturnType<typeof deferred<{ data: unknown[] }>>>()
client.session.list = async (params: { directory: string }) => {
const next = deferred<{ data: unknown[] }>()
pending.set(params.directory, next)
return next.promise as never
}
const connection = createConnection(client)
await connection.connect()
let active = "a"
const provider = new KiloProvider({} as never, connection as never, undefined, {
rootDirectory: () => `/repo/${active}`,
projectQualifier: () => ({ projectId: active }),
})
const internal = provider as unknown as ProviderInternals
internal.connectionState = "connected"
const first = internal.handleLoadSessions()
active = "b"
const second = internal.handleLoadSessions()
pending.get("/repo/b")!.resolve({
data: [{ id: "ses-b", projectID: "backend-b", time: { created: 1, updated: 1 } }],
})
await second
pending.get("/repo/a")!.resolve({
data: [{ id: "ses-a", projectID: "backend-a", time: { created: 1, updated: 1 } }],
})
await first
expect(internal.projectID).toBe("backend-b")
})
it("keeps worktree sessions with legacy project ids", async () => {
const sent: unknown[] = []
const ctx = createContext({
@@ -1,5 +1,6 @@
import { describe, expect, it } from "bun:test"
import { projectAdjacentHint } from "../../webview-ui/agent-manager/project-local-navigation"
import { worktreeNavId } from "../../webview-ui/agent-manager/navigate"
import { projectAdjacentHint, projectWorktreeRow } from "../../webview-ui/agent-manager/project-local-navigation"
describe("projectAdjacentHint", () => {
it("does not leak a hint to another project with the same raw ID", () => {
@@ -22,4 +23,44 @@ describe("projectAdjacentHint", () => {
projectAdjacentHint("project-b", "project-b", "shared", "local", ["local", "other", "shared"], "prev", "next"),
).toBe("")
})
it("keeps shortcut and worktree keybinding values scoped for duplicate raw IDs", () => {
const bindings = {
previousSession: "Ctrl+Alt+Up",
nextSession: "Ctrl+Alt+Down",
closeWorktree: "Ctrl+Shift+W",
openWorktree: "Ctrl+Shift+O",
}
const shortcuts = new Map([
[worktreeNavId("project-a", "shared"), 2],
[worktreeNavId("project-b", "shared"), 4],
])
const first = projectWorktreeRow({
projectId: "project-a",
activeProjectId: "project-a",
worktreeId: "shared",
activeId: "local",
flatIds: ["local", "shared"],
bindings,
shortcuts,
})
const second = projectWorktreeRow({
projectId: "project-b",
activeProjectId: "project-a",
worktreeId: "shared",
activeId: "local",
flatIds: ["local", "shared"],
bindings,
shortcuts,
})
expect(first.navHint).toBe(bindings.nextSession)
expect(second.navHint).toBe("")
expect(first.shortcut).toBe(2)
expect(second.shortcut).toBe(4)
expect(first.closeKeybind).toBe(bindings.closeWorktree)
expect(first.openKeybind).toBe(bindings.openWorktree)
expect(second.closeKeybind).toBe(bindings.closeWorktree)
expect(second.openKeybind).toBe(bindings.openWorktree)
})
})
@@ -42,4 +42,16 @@ describe("project stores", () => {
same: { worktreeId: "same", state: "running" },
})
})
it("keeps busy worktrees isolated between projects", () => {
const first = createProjectStore("a")
const second = createProjectStore("b")
first.applyState(state("a", ["same"]))
second.applyState(state("b", ["same"]))
first.setBusy(new Map([["same", { reason: "setting-up" as const }]]))
expect(first.busy().has("same")).toBe(true)
expect(second.busy().has("same")).toBe(false)
})
})
@@ -315,10 +315,17 @@ describe("server workspace helpers", () => {
expect(resolveIndexingEnv([{ uri: { fsPath: "/repo" } }])).toEqual({})
})
it("uses the shared database for the managed backend while preserving the environment", () => {
expect(resolveManagedServerEnv({ PATH: "/usr/bin", KILO_DISABLE_CHANNEL_DB: "false" })).toEqual({
it("disables unused managed-backend services while preserving the environment", () => {
expect(
resolveManagedServerEnv({
PATH: "/usr/bin",
KILO_DISABLE_CHANNEL_DB: "false",
KILO_EXPERIMENTAL_DISABLE_FILEWATCHER: "false",
}),
).toEqual({
PATH: "/usr/bin",
KILO_DISABLE_CHANNEL_DB: "true",
KILO_EXPERIMENTAL_DISABLE_FILEWATCHER: "true",
})
})
})
@@ -89,6 +89,7 @@ import type { WorktreeBusyState } from "./project/store"
import { rememberTarget, restoreProjectTarget } from "./project/restore"
import { createProjectStateRouter } from "./project/state"
import { applyRunStatus } from "./project/run-status"
import { clearMultiVersionBusy, markMultiVersionBusy } from "./project/progress"
import { selectLocalAction, selectWorktreeAction } from "./selection-actions"
import { DataBridge } from "../src/App"
import { LanguageBridge } from "../src/context/language-bridge"
@@ -173,6 +174,7 @@ import { createMarkdownRender } from "./review-preferences"
import { createSidebarCollapse } from "./sidebar-collapse"
import { SidebarToggleButton } from "./SidebarToggleButton"
import { setTabWidths } from "./tab-widths"
import { clampPanelWidth, maxPanelWidth, minPanelWidth } from "./side-panel-layout"
import { buildShortcutCategories } from "./shortcuts"
import { tracker } from "./telemetry"
import { createChatFocus, hasQuestionOption } from "./focus"
@@ -273,7 +275,7 @@ const AgentManagerContent: Component = () => {
const MAX_SIDEBAR_WIDTH_RATIO = 0.4
// Recover persisted local session IDs from webview state
const persisted = vscode.getState<PersistedProjectTabs & { sidebarWidth?: number }>()
const persisted = vscode.getState<PersistedProjectTabs & { sidebarWidth?: number; sidePanelWidth?: number }>()
const registry = createProjectRegistry({
persisted: persisted ?? {},
activeId: () => currentProjectId() ?? "single",
@@ -313,26 +315,15 @@ const AgentManagerContent: Component = () => {
const diffLoading = diffs.diffLoading
const setDiffLoading = diffs.setDiffLoading
const diffNotices = diffs.diffNotices
// The diff and terminal panels each remember their own width: a diff
// benefits from half the window, a terminal only needs about a third.
const TERMINAL_MIN_WIDTH = 360
const TERMINAL_MAX_WIDTH = 640
const [diffWidth, setDiffWidth] = createSignal(Math.round(window.innerWidth * 0.5))
const [terminalWidth, setTerminalWidth] = createSignal(
Math.min(TERMINAL_MAX_WIDTH, Math.max(TERMINAL_MIN_WIDTH, Math.round(window.innerWidth / 3))),
)
// The hidden-but-mounted host still fits the terminal, so pick the
// terminal's width whenever one is alive and no other mode is showing.
const widthMode = () => sidePanel() ?? (terms.sides().length > 0 ? "terminal" : null)
const hostWidth = () => (widthMode() === "terminal" ? terminalWidth() : diffWidth())
const sideMin = () => (widthMode() === "terminal" ? TERMINAL_MIN_WIDTH : 200)
// Diff and terminal views share one inspector width, restored from webview
// state so the user's divider position survives panel reloads.
const [panelWidth, setPanelWidth] = createSignal(clampPanelWidth(persisted?.sidePanelWidth, window.innerWidth))
const resizeSide = (width: number) => {
pendingSideWidth = Math.max(sideMin(), Math.min(width, window.innerWidth * 0.8))
pendingSideWidth = clampPanelWidth(width, window.innerWidth)
if (sideRaf !== undefined) return
sideRaf = requestAnimationFrame(() => {
sideRaf = undefined
if (widthMode() === "terminal") setTerminalWidth(pendingSideWidth!)
else setDiffWidth(pendingSideWidth!)
setPanelWidth(pendingSideWidth!)
})
}
const showSideTerminal = () => {
@@ -642,6 +633,7 @@ const AgentManagerContent: Component = () => {
},
key: () => registry.active().id,
width: sidebarWidth,
panelWidth,
get: () => vscode.getState<Record<string, unknown>>(),
set: (value) => vscode.setState(value),
})
@@ -865,6 +857,16 @@ const AgentManagerContent: Component = () => {
/** True when a local session is actively working. */
const isLocalBusy = (): boolean => isAnySessionBusy(localSessionIDs())
const projectBusy = (projectId: string, worktreeId: string | null): boolean => {
if (projectId === activeProjectId()) {
return worktreeId === null ? isLocalBusy() : isAgentBusy(worktreeId)
}
const ids = (projectSessionsLive()[projectId] ?? [])
.filter((item) => item.worktreeId === worktreeId)
.map((item) => item.id)
return isAnySessionBusy(ids)
}
const isSessionBusy = (id: string): boolean => isAnySessionBusy([id])
/** Worktrees sorted so that grouped items are always adjacent, respecting custom order if set. */
@@ -1452,13 +1454,8 @@ const AgentManagerContent: Component = () => {
const ev = msg as unknown as AgentManagerMultiVersionProgressMessage
if (ev.status === "done" && ev.groupId) {
// Clear busy state for all worktrees in this group
setBusyWorktrees((prev) => {
const next = new Map(prev)
for (const wt of worktrees()) {
if (wt.groupId === ev.groupId) next.delete(wt.id)
}
return next
})
const store = ev.projectId ? registry.ensure(ev.projectId) : registry.active()
clearMultiVersionBusy(store, ev.groupId)
}
}
@@ -1467,11 +1464,8 @@ const AgentManagerContent: Component = () => {
if (msg.type === "agentManager.worktreeSetup") {
const ev = msg as AgentManagerWorktreeSetupMessage
if (ev.status === "ready" && ev.sessionId) {
const ms = managedSessions().find((s) => s.id === ev.sessionId)
const wt = ms?.worktreeId ? worktrees().find((w) => w.id === ms.worktreeId) : undefined
if (wt?.groupId) {
setBusyWorktrees((prev) => new Map([...prev, [wt.id, { reason: "setting-up" as const }]]))
}
const store = ev.projectId ? registry.ensure(ev.projectId) : registry.active()
markMultiVersionBusy(store, ev.sessionId)
}
}
@@ -1508,7 +1502,8 @@ const AgentManagerContent: Component = () => {
// Clear busy state — use worktreeId from the message directly
// to avoid race condition where managedSessions() hasn't updated yet
if (ev.worktreeId) {
setBusyWorktrees((prev) => {
const store = ev.projectId ? registry.ensure(ev.projectId) : registry.active()
store.setBusy((prev) => {
const next = new Map(prev)
next.delete(ev.worktreeId)
return next
@@ -2340,6 +2335,9 @@ const AgentManagerContent: Component = () => {
projects={projectList()}
states={projectStates()}
store={(id) => registry.ensure(id)}
busy={(projectId, id) => registry.ensure(projectId).busy().has(id)}
working={(projectId, id) => projectBusy(projectId, id)}
localBusy={(projectId) => projectBusy(projectId, null)}
stats={projectLive.stats()}
local={projectLive.local()}
prs={projectLive.prs()}
@@ -2465,20 +2463,6 @@ const AgentManagerContent: Component = () => {
track={metrics.click}
/>
{/* Empty worktree state */}
<Show when={contextEmpty()}>
<div class="am-empty-state">
<div class="am-empty-state-icon">
<Icon name="branch" size="large" />
</div>
<div class="am-empty-state-text">{t("agentManager.session.noSessions")}</div>
<Button variant="primary" size="small" onClick={handleAddSession}>
{t("agentManager.session.new")}
<span class="am-shortcut-hint">{kb().newTab ?? ""}</span>
</Button>
</div>
</Show>
<Show when={overlay()}>
{(state) => (
<div class="am-setup-overlay">
@@ -2643,16 +2627,16 @@ const AgentManagerContent: Component = () => {
<Show when={sidePanel() !== null || terms.sides().length > 0}>
<div
class={`am-diff-resize ${sidePanel() === null ? "am-side-host-hidden" : ""}`}
style={{ width: `${hostWidth()}px` }}
style={{ width: `${panelWidth()}px` }}
inert={sidePanel() === null}
>
<Show when={sidePanel() !== null}>
<ResizeHandle
direction="horizontal"
edge="start"
size={hostWidth()}
min={sideMin()}
max={Math.round(window.innerWidth * 0.8)}
size={panelWidth()}
min={minPanelWidth(window.innerWidth)}
max={maxPanelWidth(window.innerWidth)}
onResize={resizeSide}
/>
</Show>
@@ -34,7 +34,9 @@ interface Props {
selection?: string
currentSessionID?: () => string | undefined
mode: ModeRouter
busy?: (id: string) => boolean
busy?: (projectId: string, id: string) => boolean
working?: (projectId: string, id: string) => boolean
localBusy?: (projectId: string) => boolean
bindings: Record<string, string>
t: LanguageContextValue["t"]
onSearchRef: (ref: SidebarSearchMenuRef) => void
@@ -212,6 +214,9 @@ export const ProjectList: Component<Props> = (props) => {
project={project}
state={props.states[project.id]}
store={props.store?.(project.id)}
busy={(id) => props.busy?.(project.id, id) ?? false}
working={(id) => props.working?.(project.id, id) ?? false}
localBusy={() => props.localBusy?.(project.id) ?? false}
stats={props.stats[project.id]}
local={props.local[project.id]}
prs={props.prs[project.id]}
@@ -20,8 +20,8 @@ import type {
} from "../src/types/messages"
import type { LanguageContextValue } from "../src/context/language"
import { useVSCode } from "../src/context/vscode"
import { projectAdjacentHint, projectSidebarOrder } from "./project-local-navigation"
import SectionHeader from "./SectionHeader"
import { SidebarSectionHeader } from "./SidebarSectionHeader"
import { WorktreeItem } from "./WorktreeItem"
import { UnassignedSessionsSection } from "./UnassignedSessionsSection"
import { ProjectActions } from "./ProjectActions"
@@ -31,6 +31,7 @@ import { sectionAwareDetector } from "./section-dnd"
import { ConstrainDragXAxis } from "./constrain-drag-x"
import { createProjectStore, type ProjectStore } from "./project/store"
import { randomColor } from "./section-colors"
import { projectSidebarOrder, projectWorktreeRow } from "./project-local-navigation"
const isMac = typeof navigator !== "undefined" && /Mac|iPhone|iPad/.test(navigator.userAgent)
@@ -39,6 +40,8 @@ interface Props {
state?: AgentManagerStateMessage
store?: ProjectStore
busy?: (id: string) => boolean
working?: (id: string) => boolean
localBusy?: () => boolean
stats?: Record<string, WorktreeGitStats>
local?: LocalGitStats
prs?: Record<string, PRStatus | null>
@@ -108,16 +111,16 @@ export const ProjectSidebarBody: Component<Props> = (props) => {
const post = (message: Record<string, unknown>) =>
vscode.postMessage({ ...message, projectId: props.project.id } as never)
const navHint = (id: string) =>
projectAdjacentHint(
props.project.id,
props.selectedProject,
id,
props.selection ?? props.currentSessionID?.(),
sidebarOrder(),
props.bindings.previousSession ?? "",
props.bindings.nextSession ?? "",
)
const row = (id: string) =>
projectWorktreeRow({
projectId: props.project.id,
activeProjectId: props.selectedProject,
worktreeId: id,
activeId: props.selection ?? props.currentSessionID?.(),
flatIds: sidebarOrder(),
bindings: props.bindings,
shortcuts: props.shortcutMap?.(),
})
const scope = (kind: "section" | "worktree", id: string) => `${props.project.id}:${kind}:${id}`
const parse = (kind: "section" | "worktree", value: unknown) => {
@@ -226,6 +229,7 @@ export const ProjectSidebarBody: Component<Props> = (props) => {
const renderWorktree = (worktree: WorktreeState, idx: () => number, list: WorktreeState[]) => {
const label = () => firstOrderedTitle(sessions(worktree.id), store.tabOrder()[worktree.id], worktree.branch)
const subtitle = () => (label() !== worktree.branch ? worktree.branch : undefined)
const values = () => row(worktree.id)
const sortable = createSortable(scope("worktree", worktree.id))
void sortable
return (
@@ -233,16 +237,16 @@ export const ProjectSidebarBody: Component<Props> = (props) => {
<WorktreeItem
worktree={worktree}
sidebarId={`${props.project.id}:${worktree.id}`}
shortcut={props.shortcutMap?.().get(`${props.project.id}:wt:${worktree.id}`)}
label={worktree.label || label()}
subtitle={worktree.label ? (worktree.label !== worktree.branch ? worktree.branch : undefined) : subtitle()}
active={active() && props.selection === worktree.id}
pendingDelete={pending() === worktree.id}
busy={props.busy?.(worktree.id) ?? false}
working={runs()[worktree.id]?.state === "running"}
working={props.working?.(worktree.id) || runs()[worktree.id]?.state === "running"}
stale={state()?.staleWorktreeIds?.includes(worktree.id) === true}
stats={props.stats?.[worktree.id]}
navHint={navHint(worktree.id)}
shortcut={values().shortcut}
navHint={values().navHint}
sessions={sessions(worktree.id).length}
grouped={isGrouped(worktree)}
groupStart={isGroupStart(worktree, idx(), list)}
@@ -250,8 +254,8 @@ export const ProjectSidebarBody: Component<Props> = (props) => {
groupSize={worktree.groupId ? sorted().filter((item) => item.groupId === worktree.groupId).length : 0}
renaming={renaming() === worktree.id}
renameValue={name()}
closeKeybind=""
openKeybind=""
closeKeybind={values().closeKeybind}
openKeybind={values().openKeybind}
pr={props.prs?.[worktree.id] ?? undefined}
runStatus={runs()[worktree.id]}
sections={sections()}
@@ -300,11 +304,13 @@ export const ProjectSidebarBody: Component<Props> = (props) => {
data-sidebar-id={`${props.project.id}:local`}
onClick={() => props.onSelectLocal(props.project.id)}
>
<svg class="am-local-icon" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="2.5" y="3.5" width="15" height="10" rx="1" stroke="currentColor" />
<path d="M6 16.5H14" stroke="currentColor" stroke-linecap="square" />
<path d="M10 13.5V16.5" stroke="currentColor" />
</svg>
<Show when={!props.localBusy?.()} fallback={<Spinner class="am-worktree-spinner" />}>
<svg class="am-local-icon" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="2.5" y="3.5" width="15" height="10" rx="1" stroke="currentColor" />
<path d="M6 16.5H14" stroke="currentColor" stroke-linecap="square" />
<path d="M10 13.5V16.5" stroke="currentColor" />
</svg>
</Show>
<Show when={props.shortcutMap?.().get(`${props.project.id}:local`)}>
{(shortcut) => (
<span class="am-shortcut-badge">
@@ -342,20 +348,25 @@ export const ProjectSidebarBody: Component<Props> = (props) => {
</button>
<div class="am-section">
<div class="am-section-header">
<span class="am-section-label">{props.t("agentManager.section.worktrees")}</span>
<ProjectActions
branch={state()?.defaultBaseBranch ?? props.local?.branch ?? "main"}
bindings={props.bindings}
loaded={state() !== undefined}
t={props.t}
onCreate={() => post({ type: "agentManager.createWorktree" })}
onNew={() => props.onNewWorktree(props.project.id)}
onSection={() => createSection()}
onSetup={() => post({ type: "agentManager.configureSetupScript" })}
onBranch={() => props.onDefaultBranch(props.project.id, state()?.defaultBaseBranch, props.local?.branch)}
/>
</div>
<SidebarSectionHeader
class="am-section-header"
label={<span class="am-section-label">{props.t("agentManager.section.worktrees")}</span>}
actions={
<ProjectActions
branch={state()?.defaultBaseBranch ?? props.local?.branch ?? "main"}
bindings={props.bindings}
loaded={state() !== undefined}
t={props.t}
onCreate={() => post({ type: "agentManager.createWorktree" })}
onNew={() => props.onNewWorktree(props.project.id)}
onSection={() => createSection()}
onSetup={() => post({ type: "agentManager.configureSetupScript" })}
onBranch={() =>
props.onDefaultBranch(props.project.id, state()?.defaultBaseBranch, props.local?.branch)
}
/>
}
/>
<div class="am-worktree-list">
<DragDropProvider
onDragStart={onDragStart}
@@ -5,6 +5,7 @@ import { Icon } from "@kilocode/kilo-ui/icon"
import { IconButton } from "@kilocode/kilo-ui/icon-button"
import type { LanguageContextValue } from "../src/context/language"
import type { AgentProjectSnapshot } from "../src/types/messages"
import { SidebarSectionHeader } from "./SidebarSectionHeader"
interface ProjectsSectionProps {
projects: AgentProjectSnapshot[]
@@ -30,66 +31,75 @@ const ProjectBodySlot: Component<{
*/
export const ProjectsSection: Component<ProjectsSectionProps> = (props) => (
<div class="am-projects">
<div class="am-section-header">
<span class="am-section-label">{props.t("agentManager.projects")}</span>
<div class="am-projects-tools">
{props.tools}
<IconButton
icon="plus"
size="small"
variant="ghost"
label={props.t("agentManager.project.add")}
onClick={props.onAdd}
/>
</div>
</div>
<SidebarSectionHeader
class="am-section-header"
label={<span class="am-section-label">{props.t("agentManager.projects")}</span>}
actions={
<div class="am-projects-tools">
{props.tools}
<IconButton
icon="plus"
size="small"
variant="ghost"
label={props.t("agentManager.project.add")}
onClick={props.onAdd}
/>
</div>
}
/>
<div class="am-projects-list">
<For each={props.projects.map((project) => project.id)}>
{(id) => {
const project = () => props.projects.find((item) => item.id === id)!
return (
<div class="am-project" classList={{ "am-project-active": project().active }}>
<div class="am-project-item" data-project-id={project().id}>
<button
class="am-project-main"
title={project().missing ? props.t("agentManager.project.missing") : project().root}
onClick={() => {
if (project().active || project().missing) return
if (project().trusted) props.onSelect(project().id)
else props.onTrust(project().id)
}}
>
<span class="am-project-label">{project().label}</span>
<Show when={props.count(project().id) !== undefined}>
<span class="am-project-count">({props.count(project().id)})</span>
<div class="am-project">
<SidebarSectionHeader
class="am-project-item"
expanded={project().expanded}
ariaLabel={project().label}
title={project().missing ? props.t("agentManager.project.missing") : project().root}
label={
<>
<span class="am-project-label">{project().label}</span>
<Show when={props.count(project().id) !== undefined}>
<span class="am-project-count">({props.count(project().id)})</span>
</Show>
<Show when={project().missing}>
<Icon name="warning" size="small" />
</Show>
<Show when={!project().trusted && !project().missing}>
<span class="am-project-trust">
<Icon name="lock" size="small" />
{props.t("agentManager.project.trust")}
</span>
</Show>
</>
}
actions={
<Show when={!project().pinned}>
<IconButton
icon="close-small"
size="small"
variant="ghost"
label={props.t("agentManager.project.remove")}
onClick={(event) => {
event.stopPropagation()
props.onRemove(project().id)
}}
/>
</Show>
<Show when={project().missing}>
<Icon name="warning" size="small" />
</Show>
<Show when={!project().trusted && !project().missing}>
<span class="am-project-trust">
<Icon name="lock" size="small" />
{props.t("agentManager.project.trust")}
</span>
</Show>
</button>
<Show when={!project().pinned}>
<IconButton
icon="close-small"
size="small"
variant="ghost"
label={props.t("agentManager.project.remove")}
onClick={() => props.onRemove(project().id)}
/>
</Show>
<button
class="am-project-chevron"
aria-label={project().label}
onClick={() => props.onExpand(project().id, !project().expanded)}
>
<Icon name={project().expanded ? "chevron-down" : "chevron-right"} size="small" />
</button>
</div>
}
onToggle={() => {
if (project().missing) return
if (!project().trusted) {
props.onTrust(project().id)
return
}
const expanded = !project().expanded
props.onExpand(project().id, expanded)
if (!project().active && project().trusted) props.onSelect(project().id)
}}
/>
<Show when={project().expanded}>
<ProjectBodySlot project={project} body={props.body} />
</Show>
@@ -5,6 +5,7 @@ import { Icon } from "@kilocode/kilo-ui/icon"
import type { SectionState } from "../src/types/messages"
import { SECTION_COLORS, colorCss } from "./section-colors"
import { useLanguage } from "../src/context/language"
import { SidebarSectionHeader } from "./SidebarSectionHeader"
interface Props {
section: SectionState
@@ -30,16 +31,23 @@ const SectionHeader: Component<Props> = (props) => {
const { t } = useLanguage()
const [renaming, setRenaming] = createSignal(false)
const [value, setValue] = createSignal("")
let cancelled = false
const border = () => colorCss(props.section.color) ?? "var(--vscode-panel-border)"
const startRename = () => {
cancelled = false
setValue(props.section.name)
setRenaming(true)
}
const commit = () => {
if (cancelled) {
cancelled = false
return
}
const trimmed = value().trim()
cancelled = true
setRenaming(false)
props.onRenameEnd?.()
if (trimmed && trimmed !== props.section.name) {
@@ -48,6 +56,7 @@ const SectionHeader: Component<Props> = (props) => {
}
const cancel = () => {
cancelled = true
setRenaming(false)
props.onRenameEnd?.()
}
@@ -56,11 +65,6 @@ const SectionHeader: Component<Props> = (props) => {
if (props.autoRename && !renaming()) startRename()
})
const handleClick = (e: MouseEvent) => {
if (e.button !== 0 || renaming()) return
props.onToggle()
}
const droppable = createDroppable(props.dropId ?? props.section.id)
return (
@@ -70,41 +74,44 @@ const SectionHeader: Component<Props> = (props) => {
style={{ "--section-color": border() }}
>
<ContextMenu>
<ContextMenu.Trigger class="am-section-group-header" onClick={handleClick}>
<div class="am-section-group-left">
<Icon
name="chevron-down"
size="small"
class={`am-section-group-chevron ${props.section.collapsed ? "am-section-group-chevron-collapsed" : ""}`}
/>
<Show
when={!renaming()}
fallback={
<input
class="am-section-group-rename"
value={value()}
onInput={(e) => setValue(e.currentTarget.value)}
onKeyDown={(e) => {
if (e.key === "Enter") commit()
if (e.key === "Escape") cancel()
}}
onBlur={commit}
onClick={(e) => e.stopPropagation()}
ref={(el) =>
requestAnimationFrame(() =>
requestAnimationFrame(() => {
el.focus()
el.select()
}),
)
}
/>
}
>
<span class="am-section-group-name">{props.section.name}</span>
</Show>
</div>
<span class="am-section-group-count">{props.count}</span>
<ContextMenu.Trigger as="div" style={{ display: "contents" }}>
<SidebarSectionHeader
class="am-section-group-header"
expanded={!props.section.collapsed}
ariaLabel={props.section.name}
label={
<Show
when={!renaming()}
fallback={
<input
class="am-section-group-rename"
value={value()}
onInput={(e) => setValue(e.currentTarget.value)}
onKeyDown={(e) => {
if (e.key === "Enter") commit()
if (e.key === "Escape") cancel()
}}
onBlur={commit}
onClick={(e) => e.stopPropagation()}
ref={(el) =>
requestAnimationFrame(() =>
requestAnimationFrame(() => {
el.focus()
el.select()
}),
)
}
/>
}
>
<span class="am-section-group-name">{props.section.name}</span>
</Show>
}
count={props.count}
onToggle={() => {
if (!renaming()) props.onToggle()
}}
/>
</ContextMenu.Trigger>
<ContextMenu.Portal>
<ContextMenu.Content class="am-ctx-menu">
@@ -28,6 +28,7 @@ import { sectionAwareDetector } from "./section-dnd"
import { ConstrainDragXAxis } from "./constrain-drag-x"
import { useVSCode } from "../src/context/vscode"
import SectionHeader from "./SectionHeader"
import { SidebarSectionHeader } from "./SidebarSectionHeader"
import { WorktreeItem } from "./WorktreeItem"
import { WorktreeSectionActions } from "./WorktreeSectionActions"
import { UnassignedSessionsSection } from "./UnassignedSessionsSection"
@@ -183,26 +184,29 @@ export const SidebarBody: Component<SidebarBodyProps> = (props) => {
{/* WORKTREES section */}
<div class={`am-section ${props.sessionsCollapsed() ? "am-section-grow" : ""}`}>
<div class="am-section-header">
<span class="am-section-label">{props.t("agentManager.section.worktrees")}</span>
<WorktreeSectionActions
items={props.search.items}
current={props.search.current}
bindings={props.bindings()}
branch={props.defaultBranch()}
git={props.isGitRepo()}
loaded={props.loaded()}
t={props.t}
onRef={(value) => props.onSearchRef(value)}
onSelect={props.onSearchSelect}
onCreate={props.onCreateWorktree}
onNew={props.onNewWorktree}
onSection={props.onNewSection}
onShortcuts={props.onShortcuts}
onSetup={props.onSetup}
onBranch={props.onBranch}
/>
</div>
<SidebarSectionHeader
class="am-section-header"
label={<span class="am-section-label">{props.t("agentManager.section.worktrees")}</span>}
actions={
<WorktreeSectionActions
items={props.search.items}
current={props.search.current}
bindings={props.bindings()}
branch={props.defaultBranch()}
git={props.isGitRepo()}
loaded={props.loaded()}
t={props.t}
onRef={(value) => props.onSearchRef(value)}
onSelect={props.onSearchSelect}
onCreate={props.onCreateWorktree}
onNew={props.onNewWorktree}
onSection={props.onNewSection}
onShortcuts={props.onShortcuts}
onSetup={props.onSetup}
onBranch={props.onBranch}
/>
}
/>
<div class="am-worktree-list">
<Show
when={props.worktreesLoaded() && props.sessionsLoaded()}
@@ -0,0 +1,52 @@
import { Show, type Component, type JSX } from "solid-js"
import { Icon } from "@kilocode/kilo-ui/icon"
interface Props {
label: JSX.Element
expanded?: boolean
onToggle?: () => void
count?: JSX.Element
actions?: JSX.Element
class?: string
title?: string
ariaLabel?: string
}
/** Shared layout for sidebar headings with a fixed leading control column. */
export const SidebarSectionHeader: Component<Props> = (props) => {
return (
<div
class={`am-sidebar-header${props.onToggle ? " am-sidebar-header-toggleable" : ""}${props.class ? ` ${props.class}` : ""}`}
title={props.title}
onClick={(event) => {
if (event.button === 0) props.onToggle?.()
}}
>
<div class="am-sidebar-header-main">
<Show when={props.onToggle}>
<button
class="am-sidebar-header-toggle"
type="button"
aria-expanded={props.expanded}
aria-label={props.ariaLabel}
onClick={(event) => {
event.stopPropagation()
props.onToggle?.()
}}
>
<span class="am-sidebar-header-chevron" aria-hidden="true">
<Icon name={props.expanded ? "chevron-down" : "chevron-right"} size="small" />
</span>
</button>
</Show>
<div class="am-sidebar-header-label">{props.label}</div>
</div>
<Show when={props.count !== undefined}>
<span class="am-sidebar-header-count">{props.count}</span>
</Show>
<Show when={props.actions !== undefined}>
<div class="am-sidebar-header-actions">{props.actions}</div>
</Show>
</div>
)
}
@@ -6,6 +6,7 @@ import { Tooltip } from "@kilocode/kilo-ui/tooltip"
import type { SessionInfo } from "../src/types/messages"
import { useLanguage } from "../src/context/language"
import { formatRelativeDate } from "../src/utils/date"
import { SidebarSectionHeader } from "./SidebarSectionHeader"
interface Props {
sessions: Accessor<SessionInfo[]>
@@ -29,12 +30,13 @@ export const UnassignedSessionsSection: Component<Props> = (props) => {
return (
<div class={`am-section ${props.collapsed() ? "" : "am-section-grow"}`}>
<button class="am-section-header am-section-toggle" onClick={props.onToggle}>
<span class="am-section-label">
<Icon name={props.collapsed() ? "chevron-right" : "chevron-down"} size="small" class="am-section-chevron" />
{t("agentManager.section.sessions")}
</span>
</button>
<SidebarSectionHeader
class="am-section-header am-section-toggle"
expanded={!props.collapsed()}
ariaLabel={t("agentManager.section.sessions")}
label={<span class="am-section-label">{t("agentManager.section.sessions")}</span>}
onToggle={props.onToggle}
/>
<Show when={!props.collapsed()}>
<div class="am-list">
<Show
@@ -181,6 +181,83 @@ html[data-theme="kilo-vscode"]
min-height: 0;
}
.am-sidebar-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
min-width: 0;
box-sizing: border-box;
}
.am-sidebar-header-toggleable {
cursor: pointer;
user-select: none;
}
.am-sidebar-header-main {
display: flex;
align-items: center;
gap: 6px;
flex: 1;
min-width: 0;
}
.am-sidebar-header-chevron {
display: flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
flex-shrink: 0;
color: var(--text-weak);
}
.am-sidebar-header-toggle {
display: flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
flex-shrink: 0;
padding: 0;
color: inherit;
background: none;
border: 0;
border-radius: var(--radius-sm);
cursor: pointer;
}
.am-sidebar-header-toggle:hover {
color: var(--text-base);
background: var(--surface-inset-base-hover);
}
.am-sidebar-header-label {
display: flex;
align-items: center;
min-width: 0;
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.am-sidebar-header-actions {
display: flex;
align-items: center;
gap: 2px;
flex-shrink: 0;
}
.am-sidebar-header-count {
flex-shrink: 0;
min-width: 14px;
color: var(--text-weaker);
font-size: var(--kilo-font-size-10);
text-align: center;
}
.am-section-grow {
flex: 1;
min-height: 0;
@@ -193,6 +270,10 @@ html[data-theme="kilo-vscode"]
padding: 4px 8px 2px;
}
.am-project-body .am-section-header {
padding-left: 6px;
}
.am-section-label {
font-size: var(--font-size-small);
font-weight: 600;
@@ -241,23 +322,7 @@ html[data-theme="kilo-vscode"]
border-bottom: 0;
}
.am-project::before {
content: "";
position: absolute;
inset: 6px auto 6px 0;
width: 2px;
border-radius: 0 2px 2px 0;
background: transparent;
}
.am-project-active::before {
background: var(--border-interactive-base);
}
.am-project-item {
display: flex;
align-items: center;
gap: 2px;
min-height: 34px;
padding: 6px 8px 6px 12px;
box-sizing: border-box;
@@ -267,21 +332,11 @@ html[data-theme="kilo-vscode"]
background: var(--surface-inset-base-hover);
}
.am-project-main {
display: flex;
align-items: center;
.am-project-item .am-sidebar-header-label {
gap: 6px;
flex: 1;
min-width: 0;
padding: 0;
background: none;
border: none;
color: inherit;
font-family: inherit;
font-size: var(--font-size-base);
font-weight: 500;
text-align: left;
cursor: pointer;
}
.am-project-label {
@@ -307,30 +362,6 @@ html[data-theme="kilo-vscode"]
color: var(--text-weak);
}
.am-project-chevron {
display: flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
flex-shrink: 0;
background: none;
border: none;
border-radius: var(--radius-sm);
color: var(--text-weak);
cursor: pointer;
padding: 0;
}
.am-project-chevron:hover {
background: var(--surface-inset-base-hover);
color: var(--text-base);
}
.am-project-chevron:disabled {
cursor: default;
}
.am-project-body {
display: flex;
flex-direction: column;
@@ -355,25 +386,10 @@ html[data-theme="kilo-vscode"]
/* Collapsible section toggle */
button.am-section-toggle {
all: unset;
display: flex;
align-items: center;
justify-content: space-between;
padding: 4px 8px 2px;
cursor: pointer;
width: 100%;
box-sizing: border-box;
}
button.am-section-toggle:hover .am-section-label {
.am-section-toggle:hover .am-section-label {
color: var(--text-base);
}
.am-section-chevron {
flex-shrink: 0;
}
.am-section-actions {
display: flex;
align-items: center;
@@ -988,12 +1004,7 @@ button.am-section-toggle:hover .am-section-label {
}
.am-section-group-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 5px 10px 5px 6px;
cursor: pointer;
user-select: none;
padding: 5px 10px 5px 4px;
border-radius: var(--radius-sm);
font-size: var(--kilo-font-size-11);
font-weight: 500;
@@ -1006,38 +1017,12 @@ button.am-section-toggle:hover .am-section-label {
background: var(--surface-interactive-hover, var(--vscode-list-hoverBackground));
}
.am-section-group-left {
display: flex;
align-items: center;
gap: 4px;
min-width: 0;
overflow: hidden;
}
.am-section-group-chevron {
flex-shrink: 0;
opacity: 0.7;
transition: transform 120ms ease;
}
.am-section-group-chevron-collapsed {
transform: rotate(-90deg);
}
.am-section-group-name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.am-section-group-count {
flex-shrink: 0;
font-size: var(--kilo-font-size-10);
color: var(--text-weaker);
min-width: 14px;
text-align: center;
}
.am-section-group-body {
padding-left: 4px;
}
@@ -1,5 +1,11 @@
import { isTextControl } from "../src/utils/focus"
const OPTION = '[data-component="question-dock"] button[data-slot="question-option"]'
/** Keep an active editor, such as the worktree rename input, in control. */
export const preservesTextFocus = (active: Element | null): boolean =>
active !== null && isTextControl(active) && !active.classList.contains("prompt-input")
export function createChatFocus(deps: {
term: () => string | undefined
history: () => boolean
@@ -7,6 +13,7 @@ export function createChatFocus(deps: {
}) {
const focus = (force: boolean) => {
if ((!force && !document.hasFocus()) || deps.term() || deps.history() || deps.review()) return
if (preservesTextFocus(document.activeElement)) return
if (!force && document.activeElement?.matches('[role="tab"]')) return
if (!force && document.activeElement?.closest('[data-component="question-dock"]')) return
if (focusQuestionOption()) return
@@ -9,6 +9,7 @@ export function initialMessage(ev: AgentManagerSendInitialMessage): SendMessageR
if (!ev.text) return undefined
return {
type: "sendMessage",
...(ev.projectId ? { projectId: ev.projectId } : {}),
text: ev.text,
sessionID: ev.sessionId,
providerID: ev.providerID,
@@ -1,4 +1,4 @@
import { adjacentHint } from "./navigate"
import { adjacentHint, worktreeNavId } from "./navigate"
import { buildSidebarOrder } from "./section-helpers"
export function projectSidebarOrder(...args: Parameters<typeof buildSidebarOrder>): string[] {
@@ -17,3 +17,31 @@ export function projectAdjacentHint(
if (projectId !== activeProjectId) return ""
return adjacentHint(itemId, activeId, flatIds, prev, next)
}
interface Input {
projectId: string
activeProjectId?: string
worktreeId: string
activeId?: string
flatIds: string[]
bindings: Record<string, string>
shortcuts?: Map<string, number>
}
/** Resolve the project-scoped values rendered by one worktree row. */
export function projectWorktreeRow(input: Input) {
return {
shortcut: input.shortcuts?.get(worktreeNavId(input.projectId, input.worktreeId)),
navHint: projectAdjacentHint(
input.projectId,
input.activeProjectId,
input.worktreeId,
input.activeId,
input.flatIds,
input.bindings.previousSession ?? "",
input.bindings.nextSession ?? "",
),
closeKeybind: input.bindings.closeWorktree ?? "",
openKeybind: input.bindings.openWorktree ?? "",
}
}
@@ -2,13 +2,14 @@ import { createEffect, createMemo, onCleanup } from "solid-js"
import type { SessionInfo } from "../../src/types/messages/sessions"
/**
* Persist open tabs and the sidebar width to webview state for recovery.
* Persist open tabs and panel widths to webview state for recovery.
* Debounced so a resize drag does not serialize state on every pixel.
*/
export function persistLocalTabs(opts: {
tabs: () => Record<string, string[]>
key: () => string
width: () => number
panelWidth?: () => number
get: () => Record<string, unknown> | undefined
set: (value: Record<string, unknown>) => void
}): void {
@@ -18,9 +19,16 @@ export function persistLocalTabs(opts: {
const tabs = opts.tabs()
const key = opts.key()
const width = opts.width()
const panel = opts.panelWidth?.()
clearTimeout(timer)
timer = setTimeout(() => {
opts.set({ ...(opts.get() ?? {}), localTabs: tabs, localSessionIDs: tabs[key] ?? [], sidebarWidth: width })
opts.set({
...(opts.get() ?? {}),
localTabs: tabs,
localSessionIDs: tabs[key] ?? [],
sidebarWidth: width,
...(panel === undefined ? {} : { sidePanelWidth: panel }),
})
}, 300)
})
onCleanup(() => clearTimeout(timer))
@@ -0,0 +1,26 @@
import type { ProjectStore } from "./store"
/** Clear setup indicators for every worktree in one multi-version group. */
export function clearMultiVersionBusy(store: ProjectStore, groupId: string): void {
const ids = new Set(
store
.worktrees()
.filter((wt) => wt.groupId === groupId)
.map((wt) => wt.id),
)
if (ids.size === 0) return
store.setBusy((prev) => new Map([...prev].filter(([id, busy]) => !ids.has(id) || busy.reason === "deleting")))
}
/** Keep a newly created grouped worktree showing progress until its prompt starts. */
export function markMultiVersionBusy(store: ProjectStore, sessionId: string): void {
const session = store.managedSessions().find((item) => item.id === sessionId)
const id = session?.worktreeId
if (!id) return
const worktree = store.worktrees().find((item) => item.id === id)
if (!worktree?.groupId) return
store.setBusy((prev) => {
if (prev.get(id)?.reason === "deleting") return prev
return new Map([...prev, [id, { reason: "setting-up" as const }]])
})
}
@@ -0,0 +1,25 @@
export const MIN_PANEL_WIDTH = 360
const DEFAULT_PANEL_WIDTH_RATIO = 0.5
const MAX_PANEL_WIDTH_RATIO = 0.8
function viewportWidth(viewport: number): number {
return Number.isFinite(viewport) && viewport > 0 ? viewport : MIN_PANEL_WIDTH
}
export function minPanelWidth(viewport: number): number {
const width = viewportWidth(viewport)
return Math.min(MIN_PANEL_WIDTH, Math.round(width * DEFAULT_PANEL_WIDTH_RATIO))
}
export function maxPanelWidth(viewport: number): number {
const width = viewportWidth(viewport)
return Math.max(minPanelWidth(width), Math.round(width * MAX_PANEL_WIDTH_RATIO))
}
/** Restore or constrain the shared inspector width without trusting saved state. */
export function clampPanelWidth(value: unknown, viewport: number): number {
const width = viewportWidth(viewport)
const fallback = Math.round(width * DEFAULT_PANEL_WIDTH_RATIO)
const candidate = typeof value === "number" && Number.isFinite(value) ? value : fallback
return Math.round(Math.max(minPanelWidth(width), Math.min(candidate, maxPanelWidth(width))))
}
@@ -10,6 +10,7 @@ import { Tooltip } from "@kilocode/kilo-ui/tooltip"
import { FileIcon } from "@kilocode/kilo-ui/file-icon"
import { Icon } from "@kilocode/kilo-ui/icon"
import { showToast } from "@kilocode/kilo-ui/toast"
import { isTextControl } from "../../utils/focus"
import { useSession } from "../../context/session"
import { useLocalTabs } from "../../context/local-tabs"
import { useServer } from "../../context/server"
@@ -418,8 +419,12 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const onFocusPrompt = (event: Event) => {
const defer = () =>
event instanceof CustomEvent && event.detail?.deferFocusToQuestion && props.deferFocusToQuestion?.()
const ownsFocus = () => {
const active = document.activeElement
return active !== textareaRef && isTextControl(active)
}
const focus = () => {
if (defer()) return
if (defer() || ownsFocus()) return
const ref = textareaRef
if (!ref) return
ref.focus({ preventScroll: true })
@@ -427,7 +432,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
focus()
if (!(event instanceof CustomEvent) || !event.detail?.restore) return
const restore = () => {
if (defer()) return
if (defer() || ownsFocus()) return
window.focus()
focus()
}
@@ -900,6 +900,8 @@ export interface SandboxStatusErrorMessage {
// Multi-version creation progress (extension → webview)
export interface AgentManagerMultiVersionProgressMessage {
type: "agentManager.multiVersionProgress"
/** Owning project; absent in single-project mode. */
projectId?: string
status: "creating" | "done"
total: number
completed: number
@@ -1043,6 +1045,8 @@ export interface WorktreeStatsLoadedMessage {
// Set the model for a session (extension → webview, used during multi-version creation)
export interface AgentManagerSetSessionModelMessage {
type: "agentManager.setSessionModel"
/** Owning project; absent in single-project mode. */
projectId?: string
sessionId: string
providerID: string
modelID: string
@@ -1051,6 +1055,8 @@ export interface AgentManagerSetSessionModelMessage {
// Request webview to send initial prompt to a newly created session (extension → webview)
export interface AgentManagerSendInitialMessage {
type: "agentManager.sendInitialMessage"
/** Owning project; absent in single-project mode. */
projectId?: string
sessionId: string
worktreeId: string
text?: string
@@ -23,6 +23,7 @@ import type { MemoryShowMessage, MemoryOperationMessage, RequestMemoryMessage }
export interface SendMessageRequest {
type: "sendMessage"
projectId?: string
text: string
messageID?: string
sessionID?: string
@@ -0,0 +1,9 @@
const nonText = new Set(["button", "checkbox", "file", "hidden", "image", "radio", "range", "reset", "submit"])
/** Whether an element owns editable text focus that should not be stolen. */
export const isTextControl = (el: Element | null): boolean => {
if (!el) return false
if (el.tagName === "TEXTAREA" || el.tagName === "SELECT") return true
if (el.tagName === "INPUT") return !nonText.has((el as HTMLInputElement).type.toLowerCase())
return ("isContentEditable" in el && (el as HTMLElement).isContentEditable) || el.getAttribute("role") === "textbox"
}
+3
View File
@@ -11,6 +11,7 @@ import { legacyReviewCommand, reviewCommand } from "@/kilocode/review/command" /
import { apply as applyOverride, type Override } from "@/kilocode/command/override" // kilocode_change
import PROMPT_INITIALIZE from "./template/initialize.txt"
import { LegacyEvent } from "@opencode-ai/schema/legacy-event"
import { SessionResume } from "@/kilocode/session-resume" // kilocode_change
type State = {
commands: Record<string, Info>
@@ -116,6 +117,8 @@ const layer = Layer.effect(
commands[Default.REVIEW] = reviewCommand()
commands["local-review"] = legacyReviewCommand("local-review")!
commands["local-review-uncommitted"] = legacyReviewCommand("local-review-uncommitted")!
commands["resume-claude"] = SessionResume.resumeClaude
commands["resume-codex"] = SessionResume.resumeCodex
// kilocode_change end
// kilocode_change start - defer partial overrides until all command sources are registered
File diff suppressed because it is too large Load Diff
+297 -2
View File
@@ -2,6 +2,7 @@ import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" // kilocode_change
import { PermissionV1 } from "@opencode-ai/core/v1/permission"
import path from "path"
import fs from "node:fs" // kilocode_change
import { SessionV1 } from "@opencode-ai/core/v1/session"
import os from "os"
import { KiloSessionPrompt } from "@/kilocode/session/prompt" // kilocode_change
@@ -87,6 +88,7 @@ import { SessionReminders } from "./reminders"
import { SessionTools } from "./tools"
import { LLMEvent } from "@opencode-ai/llm"
import { RepositoryCache } from "@opencode-ai/core/repository-cache" // kilocode_change
import { SessionResume } from "@/kilocode/session-resume" // kilocode_change
// @ts-ignore
globalThis.AI_SDK_LOG_WARNINGS = false
@@ -143,10 +145,10 @@ export interface Interface {
// kilocode_change end
readonly loop: (input: LoopInput) => Effect.Effect<SessionV1.WithParts>
readonly shell: (input: ShellInput) => Effect.Effect<SessionV1.WithParts, Session.BusyError>
// kilocode_change start - commands can fail on unmet agent requirements
// kilocode_change start - commands can fail on unmet agent requirements or resume errors
readonly command: (
input: CommandInput,
) => Effect.Effect<SessionV1.WithParts, Image.Error | Agent.RequirementBlockedError>
) => Effect.Effect<SessionV1.WithParts, Image.Error | Agent.RequirementBlockedError | Error>
// kilocode_change end
readonly resolvePromptParts: (template: string) => Effect.Effect<PromptInput["parts"]>
}
@@ -1943,6 +1945,293 @@ export const layer = Layer.effect(
)
})
// kilocode_change start - resume command handler
const isResumeCommand = (name: string): SessionResume.Format | undefined => {
if (name === "resume-claude") return "claude"
if (name === "resume-codex") return "codex"
return undefined
}
const handleResume = Effect.fn("SessionPrompt.handleResume")(function* (input: {
cmdInput: CommandInput
format: SessionResume.Format
}) {
const ctx = yield* InstanceState.context
const session = yield* sessions.get(input.cmdInput.sessionID).pipe(Effect.orDie)
// kilocode_change start - test-only resume roots via Context.Service
const opt = yield* Effect.serviceOption(SessionResume.ResumeRoots)
const roots = Option.getOrUndefined(opt) ?? {}
// kilocode_change end
// Reject nonempty sessions
const msgs = yield* sessions.messages({ sessionID: input.cmdInput.sessionID }).pipe(Effect.orDie)
if (msgs.length > 0) {
const error = new NamedError.Unknown({
message: "Start a new Kilo session, then run the resume command again.",
})
yield* events.publish(Session.Event.Error, { sessionID: input.cmdInput.sessionID, error: error.toObject() })
return yield* Effect.fail(error)
}
// Resolve agent
const agentName = input.cmdInput.agent
const agent = agentName ? yield* agents.get(agentName) : yield* agents.defaultInfo()
if (!agent) {
const available = (yield* agents.list()).filter((a) => !a.hidden).map((a) => a.name)
const hint = available.length ? ` Available agents: ${available.join(", ")}` : ""
const error = new NamedError.Unknown({ message: `Agent not found: "${agentName}".${hint}` })
yield* events.publish(Session.Event.Error, { sessionID: input.cmdInput.sessionID, error: error.toObject() })
return yield* Effect.fail(error)
}
yield* agents.guardRequirements(agent)
// Resolve model
const model = yield* Effect.gen(function* () {
if (input.cmdInput.model) return Provider.parseModel(input.cmdInput.model)
if (agent.model) return agent.model
return yield* currentModel(input.cmdInput.sessionID)
})
yield* getModel(model.providerID, model.modelID, input.cmdInput.sessionID)
const trimmed = input.cmdInput.arguments.trim()
let uuid: string
if (trimmed.length === 0) {
// Show question picker: discover sessions of the requested format only
const cwd = ctx.directory
let claudeFiles: string[] = []
if (input.format === "claude") {
try {
claudeFiles = SessionResume.discoverClaude({ cwd, ...roots })
} catch (cause) {
const code = typeof cause === "object" && cause !== null && "code" in cause ? cause.code : undefined
if (code !== "ENOENT") {
const error = new NamedError.Unknown({ message: "Unreadable Claude transcript directory" })
yield* events.publish(Session.Event.Error, { sessionID: input.cmdInput.sessionID, error: error.toObject() })
return yield* Effect.fail(error)
}
}
}
const codexExit = input.format === "codex"
? yield* Effect.exit(Effect.promise(() => SessionResume.discoverCodex({ cwd, ...roots })))
: undefined
const codexFiles = (codexExit && Exit.isSuccess(codexExit)) ? codexExit.value : []
type Entry = { id: string; format: SessionResume.Format; mtime?: number }
const entries: Entry[] = []
for (const f of claudeFiles) {
const id = path.basename(f, ".jsonl")
let mtime: number | undefined
try { mtime = fs.statSync(f).mtimeMs } catch { mtime = undefined }
entries.push({ id, format: "claude", mtime })
}
for (const f of codexFiles) {
const base = path.basename(f, ".jsonl")
// Derive UUID from final -<uuid> segment: rollout-YYYY-MM-DDTHH-MM-SS-<uuid>
const raw = base.slice("rollout-".length)
const id = raw.split("-").slice(-5).join("-")
let mtime: number | undefined
try { mtime = fs.statSync(f).mtimeMs } catch { mtime = undefined }
entries.push({ id, format: "codex", mtime })
}
if (entries.length === 0) {
const error = new NamedError.Unknown({
message: "No session transcripts found in the current directory. Use /resume-claude <uuid> or /resume-codex <uuid> with an explicit UUID.",
})
yield* events.publish(Session.Event.Error, { sessionID: input.cmdInput.sessionID, error: error.toObject() })
return yield* Effect.fail(error)
}
// Limit and format labels: UUID only, ISO mtime as description
const display = entries.slice(0, 10)
const options = display.map((e) => {
const timeLabel = e.mtime ? new Date(e.mtime).toISOString() : "unknown time"
return {
label: e.id,
description: timeLabel,
}
})
const answers = yield* question.ask({
sessionID: input.cmdInput.sessionID,
questions: [
{
question: `Which recent ${input.format === "claude" ? "Claude Code" : "Codex"} session do you want to resume?`,
header: "Resume session",
options,
multiple: false,
custom: false,
},
],
blocking: true,
})
const pickerAnswer = answers[0]?.[0]
if (pickerAnswer === undefined || pickerAnswer === "") {
return yield* Effect.fail(new NamedError.Unknown({ message: "No session selected." }))
}
const pickerIdx = options.findIndex((o) => o.label === pickerAnswer)
if (pickerIdx < 0 || pickerIdx >= display.length) {
const error = new NamedError.Unknown({
message: `Invalid selection: "${pickerAnswer}"`,
})
yield* events.publish(Session.Event.Error, { sessionID: input.cmdInput.sessionID, error: error.toObject() })
return yield* Effect.fail(error)
}
uuid = display[pickerIdx].id
} else {
uuid = trimmed
}
// Validate UUID
if (!SessionResume.isUUID(uuid)) {
const error = new NamedError.Unknown({
message: `Invalid UUID: ${uuid}`,
})
yield* events.publish(Session.Event.Error, { sessionID: input.cmdInput.sessionID, error: error.toObject() })
return yield* Effect.fail(error)
}
// Discover and parse
const cwd = ctx.directory
const codexExit = input.format === "codex"
? yield* Effect.exit(Effect.promise(() => SessionResume.discoverCodex({ cwd, id: uuid, ...roots })))
: undefined
let file: string | undefined
if (input.format === "claude") {
try {
file = SessionResume.discoverClaude({ cwd, id: uuid, ...roots })[0]
} catch (cause) {
if (cause instanceof SessionResume.ParseError) {
const error = new NamedError.Unknown({ message: cause.message })
yield* events.publish(Session.Event.Error, { sessionID: input.cmdInput.sessionID, error: error.toObject() })
return yield* Effect.fail(error)
}
const code = typeof cause === "object" && cause !== null && "code" in cause ? cause.code : undefined
if (code !== "ENOENT") {
const error = new NamedError.Unknown({ message: `Unreadable Claude transcript: ${uuid}` })
yield* events.publish(Session.Event.Error, { sessionID: input.cmdInput.sessionID, error: error.toObject() })
return yield* Effect.fail(error)
}
}
} else {
file = codexExit && Exit.isSuccess(codexExit) ? codexExit.value[0] : undefined
}
if (!file) {
const error = new NamedError.Unknown({
message: `No ${input.format === "claude" ? "Claude Code" : "OpenAI Codex"} session found with UUID: ${uuid}`,
})
yield* events.publish(Session.Event.Error, { sessionID: input.cmdInput.sessionID, error: error.toObject() })
return yield* Effect.fail(error)
}
const parseExit = yield* Effect.exit(
Effect.tryPromise({
try: () => SessionResume.parse(file),
catch: (err) => {
const msg = err instanceof Error ? err.message : String(err)
return new NamedError.Unknown({ message: `Failed to parse session transcript: ${msg}` })
},
}),
)
if (Exit.isFailure(parseExit)) {
const err = Cause.squash(parseExit.cause)
if (err instanceof NamedError.Unknown) {
yield* events.publish(Session.Event.Error, { sessionID: input.cmdInput.sessionID, error: err.toObject() })
}
return yield* Effect.failCause(parseExit.cause)
}
const transcript = parseExit.value
// Reject transcripts without a real user
const hasRealUser = transcript.steps.some(
(s) => s.role === "user" && s.parts.some((p) => p.type === "text" && p.text.trim().length > 0),
)
if (!hasRealUser) {
const error = new NamedError.Unknown({
message: "The transcript contains no user messages. Nothing was imported.",
})
yield* events.publish(Session.Event.Error, { sessionID: input.cmdInput.sessionID, error: error.toObject() })
return yield* Effect.fail(error)
}
// Map transcript to messages
const { messages: mapped } = SessionResume.mapTranscript(transcript, {
sessionID: input.cmdInput.sessionID,
agent: agent.name,
providerID: model.providerID,
modelID: model.modelID,
directory: ctx.directory,
worktree: ctx.worktree,
sourceModel: transcript.sourceModel,
})
// Reject every assistant before a real user parent before any write
if (mapped.length > 0 && mapped[0].info.role !== "user") {
const error = new NamedError.Unknown({
message: "Transcript starts with an assistant message. The first message must be from a user.",
})
yield* events.publish(Session.Event.Error, { sessionID: input.cmdInput.sessionID, error: error.toObject() })
return yield* Effect.fail(error)
}
// Write messages and parts in order with ascending IDs
const idMap = new Map<string, string>()
for (const item of mapped) {
const newID = MessageID.ascending()
idMap.set(item.info.id as string, newID)
const parentID = item.info.role === "assistant"
? (typeof item.info.parentID === "string" ? idMap.get(item.info.parentID) : undefined)
: undefined
const info = {
...item.info,
id: newID,
sessionID: input.cmdInput.sessionID,
...(parentID && { parentID }),
} as SessionV1.Info
yield* sessions.updateMessage(info)
for (const part of item.parts) {
const partID = PartID.ascending()
const p = {
...part,
id: partID,
messageID: newID,
sessionID: input.cmdInput.sessionID,
} as SessionV1.Part
yield* sessions.updatePart(p)
}
}
yield* sessions.touch(input.cmdInput.sessionID)
// Build result from the final assistant
const resultMsgs = yield* sessions.messages({ sessionID: input.cmdInput.sessionID }).pipe(Effect.orDie)
const last = resultMsgs.findLast((m) => m.info.role === "assistant")
if (!last) {
const error = new NamedError.Unknown({ message: "No assistant message found after resume import" })
yield* events.publish(Session.Event.Error, { sessionID: input.cmdInput.sessionID, error: error.toObject() })
return yield* Effect.fail(error)
}
yield* events.publish(Command.Event.Executed, {
name: input.cmdInput.command,
sessionID: input.cmdInput.sessionID,
arguments: input.cmdInput.arguments,
messageID: last.info.id,
})
return last
})
// kilocode_change end
const command = Effect.fn("SessionPrompt.command")(function* (input: CommandInput) {
yield* Effect.logInfo("command", {
"session.id": input.sessionID,
@@ -1960,6 +2249,12 @@ export const layer = Layer.effect(
throw error
}
const agentName = cmd.agent ?? input.agent
// kilocode_change start - resume commands import external transcripts
const fmt = isResumeCommand(input.command)
if (fmt) {
return yield* handleResume({ cmdInput: input, format: fmt })
}
// kilocode_change end
// kilocode_change start - deprecated review aliases should display a static notice without an LLM turn
const legacy = legacyReviewMessage(input.command)
if (legacy) {
@@ -0,0 +1,22 @@
{"type":"user","version":"2.42.0","isSidechain":false,"message":{"id":"msg_001","role":"user","content":[{"type":"text","text":"Hello, can you help me read a file?"}]}}
{"type":"assistant","version":"2.42.0","isSidechain":false,"message":{"id":"msg_002","role":"assistant","content":[{"type":"text","text":"Of course! Let me read that for you."},{"type":"tool_use","id":"toolu_01","name":"read","input":{"file_path":"src/index.ts"}}]}}
{"type":"user","version":"2.42.0","isSidechain":false,"message":{"id":"msg_003","role":"user","content":[{"type":"tool_result","tool_use_id":"toolu_01","content":"export const x = 1;\nexport const y = 2;"}]}}
{"type":"assistant","version":"2.42.0","isSidechain":false,"message":{"id":"msg_004","role":"assistant","content":[{"type":"text","text":"I can see the file contents. Now let me edit it."},{"type":"tool_use","id":"toolu_02","name":"edit","input":{"file_path":"src/index.ts","old_str":"export const x = 1;","new_str":"export const x = 42;"}},{"type":"tool_use","id":"toolu_03","name":"bash","input":{"command":"bun run typecheck"}}]}}
{"type":"user","version":"2.42.0","isSidechain":false,"message":{"id":"msg_005","role":"user","content":[{"type":"tool_result","tool_use_id":"toolu_02","content":"File edited successfully."},{"type":"tool_result","tool_use_id":"toolu_03","content":"Typecheck passed with no errors."}]}}
{"type":"assistant","version":"2.42.0","isSidechain":false,"message":{"id":"msg_006","role":"assistant","content":[{"type":"text","text":"The edit was applied and typecheck passed. Is there anything else?"}]}}
{"type":"ai-title","version":"2.42.0","isSidechain":true,"message":{"id":"title_001","role":"assistant","content":[{"type":"text","text":"File read and edit session"}]}}
{"type":"user","version":"2.42.0","isSidechain":false,"message":{"id":"msg_007","role":"user","content":[{"type":"text","text":"What is the capital of France?"}]}}
{"type":"assistant","version":"2.42.0","isSidechain":false,"message":{"id":"msg_008","role":"assistant","content":[{"type":"thinking","thinking":"The user is asking a simple factual question. I should answer directly.","signature":"sig_test123"},{"type":"text","text":"The capital of France is Paris."}]}}
{"type":"user","version":"2.42.0","isSidechain":false,"message":{"id":"msg_009","role":"user","content":[{"type":"text","text":"Now run a complex command with multiple tool calls."}]}}
{"type":"assistant","version":"2.42.0","isSidechain":false,"message":{"id":"msg_010","role":"assistant","content":[{"type":"text","text":"Sure, running now."},{"type":"tool_use","id":"toolu_04","name":"bash","input":{"command":"ls -la"}},{"type":"tool_use","id":"toolu_05","name":"bash","input":{"command":"git status"}}]}}
{"type":"user","version":"2.42.0","isSidechain":false,"message":{"id":"msg_011","role":"user","content":[{"type":"tool_result","tool_use_id":"toolu_04","content":"total 8\ndrwxr-xr-x 3 user staff 96 Jan 1 12:00 .\ndrwxr-xr-x 5 user staff 160 Jan 1 12:00 ..\n-rw-r--r-- 1 user staff 42 Jan 1 12:00 index.ts"}]}}
{"type":"assistant","version":"2.42.0","isSidechain":false,"message":{"id":"msg_012","role":"assistant","content":[{"type":"text","text":"One command succeeded. The other result might have been lost."}]}}
{"type":"user","version":"2.42.0","isSidechain":false,"message":{"id":"msg_013","role":"user","content":[{"type":"text","text":"This message repeats IDs intentionally."}]}}
{"type":"last-prompt","version":"2.42.0","isSidechain":true,"message":{"id":"lp_001","role":"user","content":[{"type":"text","text":"This message repeats IDs intentionally."}]}}
{"type":"assistant","version":"2.42.0","isSidechain":false,"message":{"id":"msg_014","role":"assistant","content":[{"type":"tool_use","id":"toolu_01","name":"read","input":{"file_path":"src/index.ts"}}]}}
{"type":"user","version":"2.42.0","isSidechain":false,"message":{"id":"msg_015","role":"user","content":[{"type":"tool_result","tool_use_id":"toolu_01","content":"export const x = 42;\nexport const y = 2;"}]}}
{"type":"assistant","version":"2.42.0","isSidechain":false,"message":{"id":"msg_016","role":"assistant","content":[{"type":"text","text":"Done reading with repeated ID."}]}}
{"type":"user","version":"2.42.0","isSidechain":false,"message":{"id":"msg_017","role":"user","content":[{"type":"text","text":"Try something unsupported."},{"type":"server_tool_result","tool_use_id":"toolu_06","content":"raw server output"}]}}
{"type":"assistant","version":"2.42.0","isSidechain":false,"message":{"id":"msg_018","role":"assistant","content":[{"type":"redacted_thinking","data":"encrypted_data_test"}]}}
{"type":"assistant","version":"2.42.0","isSidechain":false,"message":{"id":"msg_019","role":"assistant","content":[{"type":"custom_block","custom_field":"some_value"}]}}
{"type":"assistant","version":"2.42.0","isSidechain":false,"message":{"id":"msg_020","role":"assistant","content":[{"type":"tool_use","id":"toolu_06","name":"search","input":{"query":"test"}}]}}
@@ -0,0 +1,27 @@
{"type":"session_meta","payload":{"cli_version":"0.8.0","cwd":"/Users/test/project","model_provider":"openai","session_id":"ses_test001"}}
{"type":"response_item","payload":{"id":"item_001","type":"message","role":"user","content":[{"type":"input_text","text":"Read src/index.ts and then edit it."}]}}
{"type":"response_item","payload":{"id":"item_002","type":"function_call","call_id":"call_01","name":"read","arguments":"{\"file_path\":\"src/index.ts\"}"}}
{"type":"response_item","payload":{"id":"item_003","type":"function_call_output","call_id":"call_01","output":"export const x = 1;\nexport const y = 2;"}}
{"type":"response_item","payload":{"id":"item_004","type":"message","role":"assistant","content":[{"type":"output_text","text":"Done reading. Now editing."}]}}
{"type":"response_item","payload":{"id":"item_005","type":"function_call","call_id":"call_02","name":"edit","arguments":"{\"file_path\":\"src/index.ts\",\"old_str\":\"export const x = 1;\",\"new_str\":\"export const x = 42;\"}"}}
{"type":"response_item","payload":{"id":"item_006","type":"function_call","call_id":"call_03","name":"bash","arguments":"{\"command\":\"bun run typecheck\"}"}}
{"type":"response_item","payload":{"id":"item_007","type":"function_call_output","call_id":"call_02","output":"File edited successfully."}}
{"type":"response_item","payload":{"id":"item_008","type":"function_call_output","call_id":"call_03","output":"Typecheck passed with no errors."}}
{"type":"response_item","payload":{"id":"item_009","type":"message","role":"assistant","content":[{"type":"output_text","text":"The edit was applied and typecheck passed."}]}}
{"type":"turn_context","payload":{}}
{"type":"response_item","payload":{"id":"item_010","type":"message","role":"user","content":[{"type":"input_text","text":"What is the capital of France?"}]}}
{"type":"response_item","payload":{"id":"item_011","type":"message","role":"assistant","content":[{"type":"output_text","text":"The capital of France is Paris."}]}}
{"type":"response_item","payload":{"id":"item_012","type":"message","role":"user","content":[{"type":"input_text","text":"Now run multiple commands."}]}}
{"type":"response_item","payload":{"id":"item_013","type":"function_call","call_id":"call_04","name":"bash","arguments":"{\"command\":\"ls -la\"}"}}
{"type":"response_item","payload":{"id":"item_014","type":"function_call","call_id":"call_05","name":"bash","arguments":"{\"command\":\"git status\"}"}}
{"type":"response_item","payload":{"id":"item_015","type":"function_call_output","call_id":"call_04","output":"total 8\ndrwxr-xr-x 3 user staff 96 Jan 1 12:00 ."}}
{"type":"response_item","payload":{"id":"item_016","type":"message","role":"user","content":[{"type":"input_text","text":"One result was lost. Continue."}]}}
{"type":"response_item","payload":{"id":"item_017","type":"message","role":"assistant","content":[{"type":"output_text","text":"Only one command result arrived."}]}}
{"type":"response_item","payload":{"id":"item_018","type":"message","role":"user","content":[{"type":"input_text","text":"Repeated call IDs."}]}}
{"type":"response_item","payload":{"id":"item_019","type":"function_call","call_id":"call_01","name":"read","arguments":"{\"file_path\":\"src/index.ts\"}"}}
{"type":"response_item","payload":{"id":"item_020","type":"function_call_output","call_id":"call_01","output":"export const x = 42;\nexport const y = 2;"}}
{"type":"response_item","payload":{"id":"item_021","type":"message","role":"assistant","content":[{"type":"output_text","text":"Done with repeated call ID."}]}}
{"type":"response_item","payload":{"id":"item_022","type":"message","role":"user","content":[{"type":"input_text","text":"Unsupported content."}]}}
{"type":"response_item","payload":{"id":"item_023","type":"reasoning","summary":[]}}
{"type":"event_msg","payload":{}}
{"type":"response_item","payload":{"id":"item_024","type":"message","role":"assistant","content":[]}}
@@ -0,0 +1,245 @@
import { describe, expect, test } from "bun:test"
import path from "node:path"
import { SessionResume } from "../../../src/kilocode/session-resume"
import { ProviderTransform } from "../../../src/provider/transform"
import { MessageV2 } from "../../../src/session/message-v2"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { ModelV2 } from "@opencode-ai/core/model"
import type { Provider } from "../../../src/provider/provider"
import type { Part as V1Part, WithParts } from "@opencode-ai/core/v1/session"
function cacheType(message: { providerOptions?: unknown }) {
const anthropic = message.providerOptions as { anthropic?: { cacheControl?: { type?: unknown } } } | undefined
return anthropic?.anthropic?.cacheControl?.type
}
// ── Fixtures ──────────────────────────────────────────────────────────────
const claudeFixture = () =>
Bun.file(path.join(__dirname, "../fixture/session-resume/claude.jsonl")).text()
const codexFixture = () =>
Bun.file(path.join(__dirname, "../fixture/session-resume/codex.jsonl")).text()
// ── Model helpers ────────────────────────────────────────────────────────
function openaiModel(id: string, opts: { reasoning?: boolean } = {}): Provider.Model {
return {
id: ModelV2.ID.make(id),
providerID: ProviderV2.ID.make("openai"),
api: { id, url: "https://api.openai.com/v1", npm: "@ai-sdk/openai" },
name: "OpenAI Test",
capabilities: {
temperature: true,
reasoning: opts.reasoning ?? false,
attachment: true,
toolcall: true,
input: { text: true, audio: false, image: true, video: false, pdf: false },
output: { text: true, audio: false, image: false, video: false, pdf: false },
interleaved: false,
},
cost: {
input: 0,
output: 0,
cache: { read: 0, write: 0 },
},
limit: { context: 128000, output: 32000 },
status: "active" as const,
options: {},
headers: {},
release_date: "2025-01-01",
}
}
function anthropicModel(id: string): Provider.Model {
return {
id: ModelV2.ID.make(id),
providerID: ProviderV2.ID.make("anthropic"),
api: { id, url: "https://api.anthropic.com/v1", npm: "@ai-sdk/anthropic" },
name: "Anthropic Test",
capabilities: {
temperature: true,
reasoning: true,
attachment: true,
toolcall: true,
input: { text: true, audio: false, image: true, video: false, pdf: true },
output: { text: true, audio: false, image: false, video: false, pdf: false },
interleaved: false,
},
cost: {
input: 0,
output: 0,
cache: { read: 0, write: 0 },
},
limit: { context: 200000, output: 32000 },
status: "active" as const,
options: {},
headers: {},
release_date: "2025-01-01",
}
}
// ── Transcript helpers ───────────────────────────────────────────────────
const base = {
sessionID: "ses_cache",
agent: "build",
providerID: "openai",
modelID: "gpt-5",
directory: "/test",
worktree: "/test",
}
function castToWithParts(mapped: SessionResume.MappedMessage[]): WithParts[] {
return mapped.map((m) => ({
info: m.info as WithParts["info"],
parts: m.parts as V1Part[],
}))
}
// ── Helper: parse fixture through the full resume pipeline ────────────────
/** Parse a fixture JSONL, map through SessionResume, convert to model messages. */
async function buildModelMessages(model: Provider.Model, fixtureName: "claude" | "codex") {
const content = fixtureName === "claude" ? await claudeFixture() : await codexFixture()
const transcript = SessionResume.parseLines(content)
const { messages } = SessionResume.mapTranscript(transcript, {
...base,
...(transcript.sourceModel ? { sourceModel: transcript.sourceModel } : {}),
})
const msgs = castToWithParts(messages)
return MessageV2.toModelMessages(msgs, model)
}
// ── Cache-proof tests ─────────────────────────────────────────────────────
describe("MessageV2.toModelMessages + ProviderTransform cache continuity", () => {
for (const fixture of ["claude", "codex"] as const) {
test(`keeps the ${fixture} imported prefix and session cache key across two OpenAI requests`, async () => {
const model = openaiModel("gpt-5")
const imported = await buildModelMessages(model, fixture)
const firstOptions = ProviderTransform.options({ model, sessionID: base.sessionID })
const laterOptions = ProviderTransform.options({ model, sessionID: base.sessionID })
const firstInput = [...structuredClone(imported), { role: "user" as const, content: "Continue from import." }]
const laterInput = [
...structuredClone(imported),
{ role: "user" as const, content: "Continue from import." },
{ role: "assistant" as const, content: "The imported history is available." },
{ role: "user" as const, content: "Use a different model for this next turn." },
]
const first = ProviderTransform.message(firstInput, model, firstOptions)
const later = ProviderTransform.message(laterInput, model, laterOptions)
expect(firstOptions.promptCacheKey).toBe(base.sessionID)
expect(laterOptions.promptCacheKey).toBe(base.sessionID)
expect(JSON.stringify(first.slice(0, imported.length))).toBe(JSON.stringify(later.slice(0, imported.length)))
})
}
test("sets store:false for OpenAI models", () => {
const model = openaiModel("gpt-5")
const opts = ProviderTransform.options({ model, sessionID: "ses_test" })
expect(opts.store).toBe(false)
})
test("fixture-derived messages preserve transcript text exactly (claude fixture)", async () => {
const model = openaiModel("gpt-5")
const modelMsgs = await buildModelMessages(model, "claude")
const opts = ProviderTransform.options({ model, sessionID: base.sessionID })
const transformed = ProviderTransform.message(modelMsgs, model, opts)
expect(transformed.length).toBeGreaterThan(0)
const raw = JSON.stringify(transformed)
// First user message from the claude fixture
expect(raw).toContain("Hello, can you help me read a file")
// A later user message from the fixture
expect(raw).toContain("What is the capital of France")
})
test("fixture-derived messages preserve transcript text exactly (codex fixture)", async () => {
const model = openaiModel("gpt-5")
const modelMsgs = await buildModelMessages(model, "codex")
const opts = ProviderTransform.options({ model, sessionID: base.sessionID })
const transformed = ProviderTransform.message(modelMsgs, model, opts)
expect(transformed.length).toBeGreaterThan(0)
const raw = JSON.stringify(transformed)
// First user message from the codex fixture
expect(raw).toContain("Read src/index.ts and then edit it")
expect(raw).toContain("What is the capital of France")
})
})
describe("ProviderTransform.message Anthropic caching", () => {
const SYSTEM = { role: "system" as const, content: "You are a test assistant." }
test("marks the prepended system message with cache control (claude fixture)", async () => {
const model = anthropicModel("claude-sonnet-4-20250514")
const modelMsgs = await buildModelMessages(model, "claude")
const opts = ProviderTransform.options({ model, sessionID: base.sessionID })
const withSystem = [SYSTEM, ...modelMsgs]
const transformed = ProviderTransform.message(withSystem, model, opts)
const sys = transformed.find((m) => m.role === "system")
expect(sys).toBeDefined()
expect(cacheType(sys!)).toBe("ephemeral")
})
test("marks exactly the final two non-system messages with cache control (claude fixture)", async () => {
const model = anthropicModel("claude-sonnet-4-20250514")
const modelMsgs = await buildModelMessages(model, "claude")
const opts = ProviderTransform.options({ model, sessionID: base.sessionID })
const withSystem = [SYSTEM, ...modelMsgs]
const transformed = ProviderTransform.message(withSystem, model, opts)
const nonSystem = transformed.filter((m) => m.role !== "system")
expect(nonSystem.length).toBeGreaterThanOrEqual(2)
const last = nonSystem[nonSystem.length - 1]
const prev = nonSystem[nonSystem.length - 2]
expect(cacheType(last)).toBe("ephemeral")
expect(cacheType(prev)).toBe("ephemeral")
// No other non-system message carries cache control
for (let i = 0; i < nonSystem.length - 2; i++) {
expect(cacheType(nonSystem[i])).toBeUndefined()
}
})
test("marks the prepended system message with cache control (codex fixture)", async () => {
const model = anthropicModel("claude-sonnet-4-20250514")
const modelMsgs = await buildModelMessages(model, "codex")
const opts = ProviderTransform.options({ model, sessionID: base.sessionID })
const withSystem = [SYSTEM, ...modelMsgs]
const transformed = ProviderTransform.message(withSystem, model, opts)
const sys = transformed.find((m) => m.role === "system")
expect(sys).toBeDefined()
expect(cacheType(sys!)).toBe("ephemeral")
})
test("marks exactly the final two non-system messages with cache control (codex fixture)", async () => {
const model = anthropicModel("claude-sonnet-4-20250514")
const modelMsgs = await buildModelMessages(model, "codex")
const opts = ProviderTransform.options({ model, sessionID: base.sessionID })
const withSystem = [SYSTEM, ...modelMsgs]
const transformed = ProviderTransform.message(withSystem, model, opts)
const nonSystem = transformed.filter((m) => m.role !== "system")
expect(nonSystem.length).toBeGreaterThanOrEqual(2)
const last = nonSystem[nonSystem.length - 1]
const prev = nonSystem[nonSystem.length - 2]
expect(cacheType(last)).toBe("ephemeral")
expect(cacheType(prev)).toBe("ephemeral")
for (let i = 0; i < nonSystem.length - 2; i++) {
expect(cacheType(nonSystem[i])).toBeUndefined()
}
})
})
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,980 @@
import { describe, expect, test } from "bun:test"
import fs from "node:fs"
import os from "node:os"
import path from "node:path"
import { SessionResume } from "../../src/kilocode/session-resume"
// ── UUID validation ───────────────────────────────────────────────────
describe("SessionResume.isUUID", () => {
test("accepts valid UUID v4", () => {
expect(SessionResume.isUUID("550e8400-e29b-41d4-a716-446655440000")).toBe(true)
})
test("rejects non-UUID string", () => {
expect(SessionResume.isUUID("not-a-uuid")).toBe(false)
expect(SessionResume.isUUID("")).toBe(false)
expect(SessionResume.isUUID("g1234567-1234-1234-1234-1234567890ab")).toBe(false)
})
})
describe("SessionResume.validateUUID", () => {
test("does not throw for valid UUID", () => {
expect(() => SessionResume.validateUUID("550e8400-e29b-41d4-a716-446655440000")).not.toThrow()
})
test("throws for invalid UUID", () => {
expect(() => SessionResume.validateUUID("bad")).toThrow(SessionResume.ParseError)
})
})
// ── Detection ────────────────────────────────────────────────────────
describe("SessionResume.detect", () => {
test("detects Claude format from real top-level envelope", () => {
const line = {
type: "user",
version: "2.42.0",
isSidechain: false,
message: { id: "msg_001", role: "user", content: [{ type: "text", text: "hello" }] },
}
expect(SessionResume.detect(line)).toBe("claude")
})
test("detects Claude format from assistant with tool_use", () => {
const line = {
type: "assistant",
version: "2.42.0",
isSidechain: false,
message: {
id: "msg_002",
role: "assistant",
content: [{ type: "tool_use", id: "toolu_01", name: "read", input: {} }],
},
}
expect(SessionResume.detect(line)).toBe("claude")
})
test("detects Codex format from session_meta", () => {
const line = { type: "session_meta", payload: { cli_version: "0.8.0", cwd: "/test" } }
expect(SessionResume.detect(line)).toBe("codex")
})
test("detects Codex format from response_item", () => {
const line = {
type: "response_item",
payload: {
id: "item_001",
type: "message",
role: "user",
content: [{ type: "input_text", text: "hello" }],
},
}
expect(SessionResume.detect(line)).toBe("codex")
})
test("returns undefined for non-message objects", () => {
expect(SessionResume.detect(null)).toBeUndefined()
expect(SessionResume.detect("string")).toBeUndefined()
expect(SessionResume.detect({})).toBeUndefined()
expect(SessionResume.detect([])).toBeUndefined()
})
})
// ── Claude parsing ───────────────────────────────────────────────────
describe("Claude parseLines", () => {
test("parses simple user+assistant text exchange with real envelopes", () => {
const text = [
'{"type":"user","version":"2.42.0","isSidechain":false,"message":{"id":"msg_001","role":"user","content":[{"type":"text","text":"Hello"}]}}',
'{"type":"assistant","version":"2.42.0","isSidechain":false,"message":{"id":"msg_002","role":"assistant","content":[{"type":"text","text":"Hi there!"}]}}',
].join("\n")
const result = SessionResume.parseLines(text)
expect(result.format).toBe("claude")
expect(result.version).toBe(2)
expect(result.steps).toHaveLength(2)
expect(result.steps[0]).toEqual({
role: "user",
parts: [{ type: "text", text: "Hello" }],
})
expect(result.steps[1]).toEqual({
role: "assistant",
parts: [{ type: "text", text: "Hi there!" }],
})
})
test("accepts string user content and ignores transcript metadata", () => {
const text = [
'{"type":"permission-mode","sessionId":"session"}',
'{"type":"user","version":"2.42.0","message":{"role":"user","content":"Hello"}}',
'{"type":"assistant","version":"2.42.0","message":{"role":"assistant","content":[{"type":"text","text":"Hi"}]}}',
].join("\n")
expect(SessionResume.parseLines(text).steps).toEqual([
{ role: "user", parts: [{ type: "text", text: "Hello" }] },
{ role: "assistant", parts: [{ type: "text", text: "Hi" }] },
])
})
test("keeps a real user turn beside paired tool results", () => {
const text = [
'{"type":"user","version":"2.42.0","message":{"role":"user","content":[{"type":"text","text":"Start"}]}}',
'{"type":"assistant","version":"2.42.0","message":{"role":"assistant","content":[{"type":"tool_use","id":"tool","name":"read","input":{}}]}}',
'{"type":"user","version":"2.42.0","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"tool","content":"done"},{"type":"text","text":"Continue"}]}}',
].join("\n")
expect(SessionResume.parseLines(text).steps.map((step) => step.role)).toEqual(["user", "assistant", "user"])
expect(SessionResume.parseLines(text).steps[2]?.parts).toEqual([{ type: "text", text: "Continue" }])
})
test("keeps a partially paired tool step before a mixed later user turn", () => {
const text = [
'{"type":"user","version":"2.42.0","message":{"role":"user","content":"Start"}}',
'{"type":"assistant","version":"2.42.0","message":{"role":"assistant","content":[{"type":"tool_use","id":"first","name":"read","input":{}},{"type":"tool_use","id":"second","name":"edit","input":{}}]}}',
'{"type":"user","version":"2.42.0","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"first","content":"done"},{"type":"text","text":"Also say hi"}]}}',
].join("\n")
const steps = SessionResume.parseLines(text).steps
expect(steps.map((step) => step.role)).toEqual(["user", "assistant", "user"])
expect(steps[1]?.parts.map((part) => part.type)).toEqual(["tool_call", "tool_call", "tool_result", "error"])
expect(steps[2]?.parts).toEqual([{ type: "text", text: "Also say hi" }])
})
test("normalizes object tool results", () => {
const claude = [
'{"type":"user","version":"2.42.0","message":{"role":"user","content":[{"type":"text","text":"Start"}]}}',
'{"type":"assistant","version":"2.42.0","message":{"role":"assistant","content":[{"type":"tool_use","id":"tool","name":"read","input":{}}]}}',
'{"type":"user","version":"2.42.0","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"tool","content":{"value":1}}]}}',
].join("\n")
const claudeTool = SessionResume.parseLines(claude).steps[1]?.parts.find((part) => part.type === "tool_result")
expect(claudeTool).toMatchObject({ content: '{"value":1}' })
})
test("reads version from real Claude envelope", () => {
const text = [
'{"type":"user","version":"2.42.0","isSidechain":false,"message":{"id":"msg_001","role":"user","content":[{"type":"text","text":"Hello"}]}}',
'{"type":"assistant","version":"2.42.0","isSidechain":false,"message":{"id":"msg_002","role":"assistant","content":[{"type":"text","text":"Hi"}]}}',
].join("\n")
const result = SessionResume.parseLines(text)
expect(result.version).toBe(2)
})
test("rejects unsupported Claude major version", () => {
const text = [
'{"type":"user","version":"3.0.0","isSidechain":false,"message":{"id":"msg_001","role":"user","content":[{"type":"text","text":"Hello"}]}}',
].join("\n")
expect(() => SessionResume.parseLines(text)).toThrow(SessionResume.ParseError)
})
test("rejects Claude records without version field", () => {
const text = [
'{"type":"user","isSidechain":false,"message":{"id":"msg_001","role":"user","content":[{"type":"text","text":"Hello"}]}}',
].join("\n")
expect(() => SessionResume.parseLines(text)).toThrow("missing version")
})
test("pairs tool_use with tool_result", () => {
const text = [
'{"type":"user","version":"2.42.0","isSidechain":false,"message":{"id":"msg_001","role":"user","content":[{"type":"text","text":"Read file"}]}}',
'{"type":"assistant","version":"2.42.0","isSidechain":false,"message":{"id":"msg_002","role":"assistant","content":[{"type":"text","text":"Reading..."},{"type":"tool_use","id":"toolu_01","name":"read","input":{"file":"foo.ts"}}]}}',
'{"type":"user","version":"2.42.0","isSidechain":false,"message":{"id":"msg_003","role":"user","content":[{"type":"tool_result","tool_use_id":"toolu_01","content":"file contents"}]}}',
'{"type":"assistant","version":"2.42.0","isSidechain":false,"message":{"id":"msg_004","role":"assistant","content":[{"type":"text","text":"Done."}]}}',
].join("\n")
const result = SessionResume.parseLines(text)
expect(result.steps).toHaveLength(3)
expect(result.steps[0].role).toBe("user")
expect(result.steps[0].parts).toEqual([{ type: "text", text: "Read file" }])
expect(result.steps[1].role).toBe("assistant")
const tc = result.steps[1].parts.find((p): p is SessionResume.ToolCall => p.type === "tool_call")
const tr = result.steps[1].parts.find((p): p is SessionResume.ToolResult => p.type === "tool_result")
expect(tc).toBeDefined()
expect(tc!.id).toBe("toolu_01")
expect(tc!.name).toBe("read")
expect(tr).toBeDefined()
expect(tr!.callID).toBe("toolu_01")
expect(tr!.content).toBe("file contents")
expect(result.steps[2].role).toBe("assistant")
expect(result.steps[2].parts).toEqual([{ type: "text", text: "Done." }])
})
test("pairs multiple tool calls with results in one round", () => {
const text = [
'{"type":"user","version":"2.42.0","isSidechain":false,"message":{"id":"msg_001","role":"user","content":[{"type":"text","text":"Do two things"}]}}',
'{"type":"assistant","version":"2.42.0","isSidechain":false,"message":{"id":"msg_002","role":"assistant","content":[{"type":"tool_use","id":"call_a","name":"read","input":{}},{"type":"tool_use","id":"call_b","name":"edit","input":{}}]}}',
'{"type":"user","version":"2.42.0","isSidechain":false,"message":{"id":"msg_003","role":"user","content":[{"type":"tool_result","tool_use_id":"call_a","content":"result A"},{"type":"tool_result","tool_use_id":"call_b","content":"result B"}]}}',
'{"type":"assistant","version":"2.42.0","isSidechain":false,"message":{"id":"msg_004","role":"assistant","content":[{"type":"text","text":"Both done."}]}}',
].join("\n")
const result = SessionResume.parseLines(text)
expect(result.steps).toHaveLength(3)
const mid = result.steps[1]
expect(mid.role).toBe("assistant")
const calls = mid.parts.filter((p): p is SessionResume.ToolCall => p.type === "tool_call")
const results = mid.parts.filter((p): p is SessionResume.ToolResult => p.type === "tool_result")
expect(calls).toHaveLength(2)
expect(results).toHaveLength(2)
expect(calls.map((c) => c.id).sort()).toEqual(["call_a", "call_b"])
expect(results.map((r) => r.callID).sort()).toEqual(["call_a", "call_b"])
})
test("merges consecutive assistant messages into one pending step", () => {
const text = [
'{"type":"user","version":"2.42.0","isSidechain":false,"message":{"id":"msg_001","role":"user","content":[{"type":"text","text":"Go"}]}}',
'{"type":"assistant","version":"2.42.0","isSidechain":false,"message":{"id":"msg_002","role":"assistant","content":[{"type":"tool_use","id":"a","name":"read","input":{}}]}}',
'{"type":"assistant","version":"2.42.0","isSidechain":false,"message":{"id":"msg_003","role":"assistant","content":[{"type":"tool_use","id":"b","name":"edit","input":{}}]}}',
'{"type":"user","version":"2.42.0","isSidechain":false,"message":{"id":"msg_004","role":"user","content":[{"type":"tool_result","tool_use_id":"a","content":"ra"},{"type":"tool_result","tool_use_id":"b","content":"rb"}]}}',
'{"type":"assistant","version":"2.42.0","isSidechain":false,"message":{"id":"msg_005","role":"assistant","content":[{"type":"text","text":"Done."}]}}',
].join("\n")
const result = SessionResume.parseLines(text)
expect(result.steps).toHaveLength(3)
const mid = result.steps[1]
expect(mid.role).toBe("assistant")
const calls = mid.parts.filter((p): p is SessionResume.ToolCall => p.type === "tool_call")
const results = mid.parts.filter((p): p is SessionResume.ToolResult => p.type === "tool_result")
expect(calls).toHaveLength(2)
expect(results).toHaveLength(2)
expect(calls.map((c) => c.id).sort()).toEqual(["a", "b"])
expect(results.map((r) => r.callID).sort()).toEqual(["a", "b"])
})
test("excludes sidechain records from parsed history", () => {
const text = [
'{"type":"user","version":"2.42.0","isSidechain":false,"message":{"id":"msg_001","role":"user","content":[{"type":"text","text":"Hello"}]}}',
'{"type":"user","version":"2.42.0","isSidechain":true,"message":{"id":"side_001","role":"user","content":[{"type":"text","text":"Sidechain"}]}}',
'{"type":"assistant","version":"2.42.0","isSidechain":false,"message":{"id":"msg_002","role":"assistant","content":[{"type":"text","text":"Hi"}]}}',
].join("\n")
const result = SessionResume.parseLines(text)
expect(result.steps).toHaveLength(2)
expect(result.steps[0].role).toBe("user")
expect(result.steps[0].parts).toEqual([{ type: "text", text: "Hello" }])
expect(result.steps[1].role).toBe("assistant")
expect(result.steps[1].parts).toEqual([{ type: "text", text: "Hi" }])
})
test("skips known metadata record types", () => {
const text = [
'{"type":"user","version":"2.42.0","isSidechain":false,"message":{"id":"msg_001","role":"user","content":[{"type":"text","text":"Hello"}]}}',
'{"type":"ai-title","version":"2.42.0","isSidechain":true,"message":{"id":"title_001","role":"assistant","content":[{"type":"text","text":"Session title"}]}}',
'{"type":"assistant","version":"2.42.0","isSidechain":false,"message":{"id":"msg_002","role":"assistant","content":[{"type":"text","text":"Hi"}]}}',
].join("\n")
const result = SessionResume.parseLines(text)
expect(result.steps).toHaveLength(2)
})
test("handles unpaired tool call in final step", () => {
const text = [
'{"type":"user","version":"2.42.0","isSidechain":false,"message":{"id":"msg_001","role":"user","content":[{"type":"text","text":"Run"}]}}',
'{"type":"assistant","version":"2.42.0","isSidechain":false,"message":{"id":"msg_002","role":"assistant","content":[{"type":"tool_use","id":"orphan_1","name":"read","input":{}}]}}',
'{"type":"user","version":"2.42.0","isSidechain":false,"message":{"id":"msg_003","role":"user","content":[{"type":"text","text":"New topic"}]}}',
].join("\n")
const result = SessionResume.parseLines(text)
expect(result.steps).toHaveLength(3)
const mid = result.steps[1]
expect(mid.role).toBe("assistant")
const errors = mid.parts.filter((p): p is SessionResume.ErrorPart => p.type === "error")
expect(errors).toHaveLength(1)
expect(errors[0].message).toContain("Unpaired tool call")
expect(errors[0].message).toContain("orphan_1")
})
test("handles repeated tool_use IDs across different rounds", () => {
const text = [
'{"type":"user","version":"2.42.0","isSidechain":false,"message":{"id":"msg_001","role":"user","content":[{"type":"text","text":"Read twice"}]}}',
'{"type":"assistant","version":"2.42.0","isSidechain":false,"message":{"id":"msg_002","role":"assistant","content":[{"type":"tool_use","id":"toolu_01","name":"read","input":{"file":"a.ts"}}]}}',
'{"type":"user","version":"2.42.0","isSidechain":false,"message":{"id":"msg_003","role":"user","content":[{"type":"tool_result","tool_use_id":"toolu_01","content":"content a"}]}}',
'{"type":"assistant","version":"2.42.0","isSidechain":false,"message":{"id":"msg_004","role":"assistant","content":[{"type":"text","text":"First read done."}]}}',
'{"type":"user","version":"2.42.0","isSidechain":false,"message":{"id":"msg_005","role":"user","content":[{"type":"text","text":"Read again"}]}}',
'{"type":"assistant","version":"2.42.0","isSidechain":false,"message":{"id":"msg_006","role":"assistant","content":[{"type":"tool_use","id":"toolu_01","name":"read","input":{"file":"b.ts"}}]}}',
'{"type":"user","version":"2.42.0","isSidechain":false,"message":{"id":"msg_007","role":"user","content":[{"type":"tool_result","tool_use_id":"toolu_01","content":"content b"}]}}',
'{"type":"assistant","version":"2.42.0","isSidechain":false,"message":{"id":"msg_008","role":"assistant","content":[{"type":"text","text":"Second read done."}]}}',
].join("\n")
const result = SessionResume.parseLines(text)
const toolSteps = result.steps.filter(
(s) => s.role === "assistant" && s.parts.some((p) => p.type === "tool_call"),
)
expect(toolSteps).toHaveLength(2)
for (const step of toolSteps) {
const tr = step.parts.find((p): p is SessionResume.ToolResult => p.type === "tool_result")
expect(tr).toBeDefined()
expect(tr!.callID).toBe("toolu_01")
}
})
test("parses reasoning/thinking blocks from real envelope", () => {
const text = [
'{"type":"user","version":"2.42.0","isSidechain":false,"message":{"id":"msg_001","role":"user","content":[{"type":"text","text":"Q"}]}}',
'{"type":"assistant","version":"2.42.0","isSidechain":false,"message":{"id":"msg_002","role":"assistant","content":[{"type":"thinking","thinking":"Let me think...","signature":"sig_test"},{"type":"text","text":"Answer"}]}}',
].join("\n")
const result = SessionResume.parseLines(text)
const assistant = result.steps[1]
expect(assistant.role).toBe("assistant")
expect(assistant.parts).toHaveLength(2)
expect(assistant.parts[0]).toEqual({ type: "reasoning", text: "Let me think..." })
expect(assistant.parts[1]).toEqual({ type: "text", text: "Answer" })
})
test("handles redacted_thinking as unsupported", () => {
const text = [
'{"type":"user","version":"2.42.0","isSidechain":false,"message":{"id":"msg_001","role":"user","content":[{"type":"text","text":"Test"}]}}',
'{"type":"assistant","version":"2.42.0","isSidechain":false,"message":{"id":"msg_002","role":"assistant","content":[{"type":"redacted_thinking","data":"encrypted"}]}}',
].join("\n")
const result = SessionResume.parseLines(text)
expect(result.unsupported).toBe(1)
const assistant = result.steps[1]
const unsupportedParts = assistant.parts.filter(
(p): p is SessionResume.Unsupported => p.type === "unsupported",
)
expect(unsupportedParts).toHaveLength(1)
expect(unsupportedParts[0].reason).toBe("encrypted reasoning block")
})
test("handles unknown content types as unsupported", () => {
const text = [
'{"type":"user","version":"2.42.0","isSidechain":false,"message":{"id":"msg_001","role":"user","content":[{"type":"text","text":"Test"}]}}',
'{"type":"assistant","version":"2.42.0","isSidechain":false,"message":{"id":"msg_002","role":"assistant","content":[{"type":"custom_block","data":"unknown"}]}}',
].join("\n")
const result = SessionResume.parseLines(text)
expect(result.unsupported).toBe(1)
const assistant = result.steps[1]
const unsupportedParts = assistant.parts.filter(
(p): p is SessionResume.Unsupported => p.type === "unsupported",
)
expect(unsupportedParts).toHaveLength(1)
expect(unsupportedParts[0].reason).toContain("unknown content type")
expect(unsupportedParts[0].reason).toContain("custom_block")
})
test("counts server_tool_result as unsupported in tool-result message", () => {
const text = [
'{"type":"user","version":"2.42.0","isSidechain":false,"message":{"id":"msg_001","role":"user","content":[{"type":"text","text":"Go"}]}}',
'{"type":"assistant","version":"2.42.0","isSidechain":false,"message":{"id":"msg_002","role":"assistant","content":[{"type":"tool_use","id":"toolu_01","name":"read","input":{}}]}}',
'{"type":"user","version":"2.42.0","isSidechain":false,"message":{"id":"msg_003","role":"user","content":[{"type":"tool_result","tool_use_id":"toolu_01","content":"ok"},{"type":"server_tool_result","tool_use_id":"toolu_99","content":"raw"}]}}',
].join("\n")
const result = SessionResume.parseLines(text)
expect(result.unsupported).toBe(1)
const mid = result.steps[1]
expect(mid.role).toBe("assistant")
const unsupportedParts = mid.parts.filter(
(p): p is SessionResume.Unsupported => p.type === "unsupported",
)
expect(unsupportedParts).toHaveLength(1)
expect(unsupportedParts[0].reason).toBe("server_tool_result block")
})
test("rejects empty input", () => {
expect(() => SessionResume.parseLines("")).toThrow(SessionResume.ParseError)
expect(() => SessionResume.parseLines(" \n ")).toThrow(SessionResume.ParseError)
})
test("rejects malformed JSON", () => {
expect(() =>
SessionResume.parseLines(
'{"type":"user","version":"2.42.0","isSidechain":false,"message":{"id":"msg_001","role":"user","content":[{"type":"text","text":"hi"}]}}\n{broken',
),
).toThrow(SessionResume.ParseError)
})
test("rejects a malformed JSON Lines file without returning partial history", async () => {
const directory = fs.mkdtempSync(path.join(os.tmpdir(), "resume-jsonl-"))
try {
const file = path.join(directory, "transcript.jsonl")
fs.writeFileSync(
file,
[
'{"type":"user","version":"2.42.0","message":{"role":"user","content":"Start"}}',
"invalid-json",
'{"type":"assistant","version":"2.42.0","message":{"role":"assistant","content":[{"type":"text","text":"Done"}]}}',
].join("\n"),
)
await expect(SessionResume.parse(file)).rejects.toThrow("Line 2: invalid JSON")
} finally {
fs.rmSync(directory, { recursive: true, force: true })
}
})
})
// ── Codex parsing ────────────────────────────────────────────────────
describe("Codex parseLines", () => {
test("parses simple user+assistant text exchange with real envelopes", () => {
const text = [
'{"type":"session_meta","payload":{"cli_version":"0.8.0","cwd":"/test"}}',
'{"type":"response_item","payload":{"id":"item_001","type":"message","role":"user","content":[{"type":"input_text","text":"Hello"}]}}',
'{"type":"response_item","payload":{"id":"item_002","type":"message","role":"assistant","content":[{"type":"output_text","text":"Hi there!"}]}}',
].join("\n")
const result = SessionResume.parseLines(text)
expect(result.format).toBe("codex")
expect(result.version).toBe(0)
expect(result.steps).toHaveLength(2)
expect(result.steps[0]).toEqual({
role: "user",
parts: [{ type: "text", text: "Hello" }],
})
expect(result.steps[1]).toEqual({
role: "assistant",
parts: [{ type: "text", text: "Hi there!" }],
})
})
test("reads version from session_meta payload", () => {
const text = [
'{"type":"session_meta","payload":{"cli_version":"0.8.0","cwd":"/test"}}',
'{"type":"response_item","payload":{"id":"item_001","type":"message","role":"user","content":[{"type":"input_text","text":"Hello"}]}}',
'{"type":"response_item","payload":{"id":"item_002","type":"message","role":"assistant","content":[{"type":"output_text","text":"Hi"}]}}',
].join("\n")
const result = SessionResume.parseLines(text)
expect(result.version).toBe(0)
})
test("rejects Codex file without session_meta", () => {
const text = [
'{"type":"response_item","payload":{"id":"item_001","type":"message","role":"user","content":[{"type":"input_text","text":"Hello"}]}}',
].join("\n")
expect(() => SessionResume.parseLines(text)).toThrow(SessionResume.ParseError)
})
test("rejects unsupported Codex major version", () => {
const text = [
'{"type":"session_meta","payload":{"cli_version":"1.0.0","cwd":"/test"}}',
'{"type":"response_item","payload":{"id":"item_001","type":"message","role":"user","content":[{"type":"input_text","text":"Hello"}]}}',
].join("\n")
expect(() => SessionResume.parseLines(text)).toThrow(SessionResume.ParseError)
})
test("starts a new assistant step after a completed function call", () => {
const text = [
'{"type":"session_meta","payload":{"cli_version":"0.8.0","cwd":"/test"}}',
'{"type":"response_item","payload":{"id":"item_001","type":"message","role":"user","content":[{"type":"input_text","text":"Read file"}]}}',
'{"type":"response_item","payload":{"id":"item_002","type":"function_call","call_id":"call_1","name":"read","arguments":"{\\"file\\":\\"foo.ts\\"}"}}',
'{"type":"response_item","payload":{"id":"item_003","type":"function_call_output","call_id":"call_1","output":"file contents"}}',
'{"type":"response_item","payload":{"id":"item_004","type":"message","role":"assistant","content":[{"type":"output_text","text":"Done."}]}}',
].join("\n")
const result = SessionResume.parseLines(text)
expect(result.steps).toHaveLength(3)
const mid = result.steps[1]
expect(mid.role).toBe("assistant")
const tc = mid.parts.find((p): p is SessionResume.ToolCall => p.type === "tool_call")
const tr = mid.parts.find((p): p is SessionResume.ToolResult => p.type === "tool_result")
expect(tc).toBeDefined()
expect(tc!.id).toBe("call_1")
expect(tc!.name).toBe("read")
expect(tc!.input).toEqual({ file: "foo.ts" })
expect(tr).toBeDefined()
expect(tr!.callID).toBe("call_1")
expect(tr!.content).toBe("file contents")
expect(result.steps[2]).toEqual({ role: "assistant", parts: [{ type: "text", text: "Done." }] })
})
test("one user with two complete tool rounds produces exact steps in source order", () => {
const text = [
'{"type":"session_meta","payload":{"cli_version":"0.8.0","cwd":"/test"}}',
'{"type":"response_item","payload":{"id":"item_001","type":"message","role":"user","content":[{"type":"input_text","text":"Read then edit"}]}}',
'{"type":"response_item","payload":{"id":"item_002","type":"function_call","call_id":"c1","name":"read","arguments":"{}"}}',
'{"type":"response_item","payload":{"id":"item_003","type":"function_call_output","call_id":"c1","output":"result1"}}',
'{"type":"response_item","payload":{"id":"item_004","type":"message","role":"assistant","content":[{"type":"output_text","text":"Done reading. Now editing."}]}}',
'{"type":"response_item","payload":{"id":"item_005","type":"function_call","call_id":"c2","name":"edit","arguments":"{}"}}',
'{"type":"response_item","payload":{"id":"item_006","type":"function_call_output","call_id":"c2","output":"result2"}}',
'{"type":"response_item","payload":{"id":"item_007","type":"message","role":"assistant","content":[{"type":"output_text","text":"All done."}]}}',
].join("\n")
const result = SessionResume.parseLines(text)
expect(result.steps).toHaveLength(4)
// Step 0: user
expect(result.steps[0].role).toBe("user")
expect(result.steps[0].parts).toEqual([{ type: "text", text: "Read then edit" }])
// Step 1: first assistant tool round
expect(result.steps[1].role).toBe("assistant")
const step1Types = result.steps[1].parts.map((p) => p.type)
expect(step1Types).toEqual(["tool_call", "tool_result"])
const step1Call = result.steps[1].parts[0] as SessionResume.ToolCall
expect(step1Call.id).toBe("c1")
expect(step1Call.name).toBe("read")
const step1Text = result.steps[2].parts[0] as SessionResume.TextPart
expect(step1Text.text).toBe("Done reading. Now editing.")
// Step 2 continues the new assistant step with the second tool round.
const step2Types = result.steps[2].parts.map((p) => p.type)
expect(step2Types).toEqual(["text", "tool_call", "tool_result"])
const step2Call = result.steps[2].parts[1] as SessionResume.ToolCall
expect(step2Call.id).toBe("c2")
expect(step2Call.name).toBe("edit")
const step2Text = result.steps[3].parts[0] as SessionResume.TextPart
expect(step2Text.text).toBe("All done.")
})
test("pairs multiple tool calls with results", () => {
const text = [
'{"type":"session_meta","payload":{"cli_version":"0.8.0","cwd":"/test"}}',
'{"type":"response_item","payload":{"id":"item_001","type":"message","role":"user","content":[{"type":"input_text","text":"Do two things"}]}}',
'{"type":"response_item","payload":{"id":"item_002","type":"function_call","call_id":"c1","name":"read","arguments":"{}"}}',
'{"type":"response_item","payload":{"id":"item_003","type":"function_call","call_id":"c2","name":"edit","arguments":"{}"}}',
'{"type":"response_item","payload":{"id":"item_004","type":"function_call_output","call_id":"c1","output":"r1"}}',
'{"type":"response_item","payload":{"id":"item_005","type":"function_call_output","call_id":"c2","output":"r2"}}',
'{"type":"response_item","payload":{"id":"item_006","type":"message","role":"assistant","content":[{"type":"output_text","text":"Both done."}]}}',
].join("\n")
const result = SessionResume.parseLines(text)
const mid = result.steps[1]
expect(mid.role).toBe("assistant")
const calls = mid.parts.filter((p): p is SessionResume.ToolCall => p.type === "tool_call")
const results = mid.parts.filter((p): p is SessionResume.ToolResult => p.type === "tool_result")
expect(calls).toHaveLength(2)
expect(results).toHaveLength(2)
expect(calls.map((c) => c.id).sort()).toEqual(["c1", "c2"])
expect(results.map((r) => r.callID).sort()).toEqual(["c1", "c2"])
})
test("converts unpaired Codex tool call to terminal error", () => {
const text = [
'{"type":"session_meta","payload":{"cli_version":"0.8.0","cwd":"/test"}}',
'{"type":"response_item","payload":{"id":"item_001","type":"message","role":"user","content":[{"type":"input_text","text":"Go"}]}}',
'{"type":"response_item","payload":{"id":"item_002","type":"function_call","call_id":"orphan","name":"read","arguments":"{}"}}',
'{"type":"response_item","payload":{"id":"item_003","type":"message","role":"user","content":[{"type":"input_text","text":"Next topic"}]}}',
].join("\n")
const result = SessionResume.parseLines(text)
const mid = result.steps[1]
expect(mid.role).toBe("assistant")
const errors = mid.parts.filter((p): p is SessionResume.ErrorPart => p.type === "error")
expect(errors).toHaveLength(1)
expect(errors[0].message).toContain("Unpaired tool call")
expect(errors[0].message).toContain("orphan")
})
test("handles orphan tool output (no matching call)", () => {
const text = [
'{"type":"session_meta","payload":{"cli_version":"0.8.0","cwd":"/test"}}',
'{"type":"response_item","payload":{"id":"item_001","type":"message","role":"user","content":[{"type":"input_text","text":"Go"}]}}',
'{"type":"response_item","payload":{"id":"item_002","type":"function_call_output","call_id":"no_match","output":"orphan"}}',
].join("\n")
const result = SessionResume.parseLines(text)
expect(result.unsupported).toBe(1)
})
test("handles repeated call IDs across rounds", () => {
const text = [
'{"type":"session_meta","payload":{"cli_version":"0.8.0","cwd":"/test"}}',
'{"type":"response_item","payload":{"id":"item_001","type":"message","role":"user","content":[{"type":"input_text","text":"First"}]}}',
'{"type":"response_item","payload":{"id":"item_002","type":"function_call","call_id":"call_01","name":"read","arguments":"{\\"f\\":\\"a\\"}"}}',
'{"type":"response_item","payload":{"id":"item_003","type":"function_call_output","call_id":"call_01","output":"result a"}}',
'{"type":"response_item","payload":{"id":"item_004","type":"message","role":"assistant","content":[{"type":"output_text","text":"Done first."}]}}',
'{"type":"response_item","payload":{"id":"item_005","type":"message","role":"user","content":[{"type":"input_text","text":"Second"}]}}',
'{"type":"response_item","payload":{"id":"item_006","type":"function_call","call_id":"call_01","name":"read","arguments":"{\\"f\\":\\"b\\"}"}}',
'{"type":"response_item","payload":{"id":"item_007","type":"function_call_output","call_id":"call_01","output":"result b"}}',
'{"type":"response_item","payload":{"id":"item_008","type":"message","role":"assistant","content":[{"type":"output_text","text":"Done second."}]}}',
].join("\n")
const result = SessionResume.parseLines(text)
const toolSteps = result.steps.filter(
(s) => s.role === "assistant" && s.parts.some((p) => p.type === "tool_call"),
)
expect(toolSteps).toHaveLength(2)
for (const step of toolSteps) {
const tr = step.parts.find((p): p is SessionResume.ToolResult => p.type === "tool_result")
expect(tr).toBeDefined()
expect(tr!.callID).toBe("call_01")
}
})
test("handles encrypted reasoning (empty summary)", () => {
const text = [
'{"type":"session_meta","payload":{"cli_version":"0.8.0","cwd":"/test"}}',
'{"type":"response_item","payload":{"id":"item_001","type":"message","role":"user","content":[{"type":"input_text","text":"Think deeply"}]}}',
'{"type":"response_item","payload":{"id":"item_002","type":"reasoning","summary":[]}}',
'{"type":"response_item","payload":{"id":"item_003","type":"message","role":"assistant","content":[{"type":"output_text","text":"Answer"}]}}',
].join("\n")
const result = SessionResume.parseLines(text)
expect(result.unsupported).toBe(1)
expect(result.steps).toHaveLength(2)
})
test("counts empty assistant content as unsupported", () => {
const text = [
'{"type":"session_meta","payload":{"cli_version":"0.8.0","cwd":"/test"}}',
'{"type":"response_item","payload":{"id":"item_001","type":"message","role":"user","content":[{"type":"input_text","text":"Test"}]}}',
'{"type":"response_item","payload":{"id":"item_002","type":"message","role":"assistant","content":[]}}',
].join("\n")
const result = SessionResume.parseLines(text)
expect(result.unsupported).toBe(1)
expect(result.steps).toHaveLength(1) // only the user step
})
test("skips turn_context, event_msg, and world_state records", () => {
const text = [
'{"type":"session_meta","payload":{"cli_version":"0.8.0","cwd":"/test"}}',
'{"type":"response_item","payload":{"id":"item_001","type":"message","role":"user","content":[{"type":"input_text","text":"Hello"}]}}',
'{"type":"turn_context","payload":{}}',
'{"type":"event_msg","payload":{}}',
'{"type":"world_state","payload":{}}',
'{"type":"response_item","payload":{"id":"item_002","type":"message","role":"assistant","content":[{"type":"output_text","text":"Hi"}]}}',
].join("\n")
const result = SessionResume.parseLines(text)
expect(result.steps).toHaveLength(2)
})
test("handles partial tool results within one round", () => {
const text = [
'{"type":"session_meta","payload":{"cli_version":"0.8.0","cwd":"/test"}}',
'{"type":"response_item","payload":{"id":"item_001","type":"message","role":"user","content":[{"type":"input_text","text":"Go"}]}}',
'{"type":"response_item","payload":{"id":"item_002","type":"function_call","call_id":"ok","name":"ok","arguments":"{}"}}',
'{"type":"response_item","payload":{"id":"item_003","type":"function_call","call_id":"missing","name":"missing","arguments":"{}"}}',
'{"type":"response_item","payload":{"id":"item_004","type":"function_call_output","call_id":"ok","output":"done"}}',
'{"type":"response_item","payload":{"id":"item_005","type":"message","role":"user","content":[{"type":"input_text","text":"Continuing."}]}}',
].join("\n")
const result = SessionResume.parseLines(text)
const mid = result.steps[1]
expect(mid.role).toBe("assistant")
const results = mid.parts.filter((p): p is SessionResume.ToolResult => p.type === "tool_result")
const errors = mid.parts.filter((p): p is SessionResume.ErrorPart => p.type === "error")
expect(results).toHaveLength(1)
expect(results[0].callID).toBe("ok")
expect(errors).toHaveLength(1)
expect(errors[0].message).toContain("missing")
})
test("handles custom_tool_call and custom_tool_call_output", () => {
const text = [
'{"type":"session_meta","payload":{"cli_version":"0.8.0","cwd":"/test"}}',
'{"type":"response_item","payload":{"id":"item_001","type":"message","role":"user","content":[{"type":"input_text","text":"Run custom tool"}]}}',
'{"type":"response_item","payload":{"id":"item_002","type":"custom_tool_call","call_id":"ct_01","name":"my-tool","input":{"key":"value"}}}',
'{"type":"response_item","payload":{"id":"item_003","type":"custom_tool_call_output","call_id":"ct_01","output":"custom result"}}',
'{"type":"response_item","payload":{"id":"item_004","type":"message","role":"assistant","content":[{"type":"output_text","text":"Done."}]}}',
].join("\n")
const result = SessionResume.parseLines(text)
const mid = result.steps[1]
expect(mid.role).toBe("assistant")
const tc = mid.parts.find((p): p is SessionResume.ToolCall => p.type === "tool_call")
expect(tc).toBeDefined()
expect(tc!.id).toBe("ct_01")
expect(tc!.name).toBe("my-tool")
expect(tc!.input).toEqual({ key: "value" })
const tr = mid.parts.find((p): p is SessionResume.ToolResult => p.type === "tool_result")
expect(tr).toBeDefined()
expect(tr!.callID).toBe("ct_01")
expect(tr!.content).toBe("custom result")
})
test("rejects empty input", () => {
expect(() => SessionResume.parseLines("")).toThrow(SessionResume.ParseError)
})
test("rejects malformed JSON", () => {
expect(() =>
SessionResume.parseLines(
'{"type":"session_meta","payload":{"cli_version":"0.8.0"}}\n{broken',
),
).toThrow(SessionResume.ParseError)
})
})
// ── Discovery ────────────────────────────────────────────────────
describe("SessionResume discovery", () => {
const id = "550e8400-e29b-41d4-a716-446655440000"
test("uses the Claude project slug and injected root", () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "resume-claude-"))
try {
const cwd = "/Users/test/project"
const directory = path.join(root, SessionResume.claudeProjectSlug(cwd))
fs.mkdirSync(directory, { recursive: true })
const expected = path.join(directory, `${id}.jsonl`)
fs.writeFileSync(expected, "{}")
expect(SessionResume.discoverClaude({ claude: root, cwd, id })).toEqual([expected])
expect(() => SessionResume.discoverClaude({ claude: root, cwd, id: "bad" })).toThrow(
SessionResume.ParseError,
)
} finally {
fs.rmSync(root, { recursive: true, force: true })
}
})
test("lists only UUID Claude transcript names", () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "resume-claude-"))
try {
const cwd = "/Users/test/project"
const directory = path.join(root, SessionResume.claudeProjectSlug(cwd))
fs.mkdirSync(directory, { recursive: true })
fs.writeFileSync(path.join(directory, "agent.jsonl"), "{}")
fs.writeFileSync(path.join(directory, "550e8400-e29b-41d4-a716-446655440000.jsonl"), "{}")
expect(SessionResume.discoverClaude({ claude: root, cwd }).map((file) => path.basename(file))).toEqual([
"550e8400-e29b-41d4-a716-446655440000.jsonl",
])
} finally {
fs.rmSync(root, { recursive: true, force: true })
}
})
test("scans Codex rollouts from an injected root", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "resume-codex-"))
try {
const directory = path.join(root, "2026", "08", "03")
fs.mkdirSync(directory, { recursive: true })
const expected = path.join(directory, `rollout-2026-08-03T10-00-00-${id}.jsonl`)
fs.writeFileSync(expected, '{"type":"session_meta","payload":{"cwd":"/repo"}}\n')
await expect(SessionResume.discoverCodex({ codex: root, cwd: "/repo", id })).resolves.toEqual([expected])
await expect(SessionResume.discoverCodex({ codex: root, cwd: "/repo", id: "bad" })).rejects.toThrow(
SessionResume.ParseError,
)
} finally {
fs.rmSync(root, { recursive: true, force: true })
}
})
test("scans a Codex rollout stored at the sessions root", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "resume-codex-"))
try {
const expected = path.join(root, `rollout-2026-08-03T10-00-00-${id}.jsonl`)
fs.writeFileSync(expected, '{"type":"session_meta","payload":{"cwd":"/repo"}}\n')
await expect(SessionResume.discoverCodex({ codex: root, cwd: "/repo", id })).resolves.toEqual([expected])
} finally {
fs.rmSync(root, { recursive: true, force: true })
}
})
test("no-ID discovery matches cwd with backslash-separated paths (Windows)", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "resume-codex-"))
try {
const cwd = "C:\\Users\\dev\\project"
const file = path.join(root, `rollout-2026-08-03T10-00-00-${id}.jsonl`)
// Escaped backslashes in JSON are valid on all platforms
fs.writeFileSync(file, '{"type":"session_meta","payload":{"cwd":"C:\\\\Users\\\\dev\\\\project"}}\n')
await expect(SessionResume.discoverCodex({ codex: root, cwd })).resolves.toEqual([file])
} finally {
fs.rmSync(root, { recursive: true, force: true })
}
})
})
// ── Full file parsing ────────────────────────────────────────────
describe("parse file", () => {
test("parses claude.jsonl fixture with exact step count", async () => {
const result = await SessionResume.parse(
__dirname + "/fixture/session-resume/claude.jsonl",
)
expect(result.format).toBe("claude")
expect(result.version).toBe(2)
// 13 steps: user + 6 assistant tool rounds + interleaved text assistants
expect(result.steps.length).toBe(13)
// First step is user
expect(result.steps[0].role).toBe("user")
expect(result.steps[0].parts[0].type).toBe("text")
// Steps alternate between user and assistant
const roles = result.steps.map((s) => s.role)
expect(roles[0]).toBe("user")
// Has tool calls in at least 3 distinct steps
const toolSteps = result.steps.filter((s) =>
s.parts.some((p) => p.type === "tool_call"),
)
expect(toolSteps.length).toBeGreaterThanOrEqual(3)
// Has reasoning
const reasoningSteps = result.steps.filter((s) =>
s.parts.some((p) => p.type === "reasoning"),
)
expect(reasoningSteps.length).toBeGreaterThanOrEqual(1)
// Unsported content: redacted_thinking + custom_block + server_tool_result in user msg_017
expect(result.unsupported).toBe(3)
})
test("parses codex.jsonl fixture with exact step count", async () => {
const result = await SessionResume.parse(
__dirname + "/fixture/session-resume/codex.jsonl",
)
expect(result.format).toBe("codex")
expect(result.version).toBe(0)
expect(result.steps.length).toBe(14)
// First step is user
expect(result.steps[0].role).toBe("user")
expect(result.steps[0].parts[0].type).toBe("text")
// Has both user and assistant
const roleSet = new Set(result.steps.map((s) => s.role))
expect(roleSet.has("user")).toBe(true)
expect(roleSet.has("assistant")).toBe(true)
// Has tool calls in multiple steps (two distinct tool rounds)
const toolSteps = result.steps.filter((s) =>
s.parts.some((p) => p.type === "tool_call"),
)
expect(toolSteps.length).toBeGreaterThanOrEqual(2)
// Has unsupported content from encrypted reasoning + empty assistant
expect(result.unsupported).toBeGreaterThanOrEqual(2)
})
test("codex fixture: first user produces two complete assistant tool rounds", async () => {
const result = await SessionResume.parse(
__dirname + "/fixture/session-resume/codex.jsonl",
)
// Step 0: user
expect(result.steps[0].role).toBe("user")
expect(result.steps[0].parts[0].type).toBe("text")
// Step 1: first completed tool round.
expect(result.steps[1].role).toBe("assistant")
const step1Types = result.steps[1].parts.map((p) => p.type)
expect(step1Types).toContain("tool_call")
expect(step1Types).toContain("tool_result")
const step1Call = result.steps[1].parts.find(
(p): p is SessionResume.ToolCall => p.type === "tool_call",
)
expect(step1Call).toBeDefined()
expect(step1Call!.name).toBe("read")
// Step 2 starts the next assistant content and tool round.
expect(result.steps[2].role).toBe("assistant")
const step2Calls = result.steps[2].parts.filter(
(p): p is SessionResume.ToolCall => p.type === "tool_call",
)
expect(step2Calls.length).toBe(2)
const step2Results = result.steps[2].parts.filter(
(p): p is SessionResume.ToolResult => p.type === "tool_result",
)
expect(step2Results.length).toBe(2)
const step2Texts = result.steps[2].parts.filter(
(p): p is SessionResume.TextPart => p.type === "text",
)
expect(step2Texts.length).toBeGreaterThanOrEqual(1)
})
test("claude fixture: consecutive assistants before tool result merged into one step", async () => {
const result = await SessionResume.parse(
__dirname + "/fixture/session-resume/claude.jsonl",
)
// msg_018 (redacted_thinking), msg_019 (custom_block), msg_020 (tool_use)
// should be merged into a single assistant step
const lastAssistant = result.steps[result.steps.length - 1]
expect(lastAssistant.role).toBe("assistant")
const partTypes = lastAssistant.parts.map((p) => p.type)
// Should contain unsupported, unsupported, tool_call, error (from markUnpaired)
expect(partTypes.filter((t) => t === "unsupported").length).toBe(2)
expect(partTypes.filter((t) => t === "tool_call").length).toBe(1)
expect(partTypes.filter((t) => t === "error").length).toBe(1)
})
test("claude fixture: sidechain and metadata records excluded", async () => {
const result = await SessionResume.parse(
__dirname + "/fixture/session-resume/claude.jsonl",
)
// The fixture has 22 lines total, but 2 are metadata/sidechain records
// (ai-title line 7, last-prompt line 14)
// Verify no step contains sidechain text
for (const step of result.steps) {
for (const part of step.parts) {
if (part.type === "text") {
expect(part.text).not.toContain("Session title")
}
}
}
})
test("claude fixture: repeated IDs across rounds handled correctly", async () => {
const result = await SessionResume.parse(
__dirname + "/fixture/session-resume/claude.jsonl",
)
// toolu_01 appears in two different rounds (msg_002 and msg_014)
const toolSteps = result.steps.filter(
(s) =>
s.role === "assistant" &&
s.parts.some((p) => p.type === "tool_call" && p.id === "toolu_01"),
)
expect(toolSteps.length).toBe(2)
})
test("codex fixture: repeated call IDs across rounds", async () => {
const result = await SessionResume.parse(
__dirname + "/fixture/session-resume/codex.jsonl",
)
// call_01 appears in two different rounds
const call01Steps = result.steps.filter(
(s) =>
s.role === "assistant" &&
s.parts.some((p) => p.type === "tool_call" && p.id === "call_01"),
)
expect(call01Steps.length).toBe(2)
})
test("codex fixture: missing session_meta is rejected", () => {
const text = [
'{"type":"response_item","payload":{"id":"item_001","type":"message","role":"user","content":[{"type":"input_text","text":"Hello"}]}}',
].join("\n")
expect(() => SessionResume.parseLines(text)).toThrow(SessionResume.ParseError)
})
test("rejects missing file", async () => {
await expect(SessionResume.parse("/nonexistent/file.jsonl")).rejects.toThrow()
})
})
// ── Unrecognized format rejection ────────────────────────────────
describe("rejection", () => {
test("rejects unrecognized format", () => {
expect(() => SessionResume.parseLines('[1,2,3]')).toThrow(SessionResume.ParseError)
expect(() => SessionResume.parseLines('{"key":"value"}')).toThrow(SessionResume.ParseError)
})
})
@@ -727,6 +727,50 @@ describe("RemoteCommand", () => {
// Even when the catalog advertises "compact" (the synthesized built-in),
// a name not in the catalog must still be rejected.
test("resume commands appear from the current catalog", () => {
const catalog = RemoteCommand.build([
{
name: "resume-claude",
description: "import a Claude Code session transcript",
source: "command",
hints: ["$ARGUMENTS"],
template: "must-not-leak",
},
{
name: "resume-codex",
description: "import an OpenAI Codex session transcript",
source: "command",
hints: ["$ARGUMENTS"],
template: "must-not-leak",
},
])
const names = catalog.commands.map((item) => item.name)
expect(names).toContain("resume-claude")
expect(names).toContain("resume-codex")
expect(names).toContain("compact")
expect(JSON.stringify(catalog)).not.toContain("template")
expect(JSON.stringify(catalog)).not.toContain("must-not-leak")
expect(catalog.commands.find((item) => item.name === "resume-claude")?.source).toBe("command")
expect(catalog.commands.find((item) => item.name === "resume-codex")?.source).toBe("command")
})
test("resume commands disappear from a catalog without them", () => {
const catalog = RemoteCommand.build([
{
name: "review",
description: "Review changes",
source: "command",
hints: ["$ARGUMENTS"],
template: "must-not-leak",
},
])
const names = catalog.commands.map((item) => item.name)
expect(names).not.toContain("resume-claude")
expect(names).not.toContain("resume-codex")
})
test("execute rejects arbitrary names even when the catalog advertises built-in compact", async () => {
const calls: unknown[] = []
const remote = RemoteCommand.create({