From 790affb98f75832a33b680885e4d5fa7586a7290 Mon Sep 17 00:00:00 2001 From: Johnny Amancio Date: Wed, 15 Jul 2026 16:47:19 +0200 Subject: [PATCH] fix: address v1.17.4 compatibility review --- .github/workflows/test.yml | 7 +- packages/core/src/config.ts | 4 +- packages/core/src/config/plugin/reference.ts | 4 +- packages/core/src/connector.ts | 24 +++-- packages/core/src/credential.ts | 86 +++++++++++++-- packages/core/src/filesystem.ts | 51 +++++++-- packages/core/src/filesystem/search.ts | 101 ++++++++++++----- packages/core/src/kilocode/session-message.ts | 32 +++++- packages/core/src/reference.ts | 10 ++ packages/core/src/ripgrep.ts | 31 ++++-- packages/core/src/session.ts | 9 +- packages/core/src/session/compaction.ts | 1 + packages/core/src/session/event.ts | 43 ++++---- packages/core/src/session/message-updater.ts | 3 +- packages/core/src/session/projector.ts | 8 +- packages/core/src/tool/glob.ts | 75 +++++++++---- packages/core/src/tool/grep.ts | 87 ++++++++++----- packages/core/test/config/config.test.ts | 37 +++++++ packages/core/test/connector.test.ts | 47 ++++++++ packages/core/test/credential.test.ts | 102 +++++++++++++++++- packages/core/test/filesystem/search.test.ts | 9 +- .../kilocode/event-storage-compat.test.ts | 68 +++++++++++- packages/core/test/kilocode/grep-tool.test.ts | 95 +++++++++++++--- .../core/test/location-filesystem.test.ts | 23 ++++ packages/core/test/reference.test.ts | 34 +++++- packages/core/test/ripgrep.test.ts | 4 +- packages/core/test/session-projector.test.ts | 5 +- packages/core/test/session-prompt.test.ts | 14 +-- packages/http-recorder/README.md | 11 +- packages/kilo-console/package.json | 1 + packages/kilo-docs/package.json | 1 + packages/kilo-indexing/package.json | 3 +- .../indexing/vector-store/qdrant-client.ts | 4 +- .../test/kilocode/indexing/detect.test.ts | 3 +- .../kilocode/indexing/service-factory.test.ts | 2 +- .../vector-store/lancedb-vector-store.test.ts | 6 +- .../vector-store/qdrant-client.test.ts | 6 +- packages/kilo-telemetry/package.json | 3 +- packages/llm/package.json | 1 + packages/llm/src/schema/messages.ts | 15 ++- packages/opencode/src/agent/agent.ts | 7 ++ .../opencode/src/cli/cmd/debug/ripgrep.ts | 4 +- packages/opencode/src/cli/cmd/mcp.ts | 6 +- packages/opencode/src/kilocode/reference.ts | 40 +++++++ .../routes/instance/httpapi/handlers/file.ts | 4 +- packages/opencode/src/session/compaction.ts | 1 + packages/opencode/src/tool/glob.ts | 8 +- packages/opencode/src/tool/grep.ts | 11 +- packages/plugin-atomic-chat/package.json | 1 + packages/tui/package.json | 1 + packages/tui/src/runtime.tsx | 2 +- 51 files changed, 934 insertions(+), 221 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 462a61c7fe..a5b378fe97 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -133,11 +133,8 @@ jobs: - name: Run non-CLI unit tests if: matrix.settings.run && matrix.settings.packages - # kilocode_change start - retain JUnit targets and explicitly cover packages that only expose `test` - run: | - bun turbo test:ci --output-logs=errors-only --log-order=grouped --log-prefix=task --filter='!@kilocode/cli' --filter='!@kilocode/kilo-jetbrains' - bun turbo test --output-logs=errors-only --log-order=grouped --log-prefix=task --filter='@kilocode/kilo-console' --filter='@kilocode/kilo-docs' --filter='@kilocode/kilo-indexing' --filter='@kilocode/kilo-telemetry' --filter='@kilocode/plugin-atomic-chat' --filter='@opencode-ai/llm' --filter='@opencode-ai/tui' - # kilocode_change end + # kilocode_change - every tested package exposes test:ci; Turbo must never silently select zero generic test tasks. + run: bun turbo test:ci --output-logs=errors-only --log-order=grouped --log-prefix=task --filter='!@kilocode/cli' --filter='!@kilocode/kilo-jetbrains' # kilocode_change start - validate the Darwin profile independently before it can suppress its own tests - name: Validate Darwin CLI test profile diff --git a/packages/core/src/config.ts b/packages/core/src/config.ts index 7ddf9ca63e..75aa8a0615 100644 --- a/packages/core/src/config.ts +++ b/packages/core/src/config.ts @@ -4,6 +4,7 @@ import path from "path" import { type ParseError, parse } from "jsonc-parser" import { Context, Effect, Layer, Option, Schema } from "effect" import { FSUtil } from "./fs-util" +import { Flag } from "./flag/flag" // kilocode_change import { Global } from "./global" import { Location } from "./location" import { PermissionSchema } from "./permission/schema" @@ -173,7 +174,8 @@ export const layer = Layer.effect( const locationIsGlobal = path.resolve(location.directory) === path.resolve(global.config) // Read configuration once when this location opens. Later calls reuse these // values until the location is reopened. - const discovered = locationIsGlobal + // kilocode_change - keep Core V2 config discovery aligned with Kilo's isolated/project-disabled mode. + const discovered = locationIsGlobal || Flag.KILO_DISABLE_PROJECT_CONFIG ? [] : yield* fs .up({ diff --git a/packages/core/src/config/plugin/reference.ts b/packages/core/src/config/plugin/reference.ts index 22c7664996..70b6e60386 100644 --- a/packages/core/src/config/plugin/reference.ts +++ b/packages/core/src/config/plugin/reference.ts @@ -22,7 +22,9 @@ export const Plugin = { for (const doc of (yield* config.entries()).filter( (entry): entry is Config.Document => entry.type === "document", )) { - const directory = doc.path ? path.dirname(doc.path) : location.directory + // kilocode_change - Kilo local references are worktree-relative, falling back to the active directory outside a project. + const root = path.parse(location.project.directory).root + const directory = location.project.directory === root ? location.directory : location.project.directory for (const [name, entry] of Object.entries(doc.info.references ?? {})) { if (!validAlias(name)) continue entries.set( diff --git a/packages/core/src/connector.ts b/packages/core/src/connector.ts index e1d420b674..a97e0898f0 100644 --- a/packages/core/src/connector.ts +++ b/packages/core/src/connector.ts @@ -323,12 +323,15 @@ export const locationLayer = Layer.effect( const settle = Effect.fnUntraced(function* ( attemptID: AttemptID, exit: Exit.Exit, + owned = false, // kilocode_change - completion may pre-claim settlement before awaiting its callback ) { return yield* Effect.uninterruptibleMask((restore) => Effect.gen(function* () { const pending = yield* SynchronizedRef.modify(attempts, (current) => { const attempt = current.get(attemptID) - if (!attempt || attempt.status !== "pending" || attempt.settling) return [undefined, current] + if (!attempt || attempt.status !== "pending") return [undefined, current] // kilocode_change + if (owned) return attempt.settling ? [attempt, current] : [undefined, current] // kilocode_change + if (attempt.settling) return [undefined, current] // kilocode_change return [attempt, new Map(current).set(attemptID, { ...attempt, settling: true })] }) if (!pending) return @@ -493,7 +496,8 @@ export const locationLayer = Layer.effect( const match = current.get(input.attemptID) if (!match || match.status !== "pending" || match.completing) return [match, current] if (match.authorization.mode === "code" && input.code === undefined) return [match, current] - return [match, new Map(current).set(input.attemptID, { ...match, completing: true })] + // kilocode_change - claim the attempt before awaiting the callback so cancel cannot delete it. + return [match, new Map(current).set(input.attemptID, { ...match, completing: true, settling: true })] }) if (!attempt) return yield* Effect.die(`OAuth attempt not found: ${input.attemptID}`) if (attempt.status !== "pending") return @@ -505,10 +509,18 @@ export const locationLayer = Layer.effect( attempt.authorization.mode === "auto" ? attempt.authorization.callback : attempt.authorization.callback(input.code as string) - const exit = yield* authorize(callback).pipe(Effect.exit) - // kilocode_change start - propagate persistence failure after atomic settlement - const settled = yield* settle(input.attemptID, exit) - if (settled && Exit.isFailure(settled)) return yield* settled + // kilocode_change start - an interrupted or timed-out callback still settles and releases its attempt. + return yield* Effect.uninterruptibleMask((restore) => + Effect.gen(function* () { + const exit = yield* restore(authorize(callback)).pipe( + Effect.timeout(settlementTimeout), + Effect.mapError((cause) => new AuthorizationError({ cause })), + Effect.exit, + ) + const settled = yield* settle(input.attemptID, exit, true) + if (settled && Exit.isFailure(settled)) return yield* settled + }), + ) // kilocode_change end }), cancel: Effect.fn("Connector.connect.oauth.cancel")(function* (attemptID) { diff --git a/packages/core/src/credential.ts b/packages/core/src/credential.ts index f4fdde8c29..39f6675712 100644 --- a/packages/core/src/credential.ts +++ b/packages/core/src/credential.ts @@ -1,7 +1,7 @@ export * as Credential from "./credential" -import { and, asc, eq, ne } from "drizzle-orm" -import { Context, Effect, Layer, Option, Schema } from "effect" +import { and, asc, desc, eq, ne } from "drizzle-orm" // kilocode_change +import { Context, Effect, Layer, Option, Schema, Semaphore } from "effect" import { Database } from "./database/database" import { ConnectorSchema } from "./connector/schema" import { EventV2 } from "./event" @@ -173,7 +173,6 @@ export const legacyImportLayer = Layer.effectDiscard( } // kilocode_change end const name = "credential.auth-json" - if (yield* db.select().from(DataMigrationTable).where(eq(DataMigrationTable.name, name)).get()) return const raw = yield* fs.readJson(path.join(global.data, "auth.json")).pipe(Effect.option) if (Option.isNone(raw) || typeof raw.value !== "object" || raw.value === null || Array.isArray(raw.value)) return const decode = Schema.decodeUnknownOption(LegacyValue) @@ -208,14 +207,26 @@ export const legacyImportLayer = Layer.effectDiscard( yield* db.transaction((tx) => Effect.gen(function* () { for (const item of values) { - if ( + // kilocode_change start - reconcile on every startup so a released client can update auth.json after import. + const current = yield* tx + .select() + .from(CredentialTable) + .where(eq(CredentialTable.connector_id, item.connectorID)) + .orderBy(desc(CredentialTable.active), asc(CredentialTable.time_created)) + .get() + yield* tx + .update(CredentialTable) + .set({ active: false }) + .where(eq(CredentialTable.connector_id, item.connectorID)) + .run() + if (current) { yield* tx - .select({ id: CredentialTable.id }) - .from(CredentialTable) - .where(eq(CredentialTable.connector_id, item.connectorID)) - .get() - ) + .update(CredentialTable) + .set({ method_id: item.methodID, value: item.value, active: true }) + .where(eq(CredentialTable.id, current.id)) + .run() continue + } yield* tx.insert(CredentialTable).values({ id: item.id, connector_id: item.connectorID, @@ -224,6 +235,7 @@ export const legacyImportLayer = Layer.effectDiscard( value: item.value, active: true, }) + // kilocode_change end } yield* tx.insert(DataMigrationTable).values({ name, time_completed: Date.now() }).onConflictDoNothing().run() }), @@ -236,6 +248,8 @@ export const layer = Layer.effect( Effect.gen(function* () { const { db } = yield* Database.Service const events = yield* EventV2.Service + const fs = Option.getOrUndefined(yield* Effect.serviceOption(FSUtil.Service)) // kilocode_change + const global = Option.getOrUndefined(yield* Effect.serviceOption(Global.Service)) // kilocode_change const decodeValue = Schema.decodeUnknownSync(Value) const info = (row: typeof CredentialTable.$inferSelect) => new Info({ @@ -312,6 +326,53 @@ export const layer = Layer.effect( const selected = new Map([...injected].map(([connectorID, credential]) => [connectorID, credential.id])) // kilocode_change end + // kilocode_change start - dual-write the active Core credential for released auth.json readers. + const lock = Semaphore.makeUnsafe(1) + const writeLegacy = (connectorID: ConnectorSchema.ID) => + lock.withPermit( + Effect.gen(function* () { + if (!fs || !global || isolated) return + const file = path.join(global.data, "auth.json") + const raw = yield* fs.readJson(file).pipe( + Effect.catchReason("PlatformError", "NotFound", () => Effect.succeed({})), + Effect.catch((cause) => + Effect.logWarning("failed to read legacy auth.json; preserving existing file", { cause }).pipe( + Effect.as(undefined), + ), + ), + ) + if (raw === undefined) return + const data: Record = + typeof raw === "object" && raw !== null && !Array.isArray(raw) + ? { ...(raw as Record) } + : {} + const row = yield* db + .select() + .from(CredentialTable) + .where(and(eq(CredentialTable.connector_id, connectorID), eq(CredentialTable.active, true))) + .get() + .pipe(Effect.orDie) + delete data[connectorID + "/"] + if (!row) delete data[connectorID] + else { + const value = decodeValue(row.value) + data[connectorID] = + value.type === "key" + ? { type: "api", key: value.key, metadata: value.metadata } + : { + type: "oauth", + refresh: value.refresh, + access: value.access, + expires: value.expires, + accountId: value.metadata?.accountID, + enterpriseUrl: value.metadata?.enterpriseURL, + } + } + yield* fs.writeJson(file, data, 0o600).pipe(Effect.orDie) + }), + ) + // kilocode_change end + const activate = Effect.fn("Credential.activate")(function* (id: ID) { // kilocode_change start - isolated credential state remains process-local if (isolated) { @@ -345,6 +406,7 @@ export const layer = Layer.effect( ) .pipe(Effect.orDie) if (switched) yield* events.publish(Event.Switched, switched) + if (switched) yield* writeLegacy(switched.connectorID) // kilocode_change }) return Service.of({ @@ -449,6 +511,7 @@ export const layer = Layer.effect( .pipe(Effect.orDie) yield* events.publish(Event.Added, { credential }) yield* events.publish(Event.Switched, { connectorID: credential.connectorID, from, to: credential.id }) + yield* writeLegacy(credential.connectorID) // kilocode_change return credential }), update: Effect.fn("Credential.update")(function* (id, updates) { @@ -468,12 +531,14 @@ export const layer = Layer.effect( return } // kilocode_change end + const row = yield* db.select().from(CredentialTable).where(eq(CredentialTable.id, id)).get().pipe(Effect.orDie) // kilocode_change yield* db .update(CredentialTable) .set({ label: updates.label, value: updates.value }) .where(eq(CredentialTable.id, id)) .run() .pipe(Effect.orDie) + if (row?.active) yield* writeLegacy(row.connector_id) // kilocode_change }), remove: Effect.fn("Credential.remove")(function* (id) { // kilocode_change start - isolated removals and fallback selection remain process-local @@ -529,6 +594,7 @@ export const layer = Layer.effect( if (!removed) return yield* events.publish(Event.Removed, { credential: removed.credential }) if (removed.switched) yield* events.publish(Event.Switched, removed.switched) + yield* writeLegacy(removed.credential.connectorID) // kilocode_change }), activate, }) @@ -538,6 +604,8 @@ export const layer = Layer.effect( export const defaultLayer = layer.pipe( Layer.provide(Database.defaultLayer), Layer.provide(EventV2.defaultLayer), + Layer.provide(FSUtil.defaultLayer), // kilocode_change + Layer.provide(Global.defaultLayer), // kilocode_change Layer.provideMerge( legacyImportLayer.pipe( Layer.provide(Database.defaultLayer), diff --git a/packages/core/src/filesystem.ts b/packages/core/src/filesystem.ts index 3257fe8840..8ecb09a395 100644 --- a/packages/core/src/filesystem.ts +++ b/packages/core/src/filesystem.ts @@ -1,13 +1,14 @@ export * as FileSystem from "./filesystem" import path from "path" -import { Context, Effect, Layer, Schema } from "effect" +import { Context, Effect, Layer, Option, Schema } from "effect" // kilocode_change import { EventV2 } from "./event" import { FSUtil } from "./fs-util" import { Location } from "./location" import { PositiveInt, RelativePath } from "./schema" import { FileSystemSearch } from "./filesystem/search" import { Entry, Match } from "./filesystem/schema" +import * as SearchTarget from "./kilocode/search-target" // kilocode_change export { Entry, Match, Submatch } from "./filesystem/schema" export const ReadInput = Schema.Struct({ @@ -35,6 +36,10 @@ export class FindInput extends Schema.Class("FileSystem.FindInput")({ limit: PositiveInt.pipe(Schema.optional), }) {} +export const DEFAULT_SEARCH_LIMIT = 100 // kilocode_change - preserve bounded Kilo tool searches +export const MAX_SEARCH_LIMIT = 100 // kilocode_change +export const SearchLimit = PositiveInt.check(Schema.isLessThanOrEqualTo(MAX_SEARCH_LIMIT)) // kilocode_change + export class GlobInput extends Schema.Class("FileSystem.GlobInput")({ pattern: Schema.String, path: RelativePath.pipe(Schema.optional), @@ -80,7 +85,8 @@ const baseLayer = Layer.effect( return yield* Effect.die(new Error("Path escapes the location")) const real = yield* fs.realPath(absolute).pipe(Effect.orDie) if (!FSUtil.contains(root, real)) return yield* Effect.die(new Error("Path escapes the location")) - return { absolute, real, directory: location.directory, root } + const target = yield* SearchTarget.inspect(fs, real).pipe(Effect.orDie) // kilocode_change + return { absolute, real, directory: location.directory, root, target } // kilocode_change }) return Service.of({ find: search.find, @@ -88,18 +94,38 @@ const baseLayer = Layer.effect( grep: search.grep, read: Effect.fn("FileSystem.read")(function* (input) { const target = yield* resolve(input.path) - const info = yield* fs.stat(target.real).pipe(Effect.orDie) - if (info.type !== "File") return yield* Effect.die(new Error("Path is not a file")) - return { - content: yield* fs.readFile(target.real).pipe(Effect.orDie), - mime: FSUtil.mimeType(target.real), - } + if (target.target.type !== "file") return yield* Effect.die(new Error("Path is not a file")) // kilocode_change + // kilocode_change start - read from the validated descriptor, not a second pathname lookup. + return yield* Effect.scoped( + Effect.gen(function* () { + const file = yield* fs.open(target.real, { flag: "r" }).pipe(Effect.orDie) + const info = yield* file.stat.pipe(Effect.orDie) + if ( + info.type !== "File" || + info.dev !== target.target.dev || + Option.getOrUndefined(info.ino) !== target.target.ino + ) + return yield* Effect.die(new Error("Path changed during read")) + const chunks: Uint8Array[] = [] + while (true) { + const chunk = yield* file.readAlloc(64 * 1024).pipe(Effect.orDie) + if (Option.isNone(chunk)) break + chunks.push(chunk.value) + } + return { + content: new Uint8Array(Buffer.concat(chunks.map((chunk) => Buffer.from(chunk)))), + mime: FSUtil.mimeType(target.real), + } + }), + ) + // kilocode_change end }), list: Effect.fn("FileSystem.list")(function* (input = {}) { const target = yield* resolve(input.path) - const info = yield* fs.stat(target.real).pipe(Effect.orDie) - if (info.type !== "Directory") return yield* Effect.die(new Error("Path is not a directory")) - return yield* fs.readDirectoryEntries(target.real).pipe( + if (target.target.type !== "directory") return yield* Effect.die(new Error("Path is not a directory")) // kilocode_change + // kilocode_change start - reject directory replacement during enumeration + yield* SearchTarget.validate(fs, target.target).pipe(Effect.orDie) + const entries = yield* fs.readDirectoryEntries(target.real).pipe( Effect.orDie, Effect.map((items) => items @@ -118,6 +144,9 @@ const baseLayer = Layer.effect( .sort((a, b) => (a.type === b.type ? a.path.localeCompare(b.path) : a.type === "directory" ? -1 : 1)), ), ) + yield* SearchTarget.validate(fs, target.target).pipe(Effect.orDie) + return entries + // kilocode_change end }), }) }), diff --git a/packages/core/src/filesystem/search.ts b/packages/core/src/filesystem/search.ts index 81094c440a..3ab89e1617 100644 --- a/packages/core/src/filesystem/search.ts +++ b/packages/core/src/filesystem/search.ts @@ -1,5 +1,6 @@ export * as FileSystemSearch from "./search" +import os from "os" // kilocode_change import path from "path" import { Context, Effect, Layer, Scope } from "effect" import { Fff } from "#fff" @@ -10,6 +11,7 @@ import { Location } from "../location" import { Ripgrep } from "../ripgrep" import { RelativePath } from "../schema" import { Flag } from "../flag/flag" +import * as SearchTarget from "../kilocode/search-target" // kilocode_change export interface Interface { readonly find: (input: FileSystem.FindInput) => Effect.Effect @@ -26,6 +28,18 @@ export const ripgrepLayer = Layer.effect( const location = yield* Location.Service const ripgrep = yield* Ripgrep.Service const scope = yield* Scope.Scope + // kilocode_change start - confine every search to the canonical active Location. + const inspect = Effect.fnUntraced(function* (input?: string) { + const root = yield* SearchTarget.inspect(fs, location.directory).pipe(Effect.orDie) + const requested = path.resolve(location.directory, input ?? ".") + if (!FSUtil.contains(location.directory, requested)) + return yield* Effect.die(new Error("Path escapes the location")) + const target = yield* SearchTarget.inspect(fs, requested).pipe(Effect.orDie) + if (root.type !== "directory" || !FSUtil.contains(root.path, target.path)) + return yield* Effect.die(new Error("Path escapes the location")) + return target + }) + // kilocode_change end const state = { files: [] as string[], directories: [] as string[], @@ -48,18 +62,19 @@ export const ripgrepLayer = Layer.effect( return Service.of({ glob: (input) => Effect.gen(function* () { - const target = path.resolve(location.directory, input.path ?? ".") - const info = yield* fs.stat(target).pipe(Effect.orDie) - const cwd = info.type === "File" ? path.dirname(target) : target + const target = yield* inspect(input.path) // kilocode_change + const cwd = target.type === "file" ? path.dirname(target.path) : target.path // kilocode_change return yield* ripgrep .glob({ cwd, pattern: input.pattern, limit: input.limit ?? Number.MAX_SAFE_INTEGER, + validate: SearchTarget.validate(fs, target), // kilocode_change }) .pipe( Effect.map((result) => - result.map( + result.items.map( + // kilocode_change (entry) => new FileSystem.Entry({ ...entry, @@ -72,20 +87,21 @@ export const ripgrepLayer = Layer.effect( }), grep: (input) => Effect.gen(function* () { - const target = path.resolve(location.directory, input.path ?? ".") - const info = yield* fs.stat(target).pipe(Effect.orDie) - const cwd = info.type === "File" ? path.dirname(target) : target + const target = yield* inspect(input.path) // kilocode_change + const cwd = target.type === "file" ? path.dirname(target.path) : target.path // kilocode_change return yield* ripgrep .grep({ cwd, pattern: input.pattern, - file: info.type === "File" ? path.basename(target) : undefined, + file: target.type === "file" ? path.basename(target.path) : undefined, // kilocode_change include: input.include, limit: input.limit ?? Number.MAX_SAFE_INTEGER, + validate: SearchTarget.validate(fs, target), // kilocode_change }) .pipe( Effect.map((result) => - result.map( + result.items.map( + // kilocode_change (match) => new FileSystem.Match({ ...match, @@ -101,6 +117,7 @@ export const ripgrepLayer = Layer.effect( }), find: (input) => Effect.gen(function* () { + // kilocode_change const items = input.type === "file" ? state.files @@ -127,13 +144,34 @@ export const fffLayer = Layer.effect( Service, Effect.gen(function* () { const location = yield* Location.Service + const fs = yield* FSUtil.Service // kilocode_change + // kilocode_change start - FFF is an index, not a security boundary; constrain its scan and filter canonical results. + const inspect = Effect.fnUntraced(function* (input?: string) { + const root = yield* SearchTarget.inspect(fs, location.directory).pipe(Effect.orDie) + const requested = path.resolve(location.directory, input ?? ".") + if (!FSUtil.contains(location.directory, requested)) + return yield* Effect.die(new Error("Path escapes the location")) + const target = yield* SearchTarget.inspect(fs, requested).pipe(Effect.orDie) + if (root.type !== "directory" || !FSUtil.contains(root.path, target.path)) + return yield* Effect.die(new Error("Path escapes the location")) + return { root, target } + }) + const safe = Effect.fnUntraced(function* (root: SearchTarget.Target, relative: string) { + const absolute = path.resolve(location.directory, relative) + if (!FSUtil.contains(location.directory, absolute)) return false + const real = yield* fs.realPath(absolute).pipe(Effect.catch(() => Effect.succeed(undefined))) + return real !== undefined && FSUtil.contains(root.path, real) + }) + // kilocode_change end const result = yield* Effect.try({ try: () => Fff.create({ basePath: location.directory, aiMode: true, - enableFsRootScanning: true, - enableHomeDirScanning: true, + // kilocode_change start - permit broad scanning only when the Location is that exact boundary. + enableFsRootScanning: location.directory === path.parse(location.directory).root, + enableHomeDirScanning: location.directory === os.homedir(), + // kilocode_change end }), catch: (cause) => cause, }).pipe(Effect.orDie) @@ -141,14 +179,21 @@ export const fffLayer = Layer.effect( yield* Effect.addFinalizer(() => Effect.sync(() => result.value.destroy()).pipe(Effect.ignore)) return Service.of({ glob: (input) => - Effect.sync(() => { + Effect.gen(function* () { + const { root, target } = yield* inspect(input.path) // kilocode_change const prefix = input.path?.replaceAll("\\", "/").replace(/\/$/, "") - const found = result.value.glob(prefix ? `${prefix}/${input.pattern}` : input.pattern, { - pageIndex: 0, - pageSize: input.limit, - }) + const found = yield* Effect.sync(() => + // kilocode_change + result.value.glob(prefix ? `${prefix}/${input.pattern}` : input.pattern, { + pageIndex: 0, + pageSize: input.limit, + }), + ) if (!found.ok) throw found.error - return found.value.items.map((item) => { + yield* SearchTarget.validate(fs, target).pipe(Effect.orDie) // kilocode_change + const items = yield* Effect.filter(found.value.items, (item) => safe(root, item.relativePath)) // kilocode_change + return items.map((item) => { + // kilocode_change const absolute = path.resolve(location.directory, item.relativePath) return new FileSystem.Entry({ path: RelativePath.make(item.relativePath.replaceAll("\\", "/")), @@ -158,16 +203,24 @@ export const fffLayer = Layer.effect( }) }), grep: (input) => - Effect.sync(() => { + Effect.gen(function* () { + // kilocode_change + const { root, target } = yield* inspect(input.path) // kilocode_change const prefix = input.path?.replaceAll("\\", "/").replace(/\/$/, "") - const found = result.value.grep( - [prefix ? `${prefix}/**` : undefined, input.include, input.pattern] - .filter((value) => value !== undefined) - .join(" "), - { mode: "regex", pageSize: input.limit, timeBudgetMs: 1_500 }, + const found = yield* Effect.sync(() => + // kilocode_change + result.value.grep( + [prefix ? `${prefix}/**` : undefined, input.include, input.pattern] + .filter((value) => value !== undefined) + .join(" "), + { mode: "regex", pageSize: input.limit, timeBudgetMs: 1_500 }, + ), ) if (!found.ok) throw found.error - return found.value.items.map((match) => { + yield* SearchTarget.validate(fs, target).pipe(Effect.orDie) // kilocode_change + const items = yield* Effect.filter(found.value.items, (item) => safe(root, item.relativePath)) // kilocode_change + return items.map((match) => { + // kilocode_change const bytes = Buffer.from(match.lineContent) return new FileSystem.Match({ entry: new FileSystem.Entry({ diff --git a/packages/core/src/kilocode/session-message.ts b/packages/core/src/kilocode/session-message.ts index cc1423aa76..48ebd40884 100644 --- a/packages/core/src/kilocode/session-message.ts +++ b/packages/core/src/kilocode/session-message.ts @@ -2,13 +2,19 @@ import { StoredToolContent } from "@opencode-ai/llm" import { Schema } from "effect" const decode = Schema.decodeUnknownSync(StoredToolContent) +const encodeContent = Schema.encodeUnknownSync(StoredToolContent) function record(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value) } export function normalize(value: unknown): unknown { - if (!record(value) || value.type !== "assistant" || !Array.isArray(value.content)) return value + if (!record(value)) return value + // kilocode_change - new readers recover the canonical summary while old readers receive recent context inline. + if (value.type === "compaction" && typeof value.kilo_summary === "string") { + return { ...value, summary: value.kilo_summary } + } + if (value.type !== "assistant" || !Array.isArray(value.content)) return value return { ...value, content: value.content.map((item) => { @@ -20,3 +26,27 @@ export function normalize(value: unknown): unknown { }), } } + +export function encode(value: unknown): unknown { + if (!record(value)) return value + // kilocode_change start - preserve current semantics while making released compaction rows self-contained. + if (value.type === "compaction" && typeof value.summary === "string" && typeof value.recent === "string") { + return { + ...value, + summary: [value.summary, value.recent ? `Recent context:\n${value.recent}` : ""].filter(Boolean).join("\n\n"), + kilo_summary: value.summary, + } + } + // kilocode_change end + if (value.type !== "assistant" || !Array.isArray(value.content)) return value + return { + ...value, + content: value.content.map((item) => { + if (!record(item) || item.type !== "tool" || !record(item.state)) return item + const status = item.state.status + if (status !== "running" && status !== "completed" && status !== "error") return item + if (!Array.isArray(item.state.content)) return item + return { ...item, state: { ...item.state, content: item.state.content.map((entry) => encodeContent(entry)) } } + }), + } +} diff --git a/packages/core/src/reference.ts b/packages/core/src/reference.ts index 66eb160eb4..a7890243c5 100644 --- a/packages/core/src/reference.ts +++ b/packages/core/src/reference.ts @@ -51,6 +51,7 @@ type Editor = { export interface Interface { readonly transform: State.Interface["transform"] + readonly replace: (sources: readonly (readonly [string, Source])[]) => Effect.Effect // kilocode_change readonly list: () => Effect.Effect } @@ -128,6 +129,15 @@ export const layer = Layer.effect( return Service.of({ transform: state.transform, + // kilocode_change start - reconcile Kilo's effective config without a request-scoped transform slot. + replace: (sources) => + state.mutate((editor) => + Effect.sync(() => { + for (const [name] of editor.list()) editor.remove(name) + for (const [name, source] of sources) editor.add(name, source) + }), + ), + // kilocode_change end list: Effect.fn("Reference.list")(function* () { return Array.from(materialized.values()) }), diff --git a/packages/core/src/ripgrep.ts b/packages/core/src/ripgrep.ts index f4973ca9df..d1a58aea22 100644 --- a/packages/core/src/ripgrep.ts +++ b/packages/core/src/ripgrep.ts @@ -82,10 +82,18 @@ export interface GrepInput { export interface Interface { readonly find: (input: FindInput) => Effect.Effect - readonly glob: (input: GlobInput) => Effect.Effect - readonly grep: (input: GrepInput) => Effect.Effect + readonly glob: (input: GlobInput) => Effect.Effect, Error> // kilocode_change + readonly grep: (input: GrepInput) => Effect.Effect, Error | InvalidPatternError> // kilocode_change } +// kilocode_change start - retain truncation state through model-facing tools +export interface SearchResult { + readonly items: readonly A[] + readonly truncated: boolean + readonly partial: boolean +} +// kilocode_change end + export class Service extends Context.Service()("@opencode/v2/Ripgrep") {} const failure = (message: string, cause?: unknown) => new Error({ message, cause }) @@ -170,6 +178,7 @@ export const layer = Layer.effect( cwd: input.cwd, limit: input.limit, signal: input.signal, + validate: input.validate, // kilocode_change - preserve spawn-bound target validation args: [ "--no-config", "--files", @@ -187,8 +196,10 @@ export const layer = Layer.effect( .replaceAll("\\", "/"), ), }).pipe( - Effect.map((result) => - result.items.map((relative) => { + // kilocode_change start - retain spawn metadata after mapping paths + Effect.map((result) => ({ + ...result, + items: result.items.map((relative) => { const absolute = path.resolve(input.cwd, relative) return new Entry({ path: RelativePath.make(relative), @@ -196,7 +207,8 @@ export const layer = Layer.effect( mime: FSUtil.mimeType(absolute), }) }), - ), + })), + // kilocode_change end Effect.catchTag("Ripgrep.InvalidPatternError", (cause) => Effect.fail(failure(cause.message, cause))), ), find: (input) => @@ -267,8 +279,10 @@ export const layer = Layer.effect( }), ), }).pipe( - Effect.map((result) => - result.items.map((match) => { + // kilocode_change start - retain spawn metadata after mapping matches + Effect.map((result) => ({ + ...result, + items: result.items.map((match) => { const relative = match.path.text .replace(/^(?:\.[\\/])+/u, "") .replace(/^[\\/]+/u, "") @@ -290,7 +304,8 @@ export const layer = Layer.effect( })), }) }), - ), + })), + // kilocode_change end ), }) }), diff --git a/packages/core/src/session.ts b/packages/core/src/session.ts index 7b26eeb7f0..e629108994 100644 --- a/packages/core/src/session.ts +++ b/packages/core/src/session.ts @@ -416,13 +416,14 @@ export const layer = Layer.effect( Effect.gen(function* () { const session = yield* store.get(sessionID) if (!session) return yield* execution.interrupt(sessionID) - const event = yield* events.publish(SessionEvent.InterruptRequested, { + // kilocode_change start - keep interrupt operational while preserving released durable event compatibility. + const seq = yield* SessionInput.latestSeq(db, sessionID) + yield* events.publish(SessionEvent.InterruptRequested, { sessionID, timestamp: yield* DateTime.now, }) - if (event.seq === undefined) - return yield* Effect.die("Interrupt request event is missing aggregate sequence") - yield* execution.interrupt(sessionID, event.seq) + yield* execution.interrupt(sessionID, seq) + // kilocode_change end }), ), ), diff --git a/packages/core/src/session/compaction.ts b/packages/core/src/session/compaction.ts index 5229949cb9..1c0cc62fea 100644 --- a/packages/core/src/session/compaction.ts +++ b/packages/core/src/session/compaction.ts @@ -224,6 +224,7 @@ export const make = (dependencies: Dependencies) => { reason: "auto", text: summary, recent: selected.recent, + include: selected.recent, // kilocode_change - released v1 readers recognize this compatibility field }) return true }) diff --git a/packages/core/src/session/event.ts b/packages/core/src/session/event.ts index aff7c09862..8797ef036b 100644 --- a/packages/core/src/session/event.ts +++ b/packages/core/src/session/event.ts @@ -120,9 +120,8 @@ export namespace PromptLifecycle { export const InterruptRequested = EventV2.define({ type: "session.next.interrupt.requested", - ...options, schema: Base, -}) +}) // kilocode_change - operational notification; released readers cannot decode a durable event with this type export type InterruptRequested = typeof InterruptRequested.Type export const ContextUpdated = EventV2.define({ @@ -453,28 +452,21 @@ export namespace Compaction { }) export type Delta = typeof Delta.Type - // Retain the unpublished v1 decoder so stored beta events remain replayable. - export const EndedV1 = EventV2.define({ - type: "session.next.compaction.ended", - ...options, - schema: { - ...Base, - text: Schema.String, - include: Schema.String.pipe(Schema.optional), - }, - }) - + // kilocode_change start - keep the released v1 event key while storing enough data for both reader generations. + const EndedFields = { + ...Base, + messageID: SessionMessageID.ID.pipe(Schema.optional), + reason: Started.data.fields.reason.pipe(Schema.optional), + text: Schema.String, + recent: Schema.String.pipe(Schema.optional), + include: Schema.String.pipe(Schema.optional), + } export const Ended = EventV2.define({ type: "session.next.compaction.ended", - sync: { aggregate: "sessionID", version: 2 }, - schema: { - ...Base, - messageID: SessionMessageID.ID, - reason: Started.data.fields.reason, - text: Schema.String, - recent: Schema.String, - }, + sync: { aggregate: "sessionID", version: 1 }, + schema: EndedFields, }) + // kilocode_change end export type Ended = typeof Ended.Type } @@ -485,7 +477,6 @@ const DurableDefinitions = [ Prompted, PromptLifecycle.Admitted, PromptLifecycle.Promoted, - InterruptRequested, ContextUpdated, Synthetic, Shell.Started, @@ -507,7 +498,13 @@ const DurableDefinitions = [ Compaction.Started, Compaction.Ended, ] as const -const EphemeralDefinitions = [Text.Delta, Tool.Input.Delta, Reasoning.Delta, Compaction.Delta] as const +const EphemeralDefinitions = [ + InterruptRequested, // kilocode_change - preserve downgrade-readable durable streams + Text.Delta, + Tool.Input.Delta, + Reasoning.Delta, + Compaction.Delta, +] as const export const Durable = Schema.Union(DurableDefinitions, { mode: "oneOf" }).pipe(Schema.toTaggedUnion("type")) export type DurableEvent = typeof Durable.Type diff --git a/packages/core/src/session/message-updater.ts b/packages/core/src/session/message-updater.ts index cf1eb2cedf..647fd607da 100644 --- a/packages/core/src/session/message-updater.ts +++ b/packages/core/src/session/message-updater.ts @@ -370,6 +370,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) { "session.next.compaction.started": () => Effect.void, "session.next.compaction.delta": () => Effect.void, "session.next.compaction.ended": (event) => { + if (event.data.messageID === undefined || event.data.reason === undefined) return Effect.void // kilocode_change return adapter.appendMessage( new SessionMessage.Compaction({ id: event.data.messageID, @@ -377,7 +378,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) { metadata: event.metadata, reason: event.data.reason, summary: event.data.text, - recent: event.data.recent, + recent: event.data.recent ?? "", // kilocode_change - current v1 writes include recent; released rows omit it time: { created: event.data.timestamp }, }), ) diff --git a/packages/core/src/session/projector.ts b/packages/core/src/session/projector.ts index a7b3cfe54f..6a0f820ef9 100644 --- a/packages/core/src/session/projector.ts +++ b/packages/core/src/session/projector.ts @@ -10,6 +10,7 @@ import { SessionV1 } from "../v1/session" import { WorkspaceTable } from "../control-plane/workspace.sql" import { SessionMessage } from "./message" import { SessionMessageUpdater } from "./message-updater" +import * as StoredMessage from "../kilocode/session-message" // kilocode_change import { SessionInput } from "./input" import { WorkspaceV2 } from "../workspace" import { SessionContextEpoch } from "./context-epoch" @@ -19,7 +20,8 @@ import type { DeepMutable } from "../schema" type DatabaseService = Database.Interface["db"] const decodeMessage = Schema.decodeUnknownSync(SessionMessage.Message) -const encodeMessage = Schema.encodeSync(SessionMessage.Message) +const encodeMessage = (message: SessionMessage.Message) => + StoredMessage.encode(Schema.encodeSync(SessionMessage.Message)(message)) as (typeof SessionMessage.Message)["Encoded"] // kilocode_change class PromptAlreadyProjected extends Error {} export class SessionAlreadyProjected extends Error {} @@ -113,7 +115,7 @@ function applyUsage( function run(db: DatabaseService, event: SessionEvent.Event) { return Effect.gen(function* () { const decodeRow = (row: typeof SessionMessageTable.$inferSelect) => - decodeMessage({ ...row.data, id: row.id, type: row.type }) + decodeMessage(StoredMessage.normalize({ ...row.data, id: row.id, type: row.type })) // kilocode_change const updateMessage = (message: SessionMessage.Message) => { if (event.seq === undefined) return Effect.die("Synchronized Session event is missing aggregate sequence") const encoded = encodeMessage(message) @@ -447,7 +449,7 @@ export const layer = Layer.effectDiscard( yield* events.project(SessionEvent.Reasoning.Ended, (event) => run(db, event)) // yield* events.project(SessionEvent.Retried, (event) => run(db, event)) yield* events.project(SessionEvent.Compaction.Ended, (event) => { - if (event.version === 1) return Effect.void + if (event.data.messageID === undefined || event.data.reason === undefined) return Effect.void // kilocode_change const seq = event.seq if (seq === undefined) return Effect.die("Synchronized Session event is missing aggregate sequence") return Effect.gen(function* () { diff --git a/packages/core/src/tool/glob.ts b/packages/core/src/tool/glob.ts index 523a686a4e..a882d246ad 100644 --- a/packages/core/src/tool/glob.ts +++ b/packages/core/src/tool/glob.ts @@ -7,6 +7,7 @@ import { FileSystem } from "../filesystem" import { FSUtil } from "../fs-util" // kilocode_change import * as SearchTarget from "../kilocode/search-target" // kilocode_change import { Location } from "../location" +import { Reference } from "../reference" // kilocode_change import { Ripgrep } from "../ripgrep" import { RelativePath } from "../schema" import { PermissionV2 } from "../permission" @@ -20,19 +21,31 @@ export const Input = Schema.Struct({ path: RelativePath.pipe(Schema.optional).annotate({ description: "Relative directory to search. Defaults to the active Location.", }), - limit: FileSystem.GlobInput.fields.limit.annotate({ + reference: Schema.NonEmptyString.pipe(Schema.optional).annotate({ + description: "Named project reference to search instead of the active Location", + }), // kilocode_change + limit: FileSystem.SearchLimit.pipe(Schema.optional).annotate({ description: "Maximum results to return", - }), + }), // kilocode_change }) -export const Output = Schema.Array(FileSystem.Entry) +// kilocode_change start - retain bounded-search status in tool results and model output +export class Result extends Schema.Class("GlobTool.Result")({ + items: Schema.Array(FileSystem.Entry), + truncated: Schema.Boolean, + partial: Schema.Boolean, +}) {} +export const Output = Result type ModelOutput = typeof Output.Encoded /** Format raw search results into the concise line-oriented output models expect. */ export const toModelOutput = (output: ModelOutput) => { - const lines = output.length === 0 ? ["No files found"] : output.map((item) => item.path) + const lines = output.items.length === 0 ? ["No files found"] : output.items.map((item) => item.path) + if (output.truncated) lines.push("", `(Results truncated: showing first ${output.items.length} files.)`) + if (output.partial) lines.push("", "(Some discovered files could not be read.)") return lines.join("\n") } +// kilocode_change end /** Glob leaf that defaults its filesystem root to the active Location. */ export const layer = Layer.effectDiscard( @@ -41,6 +54,7 @@ export const layer = Layer.effectDiscard( const fs = yield* FSUtil.Service // kilocode_change const ripgrep = yield* Ripgrep.Service const location = yield* Location.Service + const references = yield* Reference.Service // kilocode_change const permission = yield* PermissionV2.Service yield* tools @@ -53,9 +67,12 @@ export const layer = Layer.effectDiscard( toModelOutput: ({ output }) => [ { type: "text", - text: toModelOutput( - output.map((entry) => ({ ...entry, path: path.resolve(location.directory, entry.path) })), - ), + // kilocode_change start - model paths remain absolute while the typed result retains metadata + text: toModelOutput({ + ...output, + items: output.items.map((entry) => ({ ...entry, path: path.resolve(location.directory, entry.path) })), + }), + // kilocode_change end }, ], execute: (input, context) => @@ -67,6 +84,7 @@ export const layer = Layer.effectDiscard( metadata: { root: input.path ?? ".", path: input.path, + reference: input.reference, // kilocode_change limit: input.limit, }, sessionID: context.sessionID, @@ -74,10 +92,15 @@ export const layer = Layer.effectDiscard( source: { type: "tool", messageID: context.assistantMessageID, callID: context.toolCallID }, }) // kilocode_change start - enforce the active Location despite RelativePath being a nominal brand - const requested = path.resolve(location.directory, input.path ?? ".") - if (!FSUtil.contains(location.directory, requested)) + const ref = input.reference + ? (yield* references.list()).find((item) => item.name === input.reference) + : undefined + if (input.reference && !ref) return yield* Effect.fail(new Error("Project reference not found")) + const base = ref?.path ?? location.directory + const requested = path.resolve(base, input.path ?? ".") + if (!FSUtil.contains(base, requested)) return yield* Effect.fail(new Error("Path escapes the active Location")) - const root = yield* SearchTarget.inspect(fs, location.directory) + const root = yield* SearchTarget.inspect(fs, base) const target = yield* SearchTarget.inspect(fs, requested) if (root.type !== "directory" || target.type !== "directory" || !FSUtil.contains(root.path, target.path)) return yield* Effect.fail(new Error("Path escapes the active Location")) @@ -86,23 +109,29 @@ export const layer = Layer.effectDiscard( .glob({ cwd: target.path, // kilocode_change pattern: input.pattern, - limit: input.limit ?? 100, // kilocode_change - bound omitted limits + limit: input.limit ?? FileSystem.DEFAULT_SEARCH_LIMIT, // kilocode_change validate: SearchTarget.validate(fs, target), // kilocode_change - reject post-approval replacement }) .pipe( - Effect.map((result) => - result.map( - // kilocode_change start - report paths from the canonical validated target - (entry) => - new FileSystem.Entry({ - ...entry, - path: RelativePath.make( - path.relative(location.directory, path.resolve(target.path, entry.path)), - ), - }), - // kilocode_change end - ), + // kilocode_change start - preserve search status after canonical path mapping + Effect.map( + (result) => + new Result({ + ...result, + items: result.items.map( + // kilocode_change start - report paths from the canonical validated target + (entry) => + new FileSystem.Entry({ + ...entry, + path: RelativePath.make( + path.relative(location.directory, path.resolve(target.path, entry.path)), + ), + }), + // kilocode_change end + ), + }), ), + // kilocode_change end ) }).pipe( Effect.mapError(() => new ToolFailure({ message: `Unable to find files matching ${input.pattern}` })), diff --git a/packages/core/src/tool/grep.ts b/packages/core/src/tool/grep.ts index 08e1bc49dc..10a2d74ccc 100644 --- a/packages/core/src/tool/grep.ts +++ b/packages/core/src/tool/grep.ts @@ -8,6 +8,7 @@ import { FSUtil } from "../fs-util" import { Global } from "../global" // kilocode_change import * as SearchTarget from "../kilocode/search-target" // kilocode_change import { Location } from "../location" +import { Reference } from "../reference" // kilocode_change import { PermissionV2 } from "../permission" import { Ripgrep } from "../ripgrep" import { RelativePath } from "../schema" @@ -23,22 +24,31 @@ export const Input = Schema.Struct({ path: RelativePath.pipe(Schema.optional).annotate({ description: "Relative directory to search. Defaults to the active Location.", }), + reference: Schema.NonEmptyString.pipe(Schema.optional).annotate({ + description: "Named project reference to search instead of the active Location", + }), // kilocode_change include: FileSystem.GrepInput.fields.include.annotate({ description: 'File glob to include in the search (for example, "*.js" or "*.{ts,tsx}")', }), - limit: FileSystem.GrepInput.fields.limit.annotate({ + limit: FileSystem.SearchLimit.pipe(Schema.optional).annotate({ description: "Maximum matches to return", - }), + }), // kilocode_change }) -export const Output = Schema.Array(FileSystem.Match) +// kilocode_change start - retain bounded-search status in tool results and model output +export class Result extends Schema.Class("GrepTool.Result")({ + items: Schema.Array(FileSystem.Match), + truncated: Schema.Boolean, + partial: Schema.Boolean, +}) {} +export const Output = Result type ModelOutput = typeof Output.Encoded /** Format raw search matches into the familiar concise model output. */ export const toModelOutput = (output: ModelOutput) => { - const lines = output.length === 0 ? ["No files found"] : [`Found ${output.length} matches`] + const lines = output.items.length === 0 ? ["No files found"] : [`Found ${output.items.length} matches`] let current = "" - for (const match of output) { + for (const match of output.items) { if (current !== match.entry.path) { if (current) lines.push("") current = match.entry.path @@ -46,8 +56,11 @@ export const toModelOutput = (output: ModelOutput) => { } lines.push(` Line ${match.line}: ${match.text}`) } + if (output.truncated) lines.push("", `(Results truncated: showing first ${output.items.length} matches.)`) + if (output.partial) lines.push("", "(Some paths were inaccessible.)") return lines.join("\n") } +// kilocode_change end /** Grep leaf that defaults its filesystem root to the active Location. */ export const layer = Layer.effectDiscard( @@ -57,6 +70,7 @@ export const layer = Layer.effectDiscard( const global = yield* Global.Service // kilocode_change const ripgrep = yield* Ripgrep.Service const location = yield* Location.Service + const references = yield* Reference.Service // kilocode_change const permission = yield* PermissionV2.Service yield* tools @@ -69,12 +83,15 @@ export const layer = Layer.effectDiscard( toModelOutput: ({ output }) => [ { type: "text", - text: toModelOutput( - output.map((match) => ({ + // kilocode_change start - model paths remain absolute while the typed result retains metadata + text: toModelOutput({ + ...output, + items: output.items.map((match) => ({ ...match, entry: { ...match.entry, path: path.resolve(location.directory, match.entry.path) }, })), - ), + }), + // kilocode_change end }, ], execute: (input, context) => @@ -86,6 +103,7 @@ export const layer = Layer.effectDiscard( metadata: { root: ".", path: input.path, + reference: input.reference, // kilocode_change include: input.include, limit: input.limit, }, @@ -94,14 +112,19 @@ export const layer = Layer.effectDiscard( source: { type: "tool", messageID: context.assistantMessageID, callID: context.toolCallID }, }) // kilocode_change start - enforce the active Location despite RelativePath being a nominal brand - const requested = path.resolve(location.directory, input.path ?? ".") + const ref = input.reference + ? (yield* references.list()).find((item) => item.name === input.reference) + : undefined + if (input.reference && !ref) return yield* Effect.fail(new Error("Project reference not found")) + const base = ref?.path ?? location.directory + const requested = path.resolve(base, input.path ?? ".") const absolute = path.isAbsolute(input.path ?? "") - if (!FSUtil.contains(location.directory, requested) && !absolute) + if (!FSUtil.contains(base, requested) && !absolute) return yield* Effect.fail(new Error("Path escapes the active Location")) - const root = yield* SearchTarget.inspect(fs, location.directory) + const root = yield* SearchTarget.inspect(fs, base) const target = yield* SearchTarget.inspect(fs, requested) const contained = root.type === "directory" && FSUtil.contains(root.path, target.path) - const retained = absolute && !contained && (yield* SearchTarget.managed(fs, global.data, target)) + const retained = !ref && absolute && !contained && (yield* SearchTarget.managed(fs, global.data, target)) if (root.type !== "directory" || (!contained && !retained)) return yield* Effect.fail(new Error("Path escapes the active Location")) // kilocode_change end @@ -112,27 +135,33 @@ export const layer = Layer.effectDiscard( pattern: input.pattern, file: target.type === "file" ? path.basename(target.path) : undefined, // kilocode_change include: input.include, - limit: input.limit ?? Number.MAX_SAFE_INTEGER, + limit: input.limit ?? FileSystem.DEFAULT_SEARCH_LIMIT, // kilocode_change validate: SearchTarget.validate(fs, target), // kilocode_change - reject post-approval replacement }) .pipe( - Effect.map((result) => - result.map( - (match) => - new FileSystem.Match({ - ...match, - entry: new FileSystem.Entry({ - ...match.entry, - path: RelativePath.make( - path.relative( - location.directory, - path.resolve(cwd, match.entry.path), // kilocode_change - ), - ), - }), - }), - ), + // kilocode_change start - preserve search status after canonical path mapping + Effect.map( + (result) => + new Result({ + ...result, + items: result.items.map( + (match) => + new FileSystem.Match({ + ...match, + entry: new FileSystem.Entry({ + ...match.entry, + path: RelativePath.make( + path.relative( + location.directory, + path.resolve(cwd, match.entry.path), // kilocode_change + ), + ), + }), + }), + ), + }), ), + // kilocode_change end ) }).pipe(Effect.mapError(() => new ToolFailure({ message: `Unable to grep for ${input.pattern}` }))), }), diff --git a/packages/core/test/config/config.test.ts b/packages/core/test/config/config.test.ts index 8d7dbd53f5..956c196442 100644 --- a/packages/core/test/config/config.test.ts +++ b/packages/core/test/config/config.test.ts @@ -162,6 +162,43 @@ describe("Config", () => { ), ) + it.live("skips project configuration when project discovery is disabled", () => + Effect.acquireRelease( + Effect.promise(() => tmpdir()), + (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), + ).pipe( + Effect.flatMap((tmp) => + Effect.gen(function* () { + const project = path.join(tmp.path, "project") + const global = path.join(tmp.path, "global") + yield* Effect.promise(async () => { + await Promise.all([fs.mkdir(project, { recursive: true }), fs.mkdir(global, { recursive: true })]) + await Promise.all([ + fs.writeFile(path.join(project, "kilo.json"), JSON.stringify({ model: "project/model" })), + fs.writeFile(path.join(global, "kilo.json"), JSON.stringify({ model: "global/model" })), + ]) + }) + + const prior = process.env.KILO_DISABLE_PROJECT_CONFIG + process.env.KILO_DISABLE_PROJECT_CONFIG = "1" + yield* Effect.addFinalizer(() => + Effect.sync(() => { + if (prior === undefined) delete process.env.KILO_DISABLE_PROJECT_CONFIG + else process.env.KILO_DISABLE_PROJECT_CONFIG = prior + }), + ) + + return yield* Effect.gen(function* () { + const config = yield* Config.Service + const documents = (yield* config.entries()).filter((entry) => entry.type === "document") + + expect(documents.map((document) => document.info.model)).toEqual(["global/model"]) + }).pipe(Effect.provide(testLayer(project, global, project))) + }), + ), + ), + ) + it.live("loads JSON and JSONC files from lowest to highest priority", () => Effect.acquireRelease( Effect.promise(() => tmpdir()), diff --git a/packages/core/test/connector.test.ts b/packages/core/test/connector.test.ts index 8896eae0b4..c9c35266f8 100644 --- a/packages/core/test/connector.test.ts +++ b/packages/core/test/connector.test.ts @@ -504,6 +504,53 @@ describe("Connector", () => { }), ) + // kilocode_change start - cancellation must not delete a completing OAuth attempt + it.effect("keeps a code OAuth attempt while its callback is completing", () => { + const created: Array<{ + connectorID: Connector.ID + methodID: Connector.MethodID + label?: string + value: Credential.Value + }> = [] + return Effect.gen(function* () { + const started = yield* Deferred.make() + const release = yield* Deferred.make() + const connectors = yield* Connector.Service + const connectorID = Connector.ID.make("openai") + const methodID = Connector.MethodID.make("chatgpt") + yield* connectors.update((editor) => + editor.method.update({ + connectorID, + method: new Connector.OAuthMethod({ id: methodID, type: "oauth", label: "ChatGPT" }), + authorize: () => + Effect.succeed({ + mode: "code" as const, + url: "https://example.com/authorize", + instructions: "Paste the code", + callback: () => + Deferred.succeed(started, undefined).pipe( + Effect.andThen(Deferred.await(release)), + Effect.as(new Credential.OAuth({ type: "oauth", access: "access", refresh: "refresh", expires: 1 })), + ), + }), + }), + ) + + const attempt = yield* connectors.connect.oauth.begin({ connectorID, methodID, inputs: {} }) + const fiber = yield* connectors.connect.oauth + .complete({ attemptID: attempt.attemptID, code: "1234" }) + .pipe(Effect.forkScoped) + yield* Deferred.await(started) + yield* connectors.connect.oauth.cancel(attempt.attemptID) + yield* Deferred.succeed(release, undefined) + yield* Fiber.join(fiber) + + expect(created).toHaveLength(1) + expect(yield* connectors.connect.oauth.status(attempt.attemptID)).toMatchObject({ status: "complete" }) + }).pipe(Effect.provide(connectionLayer(created))) + }) + // kilocode_change end + it.effect("fails and releases OAuth attempts when credential persistence times out", () => Effect.gen(function* () { const started = yield* Deferred.make() diff --git a/packages/core/test/credential.test.ts b/packages/core/test/credential.test.ts index b63a19c070..5f049350b6 100644 --- a/packages/core/test/credential.test.ts +++ b/packages/core/test/credential.test.ts @@ -97,7 +97,8 @@ describe("Credential", () => { ) // kilocode_change end - it.live("imports supported legacy auth.json credentials once", () => + // kilocode_change - released auth.json remains authoritative when reconciling on startup + it.live("reconciles supported legacy auth.json credentials on startup", () => Effect.acquireRelease( Effect.promise(() => tmpdir()), (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), @@ -160,16 +161,111 @@ describe("Credential", () => { }), ) + // kilocode_change start - update the selected row when a released client changes auth.json. + const selected = yield* Effect.gen(function* () { + return yield* (yield* Credential.Service).create({ + connectorID: Connector.ID.make("azure"), + methodID: Connector.MethodID.make("api-key"), + label: "Selected", + value: new Credential.Key({ type: "key", key: "selected" }), + }) + }).pipe(Effect.provide(credentials), Effect.scoped) + + yield* Effect.promise(() => + Bun.write( + path.join(tmp.path, "auth.json"), + JSON.stringify({ azure: { type: "api", key: "updated", metadata: { resourceName: "resource" } } }), + ), + ) yield* importer.pipe(Layer.build, Effect.scoped) const after = yield* Effect.gen(function* () { - return yield* (yield* Credential.Service).all() + const service = yield* Credential.Service + return { + all: yield* service.all(), + active: yield* service.active(Connector.ID.make("azure")), + } }).pipe(Effect.provide(credentials), Effect.scoped) - expect(after).toHaveLength(2) + expect(after.all).toHaveLength(3) + expect(after.active).toMatchObject({ + id: selected.id, + value: { type: "key", key: "updated" }, + }) + expect( + after.all.find((item) => item.connectorID === Connector.ID.make("azure") && item.id !== selected.id)?.value, + ).toMatchObject({ type: "key", key: "key" }) + // kilocode_change end }), ), ), ) + // kilocode_change start - retain downgrade-readable credential state + it.live("dual-writes active credentials for released auth.json readers", () => + Effect.acquireRelease( + Effect.promise(() => tmpdir()), + (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), + ).pipe( + Effect.flatMap((tmp) => { + const database = Database.layerFromPath(path.join(tmp.path, "credential.db")).pipe(Layer.fresh) + const global = Global.layerWith({ data: tmp.path }) + const credentials = Credential.layer.pipe( + Layer.provide(database), + Layer.provide(EventV2.defaultLayer), + Layer.provide(FSUtil.defaultLayer), + Layer.provide(global), + ) + return Effect.gen(function* () { + const service = yield* Credential.Service + const connectorID = Connector.ID.make("legacy-reader") + const created = yield* service.create({ + connectorID, + methodID: Connector.MethodID.make("api-key"), + value: new Credential.Key({ type: "key", key: "first" }), + }) + expect(yield* Effect.promise(() => Bun.file(path.join(tmp.path, "auth.json")).json())).toMatchObject({ + "legacy-reader": { type: "api", key: "first" }, + }) + + yield* service.update(created.id, { value: new Credential.Key({ type: "key", key: "second" }) }) + expect(yield* Effect.promise(() => Bun.file(path.join(tmp.path, "auth.json")).json())).toMatchObject({ + "legacy-reader": { type: "api", key: "second" }, + }) + + yield* service.remove(created.id) + expect(yield* Effect.promise(() => Bun.file(path.join(tmp.path, "auth.json")).json())).not.toHaveProperty( + "legacy-reader", + ) + + const file = path.join(tmp.path, "auth.json") + yield* Effect.promise(() => Bun.write(file, "{")) + yield* service.create({ + connectorID: Connector.ID.make("malformed-reader"), + methodID: Connector.MethodID.make("api-key"), + value: new Credential.Key({ type: "key", key: "safe" }), + }) + expect(yield* Effect.promise(() => Bun.file(file).text())).toBe("{") + + yield* Effect.promise(() => Bun.write(file, "{}")) + yield* Effect.all( + ["first-reader", "second-reader"].map((name) => + service.create({ + connectorID: Connector.ID.make(name), + methodID: Connector.MethodID.make("api-key"), + value: new Credential.Key({ type: "key", key: name }), + }), + ), + { concurrency: "unbounded" }, + ) + expect(yield* Effect.promise(() => Bun.file(file).json())).toMatchObject({ + "first-reader": { type: "api", key: "first-reader" }, + "second-reader": { type: "api", key: "second-reader" }, + }) + }).pipe(Effect.provide(credentials), Effect.scoped) + }), + ), + ) + // kilocode_change end + it.live("emits credential lifecycle events", () => Effect.acquireRelease( Effect.promise(() => tmpdir()), diff --git a/packages/core/test/filesystem/search.test.ts b/packages/core/test/filesystem/search.test.ts index 77d0a9e33c..fd77f41785 100644 --- a/packages/core/test/filesystem/search.test.ts +++ b/packages/core/test/filesystem/search.test.ts @@ -22,7 +22,8 @@ describe("Ripgrep", () => { yield* Effect.promise(() => fs.mkdir(path.join(cwd, "src"))) yield* Effect.promise(() => fs.writeFile(path.join(cwd, "src", "match.ts"), "needle\n")) const result = yield* (yield* Ripgrep.Service).glob({ cwd, pattern: "**/*.ts", limit: 10 }) - expect(result.map((item) => item.path)).toEqual([RelativePath.make("src/match.ts")]) + expect(result.items.map((item) => item.path)).toEqual([RelativePath.make("src/match.ts")]) // kilocode_change + expect(result.truncated).toBe(false) // kilocode_change }), ), ) @@ -34,9 +35,9 @@ describe("Ripgrep", () => { yield* Effect.promise(() => fs.writeFile(path.join(cwd, "src", "match.ts"), "needle\n")) yield* Effect.promise(() => fs.writeFile(path.join(cwd, "src", "skip.txt"), "needle\n")) const result = yield* (yield* Ripgrep.Service).grep({ cwd, pattern: "needle", include: "*.ts", limit: 10 }) - expect(result).toHaveLength(1) - expect(result[0]?.entry.path).toBe(RelativePath.make("src/match.ts")) - expect(result[0]?.submatches[0]?.text).toBe("needle") + expect(result.items).toHaveLength(1) // kilocode_change + expect(result.items[0]?.entry.path).toBe(RelativePath.make("src/match.ts")) // kilocode_change + expect(result.items[0]?.submatches[0]?.text).toBe("needle") // kilocode_change }), ), ) diff --git a/packages/core/test/kilocode/event-storage-compat.test.ts b/packages/core/test/kilocode/event-storage-compat.test.ts index 6b5682daeb..07a448a388 100644 --- a/packages/core/test/kilocode/event-storage-compat.test.ts +++ b/packages/core/test/kilocode/event-storage-compat.test.ts @@ -1,10 +1,13 @@ import { expect } from "bun:test" -import { Effect, Layer, Stream } from "effect" +import { DateTime, Effect, Layer, Schema, Stream } from "effect" import { Database } from "@opencode-ai/core/database/database" import { EventV2 } from "@opencode-ai/core/event" import { SessionEvent } from "@opencode-ai/core/session/event" import { SessionV2 } from "@opencode-ai/core/session" import { testEffect } from "../lib/effect" +import { EventTable } from "@opencode-ai/core/event/sql" +import { SessionMessage } from "@opencode-ai/core/session/message" +import * as StoredMessage from "@opencode-ai/core/kilocode/session-message" const database = Database.layerFromPath(":memory:") const events = EventV2.layer.pipe(Layer.provide(database)) @@ -46,3 +49,66 @@ it.effect("decodes legacy durable tool content without exposing it to consumers" }) }), ) + +it.effect("writes released durable tool and compaction shapes", () => + Effect.gen(function* () { + const events = yield* EventV2.Service + const { db } = yield* Database.Service + const sessionID = SessionV2.ID.make("ses_current_writer_compat") + yield* events.publish(SessionEvent.Tool.Success, { + timestamp: DateTime.makeUnsafe(1), + sessionID, + assistantMessageID: SessionMessage.ID.make("msg_assistant"), + callID: "call_read", + structured: {}, + content: [{ type: "file", uri: "data:image/png;base64,AAAA", mime: "image/png", name: "image.png" }], + provider: { executed: true }, + }) + yield* events.publish(SessionEvent.Compaction.Ended, { + timestamp: DateTime.makeUnsafe(2), + sessionID, + messageID: SessionMessage.ID.make("msg_compaction"), + reason: "auto", + text: "summary", + recent: "recent", + include: "recent", + }) + + const rows = yield* db.select().from(EventTable).all().pipe(Effect.orDie) + expect(rows[0]).toMatchObject({ + type: EventV2.versionedType(SessionEvent.Tool.Success.type, 1), + data: { + content: [ + { + type: "file", + source: { type: "data", data: "AAAA" }, + mime: "image/png", + name: "image.png", + }, + ], + }, + }) + expect(rows[1]).toMatchObject({ + type: EventV2.versionedType(SessionEvent.Compaction.Ended.type, 1), + data: { text: "summary", include: "recent" }, + }) + }), +) + +it.effect("stores self-contained compaction projections for released readers", () => + Effect.sync(() => { + const encoded = StoredMessage.encode({ + id: "msg_compaction", + type: "compaction", + reason: "auto", + summary: "summary", + recent: "recent", + time: { created: 1 }, + }) + const released = Schema.decodeUnknownSync( + Schema.Struct({ type: Schema.Literal("compaction"), summary: Schema.String }), + )(encoded) + expect(released.summary).toBe("summary\n\nRecent context:\nrecent") + expect(StoredMessage.normalize(encoded)).toMatchObject({ summary: "summary", recent: "recent" }) + }), +) diff --git a/packages/core/test/kilocode/grep-tool.test.ts b/packages/core/test/kilocode/grep-tool.test.ts index d0ff7c0c5c..0f73a8a4f8 100644 --- a/packages/core/test/kilocode/grep-tool.test.ts +++ b/packages/core/test/kilocode/grep-tool.test.ts @@ -7,6 +7,7 @@ import { FSUtil } from "@opencode-ai/core/fs-util" import { Global } from "@opencode-ai/core/global" import { Location } from "@opencode-ai/core/location" import { PermissionV2 } from "@opencode-ai/core/permission" +import { Reference } from "@opencode-ai/core/reference" import { Ripgrep } from "@opencode-ai/core/ripgrep" import { AbsolutePath } from "@opencode-ai/core/schema" import { SessionV2 } from "@opencode-ai/core/session" @@ -17,17 +18,28 @@ import { location } from "../fixture/location" import { tmpdir } from "../fixture/tmpdir" import { executeTool, toolIdentity } from "../lib/tool" -const permission = Layer.succeed( - PermissionV2.Service, - PermissionV2.Service.of({ - assert: () => Effect.void, - ask: () => Effect.die("unused"), - reply: () => Effect.die("unused"), - get: () => Effect.die("unused"), - forSession: () => Effect.die("unused"), - list: () => Effect.die("unused"), - }), -) +const permission = (requests: PermissionV2.AssertInput[] = []) => + Layer.succeed( + PermissionV2.Service, + PermissionV2.Service.of({ + assert: (input) => Effect.sync(() => requests.push(input)).pipe(Effect.asVoid), + ask: () => Effect.die("unused"), + reply: () => Effect.die("unused"), + get: () => Effect.die("unused"), + forSession: () => Effect.die("unused"), + list: () => Effect.die("unused"), + }), + ) + +const references = (items: Reference.Info[] = []) => + Layer.succeed( + Reference.Service, + Reference.Service.of({ + transform: () => Effect.die("unused"), + replace: () => Effect.die("unused"), // kilocode_change + list: () => Effect.succeed(items), + }), + ) describe("GrepTool managed output", () => { test("searches an absolute retained output file", async () => { @@ -44,7 +56,8 @@ describe("GrepTool managed output", () => { FSUtil.defaultLayer, Global.layerWith({ data }), Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make(worktree) }))), - permission, + permission(), + references(), Ripgrep.defaultLayer, ) const store = ToolOutputStore.layer.pipe(Layer.provide(base)) @@ -80,7 +93,8 @@ describe("GrepTool managed output", () => { FSUtil.defaultLayer, Global.layerWith({ data }), Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make(worktree) }))), - permission, + permission(), + references(), Ripgrep.defaultLayer, ) const store = ToolOutputStore.layer.pipe(Layer.provide(base)) @@ -105,4 +119,59 @@ describe("GrepTool managed output", () => { expect(result.value).toContain("needle") expect(result.value).toContain(output) }) + + test("confines named references and records permission metadata", async () => { + await using tmp = await tmpdir() + const worktree = path.join(tmp.path, "worktree") + const data = path.join(tmp.path, "data") + const docs = path.join(tmp.path, "docs") + const output = path.join(data, ToolOutputStore.MANAGED_DIRECTORY, "tool_reference") + await fs.mkdir(worktree) + await fs.mkdir(docs) + await fs.mkdir(path.dirname(output), { recursive: true }) + await fs.writeFile(path.join(docs, "guide.md"), "reference needle") + await fs.writeFile(output, "retained needle") + const requests: PermissionV2.AssertInput[] = [] + const source = new Reference.LocalSource({ type: "local", path: AbsolutePath.make(docs) }) + const base = Layer.mergeAll( + ApplicationTools.layer, + FSUtil.defaultLayer, + Global.layerWith({ data }), + Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make(worktree) }))), + permission(requests), + references([new Reference.Info({ name: "docs", path: source.path, source })]), + Ripgrep.defaultLayer, + ) + const store = ToolOutputStore.layer.pipe(Layer.provide(base)) + const registry = ToolRegistry.layer.pipe(Layer.provide(base), Layer.provide(store)) + const grep = GrepTool.layer.pipe(Layer.provide(base), Layer.provide(registry)) + const layer = Layer.mergeAll(base, store, registry, grep) + const run = (id: string, input: Record) => + Effect.gen(function* () { + const tools = yield* ToolRegistry.Service + return yield* executeTool(tools, { + sessionID: SessionV2.ID.make("ses_grep_reference_test"), + ...toolIdentity, + call: { type: "tool-call", id, name: "grep", input }, + }) + }).pipe(Effect.provide(layer), Effect.scoped, Effect.runPromise) + + const result = await run("call-grep-reference", { pattern: "needle", reference: "docs" }) + expect(result.type).toBe("text") + if (result.type === "text") { + expect(result.value).toContain("reference needle") + expect(result.value).toContain(path.join(docs, "guide.md")) + } + expect(requests[0]?.metadata).toMatchObject({ reference: "docs" }) + + const missing = await run("call-grep-reference-missing", { pattern: "needle", reference: "missing" }) + expect(missing.type).toBe("error") + + const escaped = await run("call-grep-reference-escape", { + pattern: "needle", + path: output, + reference: "docs", + }) + expect(escaped.type).toBe("error") + }) }) diff --git a/packages/core/test/location-filesystem.test.ts b/packages/core/test/location-filesystem.test.ts index a3ac24a905..bb022f8518 100644 --- a/packages/core/test/location-filesystem.test.ts +++ b/packages/core/test/location-filesystem.test.ts @@ -70,4 +70,27 @@ describe("FileSystem", () => { }).pipe(provide(directory)), ), ) + + // kilocode_change start - canonical containment must reject in-worktree links to outside paths + it.live("rejects symlink escapes for reads, lists, and searches", () => + withTmp((directory) => + withTmp((outside) => + Effect.gen(function* () { + yield* Effect.promise(async () => { + await fs.writeFile(path.join(outside, "secret.txt"), "secret") + await fs.symlink(outside, path.join(directory, "escape"), process.platform === "win32" ? "junction" : "dir") + }) + const service = yield* FileSystem.Service + const exits = yield* Effect.all([ + service.read({ path: RelativePath.make("escape/secret.txt") }).pipe(Effect.exit), + service.list({ path: RelativePath.make("escape") }).pipe(Effect.exit), + service.glob({ pattern: "**/*", path: RelativePath.make("escape") }).pipe(Effect.exit), + service.grep({ pattern: "secret", path: RelativePath.make("escape") }).pipe(Effect.exit), + ]) + expect(exits.every((exit) => exit._tag === "Failure")).toBe(true) + }).pipe(provide(directory)), + ), + ), + ) + // kilocode_change end }) diff --git a/packages/core/test/reference.test.ts b/packages/core/test/reference.test.ts index dfa8a202a6..a3c58ae2cb 100644 --- a/packages/core/test/reference.test.ts +++ b/packages/core/test/reference.test.ts @@ -11,6 +11,11 @@ import { it } from "./lib/effect" const cache = Layer.mock(RepositoryCache.Service, { ensure: () => Effect.die("unexpected Git materialization"), }) +// kilocode_change - keep reference state tests independent from the persistent event store. +const events = Layer.mock(EventV2.Service)({ + publish: (definition, data) => + Effect.succeed({ id: EventV2.ID.make("evt_reference_test"), type: definition.type, data }), +}) describe("Reference", () => { it.effect("registers normalized sources for the owning scope", () => @@ -36,7 +41,7 @@ describe("Reference", () => { }).pipe( Effect.provide(Reference.layer), Effect.provide(cache), - Effect.provide(EventV2.defaultLayer), + Effect.provide(events), Effect.provide(Global.defaultLayer), ), ) @@ -60,7 +65,7 @@ describe("Reference", () => { Effect.scoped, Effect.provide(Reference.layer), Effect.provide(cache), - Effect.provide(EventV2.defaultLayer), + Effect.provide(events), Effect.provide(Global.defaultLayer), ), ) @@ -89,7 +94,30 @@ describe("Reference", () => { Effect.scoped, Effect.provide(Reference.layer), Effect.provide(cache), - Effect.provide(EventV2.defaultLayer), + Effect.provide(events), + Effect.provide(Global.defaultLayer), + ), + ) + + // kilocode_change - Kilo config reconciliation must clear stale sources without owning a scoped transform. + it.effect("replaces sources without a scoped transform", () => + Effect.gen(function* () { + const references = yield* Reference.Service + const update = yield* references.transform() + const stale = new Reference.LocalSource({ type: "local", path: AbsolutePath.make("/stale") }) + const current = new Reference.LocalSource({ type: "local", path: AbsolutePath.make("/current") }) + yield* update((editor) => editor.add("stale", stale)) + + yield* references.replace([["current", current]]) + + expect(yield* references.list()).toEqual([ + new Reference.Info({ name: "current", path: AbsolutePath.make("/current"), source: current }), + ]) + }).pipe( + Effect.scoped, + Effect.provide(Reference.layer), + Effect.provide(cache), + Effect.provide(events), Effect.provide(Global.defaultLayer), ), ) diff --git a/packages/core/test/ripgrep.test.ts b/packages/core/test/ripgrep.test.ts index d7efe2a2e3..677ecc2dd6 100644 --- a/packages/core/test/ripgrep.test.ts +++ b/packages/core/test/ripgrep.test.ts @@ -55,8 +55,8 @@ describe("Ripgrep", () => { expect(observed).toEqual(limited.map((item) => item.path)) const matches = yield* ripgrep.grep({ cwd: tmp.path, pattern: "needle", include: "config", limit: 10 }) - expect(matches.map((item) => item.entry.path)).toContain(RelativePath.make(".opencode/config")) - expect(matches.map((item) => item.entry.path)).not.toContain(RelativePath.make(".git/config")) + expect(matches.items.map((item) => item.entry.path)).toContain(RelativePath.make(".opencode/config")) // kilocode_change + expect(matches.items.map((item) => item.entry.path)).not.toContain(RelativePath.make(".git/config")) // kilocode_change }), (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), ), diff --git a/packages/core/test/session-projector.test.ts b/packages/core/test/session-projector.test.ts index f84f60f308..d9b03eb0c1 100644 --- a/packages/core/test/session-projector.test.ts +++ b/packages/core/test/session-projector.test.ts @@ -19,6 +19,7 @@ import { SessionExecution } from "@opencode-ai/core/session/execution" import { SessionInput } from "@opencode-ai/core/session/input" import { SessionStore } from "@opencode-ai/core/session/store" import { SessionInputTable, SessionMessageTable, SessionTable } from "@opencode-ai/core/session/sql" +import * as StoredMessage from "@opencode-ai/core/kilocode/session-message" // kilocode_change import { testEffect } from "./lib/effect" const database = Database.layerFromPath(":memory:") @@ -265,7 +266,9 @@ describe("SessionProjector", () => { .all() .pipe(Effect.orDie) const messages = rows.map((row) => - Schema.decodeUnknownSync(SessionMessage.Message)({ ...row.data, id: row.id, type: row.type }), + Schema.decodeUnknownSync(SessionMessage.Message)( + StoredMessage.normalize({ ...row.data, id: row.id, type: row.type }), // kilocode_change + ), ) expect(messages.map((message) => message.type)).toEqual([ diff --git a/packages/core/test/session-prompt.test.ts b/packages/core/test/session-prompt.test.ts index d663cb715b..52969dd90b 100644 --- a/packages/core/test/session-prompt.test.ts +++ b/packages/core/test/session-prompt.test.ts @@ -104,15 +104,7 @@ const eventCount = (type: string) => ), ) -const interruptEvent = Database.Service.use(({ db }) => - db - .select() - .from(EventTable) - .where(eq(EventTable.type, "session.next.interrupt.requested.1")) - .get() - .pipe(Effect.orDie), -) - +// kilocode_change - no durable interrupt lookup: released database readers cannot decode that event type. describe("SessionV2.prompt", () => { it.effect("delegates execution continuation through SessionExecution", () => Effect.gen(function* () { @@ -137,8 +129,8 @@ describe("SessionV2.prompt", () => { expect(interruptCalls).toEqual([sessionID]) expect(interruptSeqs).toHaveLength(1) expect(typeof interruptSeqs[0]).toBe("number") - expect(yield* eventCount("session.next.interrupt.requested.1")).toBe(1) - expect(yield* interruptEvent).toMatchObject({ aggregate_id: sessionID, seq: interruptSeqs[0] }) + expect(interruptSeqs[0]).toBe(-1) // kilocode_change - ephemeral interrupts do not advance durable storage + expect(yield* eventCount("session.next.interrupt.requested.1")).toBe(0) // kilocode_change expect(yield* session.messages({ sessionID })).toEqual([]) }), ) diff --git a/packages/http-recorder/README.md b/packages/http-recorder/README.md index f388e8e253..c19f3b0886 100644 --- a/packages/http-recorder/README.md +++ b/packages/http-recorder/README.md @@ -4,16 +4,9 @@ Record real Effect HTTP and WebSocket traffic once, then replay it from determin Use it for provider integrations, retries, polling, multi-step flows, and any test where hand-written HTTP mocks hide too much of the real request shape. -> Public beta. The API depends on Effect 4 beta and may change with Effect's unstable transport modules. +> Private workspace package. Its API depends on Effect 4 beta and may change with Effect's unstable transport modules. -## Install - -```sh -bun add effect@4.0.0-beta.74 -bun add -d @opencode-ai/http-recorder@beta @effect/vitest vitest -``` - -The package supports Node.js 22+ and Bun. It is not intended for browsers, workers, or Deno. +The package is available only inside this monorepo. It supports Node.js 22+ and Bun and is not intended for browsers, workers, or Deno. Effect `4.0.0-beta.74` has a known declaration error (`SchemaErrorTypeId` is missing). Until that upstream declaration is fixed, TypeScript consumers need: diff --git a/packages/kilo-console/package.json b/packages/kilo-console/package.json index d3d88f83bc..2d0e8c0249 100755 --- a/packages/kilo-console/package.json +++ b/packages/kilo-console/package.json @@ -8,6 +8,7 @@ "build": "vite build", "preview": "vite preview --host 127.0.0.1 --port 3018", "test": "bun test src", + "test:ci": "mkdir -p .artifacts/unit && bun test src --reporter=junit --reporter-outfile=.artifacts/unit/junit.xml", "typecheck": "tsgo --noEmit" }, "dependencies": { diff --git a/packages/kilo-docs/package.json b/packages/kilo-docs/package.json index 5df98e8eb7..085d3e36b4 100644 --- a/packages/kilo-docs/package.json +++ b/packages/kilo-docs/package.json @@ -8,6 +8,7 @@ "lint": "bun run --cwd ../.. lint packages/kilo-docs", "start": "next start", "test": "vitest run", + "test:ci": "mkdir -p .artifacts/unit && vitest run --reporter=junit --outputFile=.artifacts/unit/junit.xml", "typecheck": "tsc --noEmit" }, "dependencies": { diff --git a/packages/kilo-indexing/package.json b/packages/kilo-indexing/package.json index 5fca32b440..ead36a3bfd 100644 --- a/packages/kilo-indexing/package.json +++ b/packages/kilo-indexing/package.json @@ -29,7 +29,8 @@ "scripts": { "typecheck": "tsgo --noEmit", "build": "tsc", - "test": "bun test --timeout 30000" + "test": "bun test --timeout 30000", + "test:ci": "mkdir -p .artifacts/unit && bun test --timeout 30000 --reporter=junit --reporter-outfile=.artifacts/unit/junit.xml" }, "dependencies": { "@aws-sdk/client-bedrock-runtime": "3.1005.0", diff --git a/packages/kilo-indexing/src/indexing/vector-store/qdrant-client.ts b/packages/kilo-indexing/src/indexing/vector-store/qdrant-client.ts index 53a4df9156..ca3ee84641 100644 --- a/packages/kilo-indexing/src/indexing/vector-store/qdrant-client.ts +++ b/packages/kilo-indexing/src/indexing/vector-store/qdrant-client.ts @@ -442,7 +442,7 @@ export class QdrantVectorStore implements IVectorStore { try { const processedPoints = points.map((point) => { if (point.payload?.filePath) { - const segments = point.payload.filePath.split(path.sep).filter(Boolean) + const segments = point.payload.filePath.split(/[\\/]+/).filter(Boolean) const pathSegments = segments.reduce((acc: Record, segment: string, index: number) => { acc[index.toString()] = segment return acc @@ -594,7 +594,7 @@ export class QdrantVectorStore implements IVectorStore { const normalizedRelativePath = path.normalize(relativePath) // Split the path into segments like we do in upsertPoints - const segments = normalizedRelativePath.split(path.sep).filter(Boolean) + const segments = normalizedRelativePath.split(/[\\/]+/).filter(Boolean) // Create a filter that matches all segments of the path // This ensures we only delete points that match the exact file path diff --git a/packages/kilo-indexing/test/kilocode/indexing/detect.test.ts b/packages/kilo-indexing/test/kilocode/indexing/detect.test.ts index fed079a513..fe62f29739 100644 --- a/packages/kilo-indexing/test/kilocode/indexing/detect.test.ts +++ b/packages/kilo-indexing/test/kilocode/indexing/detect.test.ts @@ -1,13 +1,14 @@ import { describe, expect, test } from "bun:test" import { mkdtemp } from "node:fs/promises" import { tmpdir } from "node:os" +import { fileURLToPath } from "node:url" import { hasIndexingPlugin, isIndexingPlugin, normalizePluginName } from "../../../src/detect" describe("indexing plugin detection", () => { test("bundles detect module for browser targets", async () => { const dir = await mkdtemp(`${tmpdir()}/kilo-indexing-detect-`) const result = await Bun.build({ - entrypoints: [new URL("../../../src/detect.ts", import.meta.url).pathname], + entrypoints: [fileURLToPath(new URL("../../../src/detect.ts", import.meta.url))], minify: true, outdir: dir, target: "browser", diff --git a/packages/kilo-indexing/test/kilocode/indexing/service-factory.test.ts b/packages/kilo-indexing/test/kilocode/indexing/service-factory.test.ts index 569c3a7eb3..e695b63511 100644 --- a/packages/kilo-indexing/test/kilocode/indexing/service-factory.test.ts +++ b/packages/kilo-indexing/test/kilocode/indexing/service-factory.test.ts @@ -49,7 +49,7 @@ describe("CodeIndexServiceFactory", () => { }) test("uses explicit LanceDB directory when configured", () => { - const dir = "/tmp/custom-lancedb" + const dir = path.join(process.cwd(), "tmp", "custom-lancedb") const factory = createFactory({ vectorStoreProvider: "lancedb", lancedbVectorStoreDirectory: dir }) const store = factory.createVectorStore() as unknown as { dbPath: string } diff --git a/packages/kilo-indexing/test/kilocode/indexing/vector-store/lancedb-vector-store.test.ts b/packages/kilo-indexing/test/kilocode/indexing/vector-store/lancedb-vector-store.test.ts index 2ac7fdeeab..0044da6266 100644 --- a/packages/kilo-indexing/test/kilocode/indexing/vector-store/lancedb-vector-store.test.ts +++ b/packages/kilo-indexing/test/kilocode/indexing/vector-store/lancedb-vector-store.test.ts @@ -767,14 +767,14 @@ describe("LocalVectorStore", () => { ) }) - test("should handle paths with backslashes safely", async () => { - const windowsPath = "C:\\Users\\test\\file.ts" + test("should handle relative paths with backslashes safely", async () => { + const windowsPath = "dir\\file.ts" mockTable.delete.mockResolvedValue(undefined) await store.deletePointsByFilePath(windowsPath) // Backslashes should be preserved, only quotes escaped - expect(mockTable.delete).toHaveBeenCalledWith(`\`filePath\` IN ('C:\\Users\\test\\file.ts')`) + expect(mockTable.delete).toHaveBeenCalledWith(`\`filePath\` IN ('dir\\file.ts')`) }) }) }) diff --git a/packages/kilo-indexing/test/kilocode/indexing/vector-store/qdrant-client.test.ts b/packages/kilo-indexing/test/kilocode/indexing/vector-store/qdrant-client.test.ts index 9ecb36221b..6661297cb4 100644 --- a/packages/kilo-indexing/test/kilocode/indexing/vector-store/qdrant-client.test.ts +++ b/packages/kilo-indexing/test/kilocode/indexing/vector-store/qdrant-client.test.ts @@ -1232,13 +1232,13 @@ describe("QdrantVectorStore", () => { }) }) - test("should correctly process pathSegments for nested file paths", async () => { + test("should correctly process pathSegments for backslash-delimited nested file paths", async () => { const mockPoints = [ { id: "test-id-1", vector: [0.1, 0.2, 0.3], payload: { - filePath: "src/components/ui/forms/InputField.tsx", + filePath: "src\\components\\ui\\forms\\InputField.tsx", content: "export const InputField = () => {}", startLine: 1, endLine: 3, @@ -1256,7 +1256,7 @@ describe("QdrantVectorStore", () => { id: "test-id-1", vector: [0.1, 0.2, 0.3], payload: { - filePath: "src/components/ui/forms/InputField.tsx", + filePath: "src\\components\\ui\\forms\\InputField.tsx", content: "export const InputField = () => {}", startLine: 1, endLine: 3, diff --git a/packages/kilo-telemetry/package.json b/packages/kilo-telemetry/package.json index 209f233724..c4a838dc5c 100644 --- a/packages/kilo-telemetry/package.json +++ b/packages/kilo-telemetry/package.json @@ -14,7 +14,8 @@ "scripts": { "typecheck": "tsgo --noEmit", "build": "tsc", - "test": "bun test" + "test": "bun test", + "test:ci": "mkdir -p .artifacts/unit && bun test --reporter=junit --reporter-outfile=.artifacts/unit/junit.xml" }, "dependencies": { "posthog-node": "4.4.0", diff --git a/packages/llm/package.json b/packages/llm/package.json index 199e5adcfc..df41e01e8e 100644 --- a/packages/llm/package.json +++ b/packages/llm/package.json @@ -8,6 +8,7 @@ "scripts": { "setup:recording-env": "bun run script/setup-recording-env.ts", "test": "bun test --timeout 30000 --only-failures", + "test:ci": "mkdir -p .artifacts/unit && bun test --timeout 30000 --reporter=junit --reporter-outfile=.artifacts/unit/junit.xml", "typecheck": "tsgo --noEmit" }, "exports": { diff --git a/packages/llm/src/schema/messages.ts b/packages/llm/src/schema/messages.ts index 3438ba5c3f..0fbd7f7b63 100644 --- a/packages/llm/src/schema/messages.ts +++ b/packages/llm/src/schema/messages.ts @@ -78,6 +78,19 @@ const LegacyToolMediaContent = Schema.Struct({ }) const ToolContentInput = Schema.Union([ToolContent, LegacyToolFileContent, LegacyToolMediaContent]) +// kilocode_change start - released readers require source-wrapped stored files +const stored = (item: ToolContent): typeof ToolContentInput.Type => { + if (item.type === "text") return item + const data = /^data:[^;,]+;base64,(.*)$/s.exec(item.uri)?.[1] + const source = data + ? ({ type: "data", data } as const) + : URL.canParse(item.uri) && ["http:", "https:"].includes(new URL(item.uri).protocol) + ? ({ type: "url", url: item.uri } as const) + : ({ type: "file", uri: item.uri } as const) + return { type: "file", source, mime: item.mime, name: item.name } +} +// kilocode_change end + export const StoredToolContent = ToolContentInput.pipe( Schema.decodeTo(ToolContent, { decode: SchemaGetter.transform((item) => { @@ -98,7 +111,7 @@ export const StoredToolContent = ToolContentInput.pipe( : item.source.uri return { type: "file" as const, uri, mime: item.mime, name: item.name } }), - encode: SchemaGetter.passthrough({ strict: false }), + encode: SchemaGetter.transform(stored), // kilocode_change - released readers require the source-wrapped file shape }), ) // kilocode_change end diff --git a/packages/opencode/src/agent/agent.ts b/packages/opencode/src/agent/agent.ts index 5e26d9517d..011ef60f63 100644 --- a/packages/opencode/src/agent/agent.ts +++ b/packages/opencode/src/agent/agent.ts @@ -119,6 +119,13 @@ export const layer = Layer.effect( // kilocode_change start - include global config dirs so agents can read them without prompting const referenceDirs = yield* Effect.gen(function* () { yield* (yield* PluginBoot.Service).wait() + // kilocode_change start - V2 tools must use Kilo's effective config precedence. + yield* KiloReference.sync({ + references: cfg.references ?? cfg.reference ?? {}, + directory: ctx.directory, + worktree: ctx.worktree, + }) + // kilocode_change end return (yield* (yield* Reference.Service).list()).map((reference) => reference.path) }).pipe(Effect.provide(locations.get(Location.Ref.make({ directory: AbsolutePath.make(ctx.directory) })))) const whitelistedDirs = [ diff --git a/packages/opencode/src/cli/cmd/debug/ripgrep.ts b/packages/opencode/src/cli/cmd/debug/ripgrep.ts index f9d82e9399..616b783de6 100644 --- a/packages/opencode/src/cli/cmd/debug/ripgrep.ts +++ b/packages/opencode/src/cli/cmd/debug/ripgrep.ts @@ -40,7 +40,7 @@ const FilesCommand = effectCmd({ limit: args.limit ?? 10_000, }) .pipe(Effect.orDie) - process.stdout.write(files.map((file) => file.path).join(EOL) + EOL) + process.stdout.write(files.items.map((file) => file.path).join(EOL) + EOL) // kilocode_change }), }) @@ -74,6 +74,6 @@ const SearchCommand = effectCmd({ limit: args.limit ?? 10_000, }) .pipe(Effect.orDie) - process.stdout.write(JSON.stringify(results, null, 2) + EOL) + process.stdout.write(JSON.stringify(results.items, null, 2) + EOL) // kilocode_change - preserve debug output shape }), }) diff --git a/packages/opencode/src/cli/cmd/mcp.ts b/packages/opencode/src/cli/cmd/mcp.ts index 74ebfe9c27..809c91559f 100644 --- a/packages/opencode/src/cli/cmd/mcp.ts +++ b/packages/opencode/src/cli/cmd/mcp.ts @@ -22,6 +22,7 @@ import { Filesystem } from "@/util/filesystem" import { EventV2Bridge } from "@/event-v2-bridge" import { EventV2 } from "@opencode-ai/core/event" import { Effect } from "effect" +import { Flag } from "@opencode-ai/core/flag/flag" // kilocode_change function getAuthStatusIcon(status: MCP.AuthStatus): string { switch (status) { @@ -479,6 +480,7 @@ export const McpAddCommand = effectCmd({ const maybeCtx = yield* InstanceRef if (!maybeCtx) return yield* Effect.die("InstanceRef not provided") const ctx = maybeCtx + const global = Flag.KILO_CONFIG_DIR ?? Global.Path.config // kilocode_change - honor the active Kilo config profile yield* Effect.promise(async () => { const command = args["--"] ?? [] if (!args.name && (args.url || args.env?.length || args.header?.length || command.length)) { @@ -520,7 +522,7 @@ export const McpAddCommand = effectCmd({ ...(Object.keys(environment).length ? { environment } : {}), } - const configPath = await resolveConfigPath(Global.Path.config, true) + const configPath = await resolveConfigPath(global, true) // kilocode_change await addMcpToConfig(args.name, mcpConfig, configPath) prompts.log.success(`MCP server "${args.name}" added to ${configPath}`) return @@ -534,7 +536,7 @@ export const McpAddCommand = effectCmd({ // Resolve config paths eagerly for hints const [projectConfigPath, globalConfigPath] = await Promise.all([ resolveConfigPath(ctx.worktree), - resolveConfigPath(Global.Path.config, true), + resolveConfigPath(global, true), // kilocode_change ]) // Determine scope diff --git a/packages/opencode/src/kilocode/reference.ts b/packages/opencode/src/kilocode/reference.ts index 42f1d0e34f..02c0bd635d 100644 --- a/packages/opencode/src/kilocode/reference.ts +++ b/packages/opencode/src/kilocode/reference.ts @@ -4,6 +4,8 @@ import { Global } from "@opencode-ai/core/global" import { parseRepositoryReference, repositoryCachePath, type RemoteReference } from "@/util/repository" import { Effect } from "effect" import { RepositoryCache } from "@opencode-ai/core/repository-cache" +import { Reference } from "@opencode-ai/core/reference" +import { AbsolutePath } from "@opencode-ai/core/schema" import { isInterrupted } from "@/kilocode/effect/cause" export type Resolved = @@ -108,3 +110,41 @@ export function ensure(cache: RepositoryCache.Interface, item: Extract((item) => { + if (item.kind === "invalid") return [] + if (item.kind === "local") { + return [ + [ + item.name, + new Reference.LocalSource({ + type: "local", + path: AbsolutePath.make(item.path), + }), + ] as const, + ] + } + return [ + [ + item.name, + new Reference.GitSource({ + type: "git", + repository: item.repository, + branch: item.branch, + }), + ] as const, + ] + }) + yield* service.replace(sources) +}) diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/file.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/file.ts index bacc4b0457..974376e895 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/file.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/file.ts @@ -25,9 +25,10 @@ export const fileHandlers = HttpApiBuilder.group(InstanceHttpApi, "file", (handl }) const findText = Effect.fn("FileHttpApi.findText")(function* (ctx: { query: { pattern: string } }) { + // kilocode_change start - preserve the released HTTP response shape while Core retains search metadata. return (yield* ripgrep .grep({ cwd: (yield* InstanceState.context).directory, pattern: ctx.query.pattern, limit: 10 }) - .pipe(Effect.orDie)).map((match) => ({ + .pipe(Effect.orDie)).items.map((match) => ({ path: { text: match.entry.path }, lines: { text: match.text }, line_number: match.line, @@ -38,6 +39,7 @@ export const fileHandlers = HttpApiBuilder.group(InstanceHttpApi, "file", (handl end: submatch.end, })), })) + // kilocode_change end }) const findFile = Effect.fn("FileHttpApi.findFile")(function* (ctx: { diff --git a/packages/opencode/src/session/compaction.ts b/packages/opencode/src/session/compaction.ts index 824b01097a..81aadddef0 100644 --- a/packages/opencode/src/session/compaction.ts +++ b/packages/opencode/src/session/compaction.ts @@ -621,6 +621,7 @@ export const layer = Layer.effect( reason: input.auto ? "auto" : "manual", text: summary ?? "", recent, + include: recent, // kilocode_change - released Core V2 readers recognize this field }) } // kilocode_change start - export self-contained compaction capture diff --git a/packages/opencode/src/tool/glob.ts b/packages/opencode/src/tool/glob.ts index 8f07de0a0a..14a17ad229 100644 --- a/packages/opencode/src/tool/glob.ts +++ b/packages/opencode/src/tool/glob.ts @@ -68,13 +68,16 @@ export const GlobTool = Tool.define( }) const limit = 100 - const files = yield* ripgrep.glob({ + // kilocode_change start - retain bounded-search metadata from Core ripgrep. + const result = yield* ripgrep.glob({ cwd: search, pattern: absolute?.pattern ?? params.pattern, // kilocode_change - absolute patterns are split into cwd + relative glob limit, signal: ctx.abort, // kilocode_change - stop ripgrep when the tool call is cancelled }) - const truncated = files.length === limit + const files = result.items + const truncated = result.truncated + // kilocode_change end const output = [] if (files.length === 0) output.push("No files found") @@ -86,6 +89,7 @@ export const GlobTool = Tool.define( `(Results are truncated: showing first ${limit} results. Consider using a more specific path or pattern.)`, ) } + if (result.partial) output.push("", "(Some discovered files could not be read.)") // kilocode_change } return { diff --git a/packages/opencode/src/tool/grep.ts b/packages/opencode/src/tool/grep.ts index be3190d433..47ac4a57dc 100644 --- a/packages/opencode/src/tool/grep.ts +++ b/packages/opencode/src/tool/grep.ts @@ -69,21 +69,23 @@ export const GrepTool = Tool.define( limit: 100, signal: ctx.abort, // kilocode_change - stop ripgrep when the tool call is cancelled }) - if (result.length === 0) return empty + const matches = result.items // kilocode_change - retain bounded-search metadata from Core ripgrep + if (matches.length === 0) return empty // kilocode_change - const rows = result.map((item) => ({ + const rows = matches.map((item) => ({ + // kilocode_change path: path.resolve(cwd, item.entry.path), line: item.line, text: item.text, })) const limit = 100 - const truncated = rows.length === limit + const truncated = result.truncated // kilocode_change const final = rows if (final.length === 0) return empty const total = rows.length - const hasMore = truncated || result.length === limit + const hasMore = truncated // kilocode_change const output = [`Found ${total} matches${hasMore ? " (more matches available)" : ""}`] let current = "" @@ -100,6 +102,7 @@ export const GrepTool = Tool.define( output.push("") output.push("(Results truncated. Consider using a more specific path or pattern.)") } + if (result.partial) output.push("", "(Some paths were inaccessible.)") // kilocode_change return { title: params.pattern, diff --git a/packages/plugin-atomic-chat/package.json b/packages/plugin-atomic-chat/package.json index 26ec79f318..f981b683c4 100644 --- a/packages/plugin-atomic-chat/package.json +++ b/packages/plugin-atomic-chat/package.json @@ -14,6 +14,7 @@ "scripts": { "typecheck": "tsgo --noEmit", "test": "vitest run", + "test:ci": "mkdir -p .artifacts/unit && vitest run --reporter=junit --outputFile=.artifacts/unit/junit.xml", "test:watch": "vitest" }, "dependencies": { diff --git a/packages/tui/package.json b/packages/tui/package.json index a245be56a4..ee722632da 100644 --- a/packages/tui/package.json +++ b/packages/tui/package.json @@ -7,6 +7,7 @@ "license": "MIT", "scripts": { "test": "bun test --timeout 30000 --only-failures", + "test:ci": "mkdir -p .artifacts/unit && bun test --timeout 30000 --reporter=junit --reporter-outfile=.artifacts/unit/junit.xml", "typecheck": "tsgo --noEmit" }, "exports": { diff --git a/packages/tui/src/runtime.tsx b/packages/tui/src/runtime.tsx index 6f519cbe82..46fed17a3f 100644 --- a/packages/tui/src/runtime.tsx +++ b/packages/tui/src/runtime.tsx @@ -5,5 +5,5 @@ export function abbreviateHome(input: string, home: string) { const relative = path.relative(home, input) if (relative === "") return "~" if (relative === ".." || relative.startsWith(".." + path.sep) || path.isAbsolute(relative)) return input - return "~" + path.sep + relative + return "~/" + relative.replaceAll(path.sep, "/") // kilocode_change - keep displayed paths portable }