mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-08-28 19:11:03 +08:00
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:
@@ -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.
|
||||
@@ -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.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@kilocode/kilo-jetbrains": patch
|
||||
---
|
||||
|
||||
Avoid GitHub API rate-limit failures when the JetBrains plugin downloads the pinned Kilo CLI.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"kilo-code": patch
|
||||
---
|
||||
|
||||
Persist the Agent Manager inspector width and share it between the terminal and diff viewer.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"kilo-code": patch
|
||||
---
|
||||
|
||||
Keep multi-project worktree rename inputs focused while selection updates are still settling.
|
||||
@@ -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,
|
||||
|
||||
@@ -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]()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
+19
@@ -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
|
||||
}
|
||||
+13
-1
@@ -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"
|
||||
|
||||
+1
@@ -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"
|
||||
|
||||
+89
-7
@@ -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"
|
||||
}
|
||||
@@ -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": {}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user