From 54bb7f3de063501ea123df98aee41273072f1045 Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Mon, 24 Aug 2026 17:40:29 +0200 Subject: [PATCH 01/17] fix(cli): preserve editor context prompt prefix --- .../stable-editor-context-prompt-prefix.md | 5 + .../opencode/src/kilocode/editor-context.ts | 9 +- .../opencode/src/kilocode/session/prompt.ts | 85 +++++------ packages/opencode/src/session/prompt.ts | 19 +-- .../kilocode/editor-context-injection.test.ts | 135 ++++++++++++++++++ .../test/kilocode/system-prompt.test.ts | 6 + 6 files changed, 205 insertions(+), 54 deletions(-) create mode 100644 .changeset/stable-editor-context-prompt-prefix.md create mode 100644 packages/opencode/test/kilocode/editor-context-injection.test.ts diff --git a/.changeset/stable-editor-context-prompt-prefix.md b/.changeset/stable-editor-context-prompt-prefix.md new file mode 100644 index 0000000000..322da1e6a9 --- /dev/null +++ b/.changeset/stable-editor-context-prompt-prefix.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Stop moving editor context between user messages so providers with prefix caching, including local models, can reuse the conversation across turns. diff --git a/packages/opencode/src/kilocode/editor-context.ts b/packages/opencode/src/kilocode/editor-context.ts index ef7c077580..605fba20f8 100644 --- a/packages/opencode/src/kilocode/editor-context.ts +++ b/packages/opencode/src/kilocode/editor-context.ts @@ -27,10 +27,9 @@ export function staticEnvLines(ctx?: EditorContext): string[] { * Build a per-message block from editor context. * These change frequently (user switches files/tabs) and belong in the * user message so the model always has fresh context. - * Always includes at least the current timestamp. + * Always includes at least the supplied message timestamp. */ -function timestamp(): string { - const now = new Date() +function timestamp(now: Date): string { const offset = -now.getTimezoneOffset() const sign = offset >= 0 ? "+" : "-" const h = Math.floor(Math.abs(offset) / 60) @@ -41,8 +40,8 @@ function timestamp(): string { return `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}T${pad(now.getHours())}:${pad(now.getMinutes())}:${pad(now.getSeconds())}${sign}${h}:${m}` } -export function environmentDetails(ctx?: EditorContext): string { - const lines: string[] = [`Current time: ${timestamp()}`] +export function environmentDetails(ctx?: EditorContext, now = new Date()): string { + const lines: string[] = [`Message time: ${timestamp(now)}`] if (ctx?.directory) { lines.push(`Working directory: ${ctx.directory}`) } diff --git a/packages/opencode/src/kilocode/session/prompt.ts b/packages/opencode/src/kilocode/session/prompt.ts index 4b997d2a5f..c7337ac779 100644 --- a/packages/opencode/src/kilocode/session/prompt.ts +++ b/packages/opencode/src/kilocode/session/prompt.ts @@ -270,7 +270,10 @@ export namespace KiloSessionPrompt { const taggedSession = PermissionProvenance.tagSession(session.permission ?? []) const ruleset = Permission.merge( taggedAgent, - guardPermissions({ agent: { name: agent.name, permission: taggedAgent }, session: { permission: taggedSession } }), + guardPermissions({ + agent: { name: agent.name, permission: taggedAgent }, + session: { permission: taggedSession }, + }), ) const outcome = yield* input.permission.ask({ ...input.request, ruleset, hardRuleset: hardPermissions({ agent }) }) if (outcome.manual) return { source: "manual" } satisfies PermissionProvenance.Approval @@ -278,13 +281,9 @@ export namespace KiloSessionPrompt { // kilocode_change end }) - /** - * Mutable cache for environment details, keyed by user message ID - * so it recomputes when a new user message arrives. - */ + /** Mutable per-turn cache for deterministic environment detail blocks. */ export interface EnvCache { - block?: string - user?: string + blocks?: Map } export function memoryToolEnabled(input: { ctx: MemoryPaths.Ctx }) { @@ -356,9 +355,10 @@ export namespace KiloSessionPrompt { } /** - * Ephemerally injects dynamic editor context (visible files, open tabs, etc.) - * into the last user message. Caches the result per user message ID so repeated - * loop iterations produce byte-identical messages (prompt caching). + * Reconstructs dynamic editor context on every user message without + * persisting synthetic prompt scaffolding. Using each message's creation + * time keeps historical blocks byte-identical, so later turns only append + * instead of moving the block and discarding the provider prompt cache. */ export function injectEditorContext(input: { msgs: MessageV2.WithParts[] @@ -366,36 +366,41 @@ export namespace KiloSessionPrompt { sessionID: SessionID cache: EnvCache }) { - if (input.cache.user !== input.lastUser.id) { - const ctx = (() => { - try { - return Instance.current - } catch { - return undefined - } - })() - input.cache.block = environmentDetails({ - ...input.lastUser.editorContext, - ...(ctx ? { directory: ctx.directory, worktree: ctx.worktree } : {}), - }) - input.cache.user = input.lastUser.id - } - if (!input.cache.block) return - const idx = input.msgs.findLastIndex((m) => m.info.role === "user") - if (idx === -1) return - input.msgs[idx] = { - ...input.msgs[idx], - parts: [ - ...input.msgs[idx].parts, - { - id: PartID.make(Identifier.ascending("part")), - sessionID: input.sessionID, - messageID: input.msgs[idx].info.id, - type: "text", - text: input.cache.block, - synthetic: true, - } satisfies MessageV2.TextPart, - ], + const current = (() => { + try { + const ctx = Instance.current + return { directory: ctx.directory, worktree: ctx.worktree } + } catch { + return undefined + } + })() + input.cache.blocks ??= new Map() + for (const msg of input.msgs) { + if (msg.info.role !== "user") continue + if ( + msg.parts.some( + (part) => part.type === "text" && part.synthetic && part.text.startsWith(""), + ) + ) + continue + const block = + input.cache.blocks.get(msg.info.id) ?? + environmentDetails( + { + ...current, + ...msg.info.editorContext, + }, + new Date(msg.info.time.created), + ) + input.cache.blocks.set(msg.info.id, block) + msg.parts.push({ + id: PartID.make(Identifier.ascending("part")), + sessionID: input.sessionID, + messageID: msg.info.id, + type: "text", + text: block, + synthetic: true, + } satisfies MessageV2.TextPart) } } diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 092666a3ef..2a9239b0b5 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -138,14 +138,10 @@ function isOrphanedInterruptedTool(part: SessionV1.ToolPart) { export interface Interface { readonly cancel: (sessionID: SessionID) => Effect.Effect - readonly prompt: ( - input: PromptInput, - ) => Effect.Effect + readonly prompt: (input: PromptInput) => Effect.Effect readonly loop: (input: LoopInput) => Effect.Effect readonly shell: (input: ShellInput) => Effect.Effect - readonly command: ( - input: CommandInput, - ) => Effect.Effect + readonly command: (input: CommandInput) => Effect.Effect readonly resolvePromptParts: (template: string) => Effect.Effect } @@ -837,6 +833,8 @@ export const layer = Layer.effect( (ag.variant && full?.variants?.[ag.variant] ? ag.variant : undefined) // kilocode_change end + // kilocode_change start - freeze the route used by dynamic editor context and reuse it for references + const ctx = yield* InstanceState.context const info: SessionV1.User = { id: input.messageID ?? MessageID.ascending(), role: "user", @@ -851,7 +849,7 @@ export const layer = Layer.effect( }, system: input.system, format: input.format, - editorContext: input.editorContext, // kilocode_change + editorContext: { ...input.editorContext, directory: ctx.directory, worktree: ctx.worktree }, } const current = yield* sessions.get(input.sessionID).pipe(Effect.orDie) @@ -881,12 +879,12 @@ export const layer = Layer.effect( id: part.id ? PartID.make(part.id) : PartID.ascending(), }) - const ctx = yield* InstanceState.context // kilocode_change - resolve V1 reference roots for attachment authorization const references = KiloConfiguredReference.resolveAll({ references: (yield* config.get()).reference ?? {}, directory: ctx.directory, worktree: ctx.worktree, }).filter((item) => item.kind !== "invalid") + // kilocode_change end const referenceContextFromFilePart = Effect.fnUntraced(function* ( part: Extract, @@ -2518,7 +2516,10 @@ export const PromptInput = Schema.Struct({ // `parts` type from the exported Schema input types so callers see a proper // tagged union. type PartInputUnion = - MessageV2.TextPartInput | MessageV2.FilePartInput | MessageV2.AgentPartInput | MessageV2.SubtaskPartInput + | MessageV2.TextPartInput + | MessageV2.FilePartInput + | MessageV2.AgentPartInput + | MessageV2.SubtaskPartInput export type PromptInput = Omit, "parts" | "editorContext"> & { parts: PartInputUnion[] editorContext?: MessageV2.EditorContext diff --git a/packages/opencode/test/kilocode/editor-context-injection.test.ts b/packages/opencode/test/kilocode/editor-context-injection.test.ts new file mode 100644 index 0000000000..f5bd457c80 --- /dev/null +++ b/packages/opencode/test/kilocode/editor-context-injection.test.ts @@ -0,0 +1,135 @@ +import { describe, expect, test } from "bun:test" +import { ModelV2 } from "@opencode-ai/core/model" +import { ProviderV2 } from "@opencode-ai/core/provider" +import type { Provider } from "../../src/provider/provider" +import { KiloSessionPrompt } from "../../src/kilocode/session/prompt" +import { MessageV2 } from "../../src/session/message-v2" +import { MessageID, PartID, SessionID } from "../../src/session/schema" + +const sessionID = SessionID.make("ses_test") +const model = { + providerID: ProviderV2.ID.make("openai"), + modelID: ModelV2.ID.make("gpt-4"), +} +const mdl: Provider.Model = { + id: model.modelID, + providerID: model.providerID, + api: { id: model.modelID, url: "https://example.com", npm: "@ai-sdk/openai" }, + name: "Test Model", + capabilities: { + temperature: true, + reasoning: false, + attachment: false, + toolcall: true, + input: { text: true, audio: false, image: false, video: false, pdf: false }, + output: { text: true, audio: false, image: false, video: false, pdf: false }, + interleaved: false, + }, + cost: { input: 0, output: 0, cache: { read: 0, write: 0 } }, + limit: { context: 100_000, input: 100_000, output: 10_000 }, + status: "active", + options: {}, + headers: {}, + release_date: "2026-01-01", +} + +function user(text: string, created: number, activeFile?: string, route = "/repo/original") { + const id = MessageID.ascending() + return { + info: { + id, + role: "user" as const, + sessionID, + time: { created }, + agent: "code", + model, + editorContext: { directory: route, worktree: route, ...(activeFile ? { activeFile } : {}) }, + }, + parts: [{ id: PartID.ascending(), messageID: id, sessionID, type: "text" as const, text }], + } satisfies MessageV2.WithParts +} + +function assistant(parentID: MessageID, text: string) { + const id = MessageID.ascending() + return { + info: { + id, + role: "assistant" as const, + sessionID, + time: { created: Date.now() }, + parentID, + modelID: model.modelID, + providerID: model.providerID, + mode: "code", + agent: "code", + path: { cwd: "/tmp", root: "/tmp" }, + cost: 0, + tokens: { total: 0, input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + }, + parts: [{ id: PartID.ascending(), messageID: id, sessionID, type: "text" as const, text }], + } satisfies MessageV2.WithParts +} + +function inject(msgs: MessageV2.WithParts[], cache: KiloSessionPrompt.EnvCache = {}) { + const last = msgs.findLast((msg) => msg.info.role === "user") + if (!last || last.info.role !== "user") throw new Error("missing user message") + KiloSessionPrompt.injectEditorContext({ msgs, lastUser: last.info, sessionID, cache }) +} + +function blocks(msg: MessageV2.WithParts) { + return msg.parts.filter( + (part): part is MessageV2.TextPart => + part.type === "text" && !!part.synthetic && part.text.startsWith(""), + ) +} + +async function prompt(msgs: MessageV2.WithParts[]) { + return JSON.stringify(await MessageV2.toModelMessages(msgs, mdl)) +} + +describe("injectEditorContext", () => { + test("keeps the previous model prompt byte-identical across turns without persisting blocks", async () => { + const stored1 = user("2 + 2", Date.parse("2026-08-24T12:00:00Z"), "src/one.ts") + const turn1 = [structuredClone(stored1)] + inject(turn1) + const first = await prompt(turn1) + + expect(blocks(turn1[0])).toHaveLength(1) + expect(blocks(stored1)).toHaveLength(0) + + const stored2 = user("3 + 3", Date.parse("2026-08-24T12:01:00Z"), "src/two.ts", "/repo/next") + const turn2 = [structuredClone(stored1), assistant(stored1.info.id, "4"), structuredClone(stored2)] + inject(turn2) + + expect((await prompt(turn2)).startsWith(first.slice(0, -1))).toBe(true) + expect(blocks(turn2[0])).toHaveLength(1) + expect(blocks(turn2[2])).toHaveLength(1) + expect(blocks(turn2[0])[0].text).toContain("Active file: src/one.ts") + expect(blocks(turn2[2])[0].text).toContain("Active file: src/two.ts") + expect(blocks(turn2[0])[0].text).toContain("Message time: 2026-08-24T") + expect(blocks(turn2[0])[0].text).toContain("Working directory: /repo/original") + expect(blocks(turn2[2])[0].text).toContain("Working directory: /repo/next") + }) + + test("is byte-identical across repeated loop iterations", async () => { + const stored = user("list files", Date.parse("2026-08-24T12:00:00Z")) + const cache: KiloSessionPrompt.EnvCache = {} + const first = [structuredClone(stored)] + const second = [structuredClone(stored)] + + inject(first, cache) + inject(second, cache) + + expect(await prompt(second)).toBe(await prompt(first)) + expect(blocks(second[0])).toHaveLength(1) + }) + + test("does not mistake user-authored markup for an injected block", () => { + const stored = user("example", Date.parse("2026-08-24T12:00:00Z")) + const msgs = [stored] + inject(msgs) + + expect(blocks(stored)).toHaveLength(1) + expect(stored.parts.filter((part) => part.type === "text")).toHaveLength(2) + }) +}) diff --git a/packages/opencode/test/kilocode/system-prompt.test.ts b/packages/opencode/test/kilocode/system-prompt.test.ts index 10cea1161d..b82b2265fe 100644 --- a/packages/opencode/test/kilocode/system-prompt.test.ts +++ b/packages/opencode/test/kilocode/system-prompt.test.ts @@ -141,4 +141,10 @@ describe("environmentDetails", () => { expect(result).toContain("Workspace root folder: /repo/.kilo/worktrees/feature") expect(result).toContain("Active file: src/app.ts") }) + + test("formats the supplied message time", () => { + const result = environmentDetails({}, new Date(2026, 7, 24, 12, 34, 56)) + + expect(result).toContain("Message time: 2026-08-24T12:34:56") + }) }) From 903c0279400fd68bac2ba5085a885fbabbeacd52 Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Mon, 24 Aug 2026 17:41:02 +0200 Subject: [PATCH 02/17] fix(cli): share location services across server routes --- .changeset/quiet-editor-location-watchers.md | 5 +++ packages/opencode/src/agent/agent.ts | 10 +---- packages/opencode/src/effect/app-runtime.ts | 2 + .../kilocode/server/reference-reconciler.ts | 4 +- .../routes/instance/httpapi/handlers/file.ts | 6 +-- .../routes/instance/httpapi/handlers/pty.ts | 8 ++-- .../server/routes/instance/httpapi/server.ts | 13 +++---- packages/opencode/src/session/system.ts | 10 +---- .../opencode/test/kilocode/reference.test.ts | 3 +- .../test/kilocode/shared-location-map.test.ts | 37 +++++++++++++++++++ 10 files changed, 65 insertions(+), 33 deletions(-) create mode 100644 .changeset/quiet-editor-location-watchers.md create mode 100644 packages/opencode/test/kilocode/shared-location-map.test.ts diff --git a/.changeset/quiet-editor-location-watchers.md b/.changeset/quiet-editor-location-watchers.md new file mode 100644 index 0000000000..ed22ebbe10 --- /dev/null +++ b/.changeset/quiet-editor-location-watchers.md @@ -0,0 +1,5 @@ +--- +"@kilocode/cli": patch +--- + +Prevent runaway memory growth in long-running editor servers by sharing project services across file, terminal, reference, agent, and session routes. diff --git a/packages/opencode/src/agent/agent.ts b/packages/opencode/src/agent/agent.ts index f43327030d..d7530016e8 100644 --- a/packages/opencode/src/agent/agent.ts +++ b/packages/opencode/src/agent/agent.ts @@ -34,7 +34,7 @@ import * as KiloReference from "@/kilocode/reference" // kilocode_change end import { ProviderV2 } from "@opencode-ai/core/provider" import { ModelV2 } from "@opencode-ai/core/model" -import { LocationServiceMap, locationServiceMapLayer } from "@opencode-ai/core/location-services" +import { LocationServiceMap } from "@opencode-ai/core/location-services" // kilocode_change - use the runtime's shared map import { Reference } from "@opencode-ai/core/reference" import { Location } from "@opencode-ai/core/location" import { PluginV2 } from "@opencode-ai/core/plugin" @@ -604,16 +604,10 @@ const layer = Layer.effect( }), ) -const locationServiceMapNode = LayerNode.make({ - service: LocationServiceMap.Service, - layer: locationServiceMapLayer, - deps: [], -}) - export const node = LayerNode.make({ service: Service, layer: layer, - deps: [Config.node, Auth.node, Plugin.node, Skill.node, Provider.node, RuntimeFlags.node, locationServiceMapNode], // kilocode_change + deps: [Config.node, Auth.node, Plugin.node, Skill.node, Provider.node, RuntimeFlags.node, LocationServiceMap.node], // kilocode_change }) export * as Agent from "./agent" diff --git a/packages/opencode/src/effect/app-runtime.ts b/packages/opencode/src/effect/app-runtime.ts index eb61a88fea..b3c2eb2a93 100644 --- a/packages/opencode/src/effect/app-runtime.ts +++ b/packages/opencode/src/effect/app-runtime.ts @@ -67,6 +67,7 @@ import { ProjectCopy } from "@opencode-ai/core/project/copy" // kilocode_change import { MoveSession } from "@opencode-ai/core/control-plane/move-session" // kilocode_change import { PtyTicket } from "@opencode-ai/core/pty/ticket" // kilocode_change import { Pty } from "@opencode-ai/core/pty" // kilocode_change +import { LocationServiceMap } from "@opencode-ai/core/location-services" // kilocode_change // kilocode_change start - retain Kilo runtime services in the upstream node graph const memory = LayerNode.make({ service: MemoryService.Service, layer: MemoryService.layer, deps: [] }) @@ -132,6 +133,7 @@ export const AppLayer = AppNodeBuilderV1.build( MoveSession.node, PtyTicket.node, Pty.shutdownNode, // kilocode_change + LocationServiceMap.node, // kilocode_change - expose the process-wide location cache to listeners // kilocode_change end ]), ).pipe(Layer.provideMerge(AppNodeBuilderV1.build(Ripgrep.node)), Layer.provideMerge(Observability.layer)) diff --git a/packages/opencode/src/kilocode/server/reference-reconciler.ts b/packages/opencode/src/kilocode/server/reference-reconciler.ts index 11ad901b69..65e5f8f5e0 100644 --- a/packages/opencode/src/kilocode/server/reference-reconciler.ts +++ b/packages/opencode/src/kilocode/server/reference-reconciler.ts @@ -3,7 +3,7 @@ import { InstanceRef } from "@/effect/instance-ref" import { isInterrupted } from "@/kilocode/effect/cause" import * as KiloReference from "@/kilocode/reference" import { InstanceStore } from "@/project/instance-store" -import { LocationServiceMap, locationServiceMapLayer } from "@opencode-ai/core/location-services" +import { LocationServiceMap } from "@opencode-ai/core/location-services" import { Location } from "@opencode-ai/core/location" import { PluginV2 } from "@opencode-ai/core/plugin" // kilocode_change import { ReferenceReconciler } from "@opencode-ai/server/kilocode/reference-reconciler" @@ -46,4 +46,4 @@ export const locations = Layer.effect( }), }) }), -).pipe(Layer.provide(locationServiceMapLayer)) +) 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 026fee106b..259a1fedc9 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/file.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/file.ts @@ -1,11 +1,11 @@ import * as InstanceState from "@/effect/instance-state" import { FileSystem } from "@opencode-ai/core/filesystem" -import { LocationServiceMap, locationServiceMapLayer } from "@opencode-ai/core/location-services" +import { LocationServiceMap } from "@opencode-ai/core/location-services" // kilocode_change - reuse the server location map import { Ripgrep } from "@opencode-ai/core/ripgrep" import { FSUtil } from "@opencode-ai/core/fs-util" import { Location } from "@opencode-ai/core/location" import { AbsolutePath, RelativePath } from "@opencode-ai/core/schema" -import { Effect, Layer, Option } from "effect" +import { Effect, Option } from "effect" // kilocode_change - location map is provided by the server import ignore from "ignore" import path from "path" import { HttpApiBuilder } from "effect/unstable/httpapi" @@ -138,4 +138,4 @@ export const fileHandlers = HttpApiBuilder.group(InstanceHttpApi, "file", (handl .handle("content", content) .handle("status", status) }), -).pipe(Layer.provide(locationServiceMapLayer)) +) // kilocode_change - reuse the server location map diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/pty.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/pty.ts index aefd39005f..020387dfe6 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/pty.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/pty.ts @@ -6,7 +6,7 @@ import { Pty } from "@opencode-ai/core/pty" import { PtyProtocol } from "@opencode-ai/core/pty/protocol" import { PtyID } from "@opencode-ai/core/pty/schema" import { PtyTicket } from "@opencode-ai/core/pty/ticket" -import { LocationServiceMap, locationServiceMapLayer } from "@opencode-ai/core/location-services" +import { LocationServiceMap } from "@opencode-ai/core/location-services" // kilocode_change - reuse the server location map import { Location } from "@opencode-ai/core/location" import { AbsolutePath } from "@opencode-ai/core/schema" import { Shell } from "@opencode-ai/core/shell" @@ -16,7 +16,7 @@ import { PTY_CONNECT_TOKEN_HEADER, PTY_CONNECT_TOKEN_HEADER_VALUE, } from "@/server/shared/pty-ticket" -import { Effect, Layer, Option, Queue, Schema } from "effect" +import { Effect, Option, Queue, Schema } from "effect" // kilocode_change - location map is provided by the server import { HttpServerRequest, HttpServerResponse } from "effect/unstable/http" import { HttpApiBuilder } from "effect/unstable/httpapi" import * as Socket from "effect/unstable/socket/Socket" @@ -165,7 +165,7 @@ export const ptyHandlers = HttpApiBuilder.group(InstanceHttpApi, "pty", (handler .handle("remove", remove) .handle("connectToken", connectToken) }), -).pipe(Layer.provide(locationServiceMapLayer)) +) // kilocode_change - reuse the server location map export const ptyConnectHandlers = HttpApiBuilder.group(PtyConnectApi, "pty-connect", (handlers) => Effect.gen(function* () { @@ -285,4 +285,4 @@ export const ptyConnectHandlers = HttpApiBuilder.group(PtyConnectApi, "pty-conne }), ) }), -).pipe(Layer.provide(locationServiceMapLayer)) +) // kilocode_change - reuse the server location map diff --git a/packages/opencode/src/server/routes/instance/httpapi/server.ts b/packages/opencode/src/server/routes/instance/httpapi/server.ts index dc35b2ddbf..9f112cbcd4 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/server.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/server.ts @@ -337,7 +337,7 @@ export function createRoutes( ), Layer.provide(locationServiceMapV2), - Layer.provide(AppNodeBuilderV1.build(app)), + Layer.provide(AppNodeBuilderV1.build(app, [[LocationServiceMap.node, locationServiceMapV2]])), // kilocode_change // Must stay last: layers provided later in this pipe build beneath earlier ones, // so Observability must come after every service graph. Otherwise eagerly forked // fibers (e.g. the ModelsDev background refresh) capture Effect's default stdout @@ -348,7 +348,9 @@ export function createRoutes( // kilocode_change start - keep listener routes local while application services come from AppRuntime export function createListenerRoutes(corsOptions?: CorsOptions) { - const locationServiceMapV2 = buildLocationServiceMap() + // Re-export AppRuntime's process-wide map through listener-local graphs instead of + // materializing another full catalog and native file index for every worktree. + const locationServiceMapV2 = Layer.effect(LocationServiceMap.Service, LocationServiceMap.Service) return Layer.mergeAll( rootApiRoutes, @@ -364,11 +366,8 @@ export function createListenerRoutes(corsOptions?: CorsOptions) { // satisfied when the layer is built, not at request time, so the listener needs the same chain // createRoutes uses. // - // These builds sit inside KiloListener's Layer.fresh boundary, so each one self-provides its own - // dependency subtree rather than resolving AppRuntime's. That is deliberate: SessionV2 is bound - // to this listener's LocationServiceMap and to SessionExecutionLocal, so it cannot be the - // process-wide instance. Everything the graph does not rebind (the nodes listed in AppLayer) - // still comes from AppRuntime, and the scope teardown releases the rest. + // SessionV2 remains listener-local because it uses SessionExecutionLocal. Its location map is + // inherited from AppRuntime so every server graph shares one cache. Layer.provide(sessionLocationLayer), Layer.provide(locationLayer), Layer.provide(PtyEnvironment.layer), diff --git a/packages/opencode/src/session/system.ts b/packages/opencode/src/session/system.ts index fedf6a0bde..a01f5fde9a 100644 --- a/packages/opencode/src/session/system.ts +++ b/packages/opencode/src/session/system.ts @@ -21,7 +21,7 @@ import { Permission } from "@/permission" import { Skill } from "@/skill" import { AbsolutePath } from "@opencode-ai/core/schema" import { Location } from "@opencode-ai/core/location" -import { LocationServiceMap, locationServiceMapLayer } from "@opencode-ai/core/location-services" +import { LocationServiceMap } from "@opencode-ai/core/location-services" // kilocode_change - use the runtime's shared map import { Reference } from "@opencode-ai/core/reference" import { MCP } from "@/mcp" import { PermissionV1 } from "@opencode-ai/core/v1/permission" @@ -184,16 +184,10 @@ const layer = Layer.effect( }), ) -const locationServiceMapNode = LayerNode.make({ - service: LocationServiceMap.Service, - layer: locationServiceMapLayer, - deps: [], -}) - export const node = LayerNode.make({ service: Service, layer: layer, - deps: [Skill.node, MCP.node, Config.node, locationServiceMapNode], // kilocode_change + deps: [Skill.node, MCP.node, Config.node, LocationServiceMap.node], // kilocode_change }) export * as SystemPrompt from "./system" diff --git a/packages/opencode/test/kilocode/reference.test.ts b/packages/opencode/test/kilocode/reference.test.ts index 1a14cafcce..b953ef8f68 100644 --- a/packages/opencode/test/kilocode/reference.test.ts +++ b/packages/opencode/test/kilocode/reference.test.ts @@ -7,7 +7,7 @@ import * as Reference from "../../src/kilocode/reference" import { Reference as CoreReference } from "@opencode-ai/core/reference" import { EventV2 } from "@opencode-ai/core/event" import { Global } from "@opencode-ai/core/global" -import { LocationServiceMap } from "@opencode-ai/core/location-services" +import { buildLocationServiceMap, LocationServiceMap } from "@opencode-ai/core/location-services" import { Location } from "@opencode-ai/core/location" import { AbsolutePath } from "@opencode-ai/core/schema" import { Config } from "../../src/config/config" @@ -141,6 +141,7 @@ describe("configured references", () => { }, }) const layer = locations.pipe( + Layer.provide(buildLocationServiceMap()), Layer.provide(AppNodeBuilder.build(Config.node)), Layer.provide(testInstanceStoreLayer), ) diff --git a/packages/opencode/test/kilocode/shared-location-map.test.ts b/packages/opencode/test/kilocode/shared-location-map.test.ts new file mode 100644 index 0000000000..677103e7c9 --- /dev/null +++ b/packages/opencode/test/kilocode/shared-location-map.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, test } from "bun:test" +import { readFileSync } from "node:fs" + +const root = new URL("../../src/", import.meta.url) + +function source(path: string) { + return readFileSync(new URL(path, root), "utf8") +} + +describe("shared location service map", () => { + test("server consumers do not build private location maps", () => { + const files = [ + "server/routes/instance/httpapi/handlers/file.ts", + "server/routes/instance/httpapi/handlers/pty.ts", + "kilocode/server/reference-reconciler.ts", + "agent/agent.ts", + "session/system.ts", + ] + + for (const file of files) { + expect(source(file), file).not.toContain("locationServiceMapLayer") + } + }) + + test("server app graph receives the listener location map", () => { + expect(source("server/routes/instance/httpapi/server.ts")).toContain( + "AppNodeBuilderV1.build(app, [[LocationServiceMap.node, locationServiceMapV2]])", + ) + }) + + test("listener inherits the process-wide location map", () => { + expect(source("server/routes/instance/httpapi/server.ts")).toContain( + "Layer.effect(LocationServiceMap.Service, LocationServiceMap.Service)", + ) + expect(source("effect/app-runtime.ts")).toContain("LocationServiceMap.node") + }) +}) From c658cd4c10c68d47ccdb9c443422f8e09d7f90e7 Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Mon, 24 Aug 2026 17:45:05 +0200 Subject: [PATCH 03/17] fix(cli): stabilize historical editor routes --- .../opencode/src/kilocode/session/prompt.ts | 21 +++++++++++-------- packages/opencode/src/session/prompt.ts | 4 ++-- .../kilocode/editor-context-injection.test.ts | 16 ++++++++++---- 3 files changed, 26 insertions(+), 15 deletions(-) diff --git a/packages/opencode/src/kilocode/session/prompt.ts b/packages/opencode/src/kilocode/session/prompt.ts index c7337ac779..3ceb264672 100644 --- a/packages/opencode/src/kilocode/session/prompt.ts +++ b/packages/opencode/src/kilocode/session/prompt.ts @@ -363,17 +363,20 @@ export namespace KiloSessionPrompt { export function injectEditorContext(input: { msgs: MessageV2.WithParts[] lastUser: MessageV2.User + session: Pick sessionID: SessionID cache: EnvCache }) { - const current = (() => { - try { - const ctx = Instance.current - return { directory: ctx.directory, worktree: ctx.worktree } - } catch { - return undefined - } - })() + const route = { + directory: input.session.directory, + worktree: path.resolve( + input.session.directory, + ...(input.session.path + ?.split("/") + .filter(Boolean) + .map(() => "..") ?? []), + ), + } input.cache.blocks ??= new Map() for (const msg of input.msgs) { if (msg.info.role !== "user") continue @@ -387,7 +390,7 @@ export namespace KiloSessionPrompt { input.cache.blocks.get(msg.info.id) ?? environmentDetails( { - ...current, + ...route, ...msg.info.editorContext, }, new Date(msg.info.time.created), diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 2a9239b0b5..91fabe53b5 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -1716,7 +1716,7 @@ export const layer = Layer.effect( // kilocode_change start — ephemeral context injection + post-summary // media strip (keeps outgoing body under the gateway body-size limit // even when filterCompacted couldn't trim the pre-summary history). - KiloSessionPrompt.injectEditorContext({ msgs, lastUser, sessionID, cache: envCache }) + KiloSessionPrompt.injectEditorContext({ msgs, lastUser, session, sessionID, cache: envCache }) msgs = KiloSessionPrompt.maybeStripHistoricalMedia(msgs) // kilocode_change end @@ -1740,7 +1740,7 @@ export const layer = Layer.effect( msgs = KiloSessionPromptQueue.scope(sessionID, msgs) msgs = KiloSessionPrompt.trimBeforeLastSummary(msgs) yield* plugin.trigger("experimental.chat.messages.transform", {}, { messages: msgs }) - KiloSessionPrompt.injectEditorContext({ msgs, lastUser, sessionID, cache: envCache }) + KiloSessionPrompt.injectEditorContext({ msgs, lastUser, session, sessionID, cache: envCache }) msgs = KiloSessionPrompt.maybeStripHistoricalMedia(msgs) modelMsgs = yield* MessageV2.toModelMessagesEffect(msgs, model).pipe( Effect.provideService(Database.Service, database), diff --git a/packages/opencode/test/kilocode/editor-context-injection.test.ts b/packages/opencode/test/kilocode/editor-context-injection.test.ts index f5bd457c80..3e49a1713a 100644 --- a/packages/opencode/test/kilocode/editor-context-injection.test.ts +++ b/packages/opencode/test/kilocode/editor-context-injection.test.ts @@ -11,6 +11,10 @@ const model = { providerID: ProviderV2.ID.make("openai"), modelID: ModelV2.ID.make("gpt-4"), } +const session = { + directory: "/repo/session", + path: "session", +} const mdl: Provider.Model = { id: model.modelID, providerID: model.providerID, @@ -33,7 +37,7 @@ const mdl: Provider.Model = { release_date: "2026-01-01", } -function user(text: string, created: number, activeFile?: string, route = "/repo/original") { +function user(text: string, created: number, activeFile?: string, route?: string) { const id = MessageID.ascending() return { info: { @@ -43,7 +47,10 @@ function user(text: string, created: number, activeFile?: string, route = "/repo time: { created }, agent: "code", model, - editorContext: { directory: route, worktree: route, ...(activeFile ? { activeFile } : {}) }, + editorContext: { + ...(route ? { directory: route, worktree: route } : {}), + ...(activeFile ? { activeFile } : {}), + }, }, parts: [{ id: PartID.ascending(), messageID: id, sessionID, type: "text" as const, text }], } satisfies MessageV2.WithParts @@ -73,7 +80,7 @@ function assistant(parentID: MessageID, text: string) { function inject(msgs: MessageV2.WithParts[], cache: KiloSessionPrompt.EnvCache = {}) { const last = msgs.findLast((msg) => msg.info.role === "user") if (!last || last.info.role !== "user") throw new Error("missing user message") - KiloSessionPrompt.injectEditorContext({ msgs, lastUser: last.info, sessionID, cache }) + KiloSessionPrompt.injectEditorContext({ msgs, lastUser: last.info, session, sessionID, cache }) } function blocks(msg: MessageV2.WithParts) { @@ -107,7 +114,8 @@ describe("injectEditorContext", () => { expect(blocks(turn2[0])[0].text).toContain("Active file: src/one.ts") expect(blocks(turn2[2])[0].text).toContain("Active file: src/two.ts") expect(blocks(turn2[0])[0].text).toContain("Message time: 2026-08-24T") - expect(blocks(turn2[0])[0].text).toContain("Working directory: /repo/original") + expect(blocks(turn2[0])[0].text).toContain("Working directory: /repo/session") + expect(blocks(turn2[0])[0].text).toContain("Workspace root folder: /repo") expect(blocks(turn2[2])[0].text).toContain("Working directory: /repo/next") }) From ce555bb343a078163c5cd55a278229905aab0da3 Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Mon, 24 Aug 2026 18:06:06 +0200 Subject: [PATCH 04/17] fix(cli): preserve isolated location map test graphs --- packages/opencode/src/agent/agent.ts | 10 ++++++++-- packages/opencode/src/effect/app-runtime.ts | 4 +++- packages/opencode/src/session/system.ts | 10 ++++++++-- .../opencode/test/kilocode/shared-location-map.test.ts | 2 -- 4 files changed, 19 insertions(+), 7 deletions(-) diff --git a/packages/opencode/src/agent/agent.ts b/packages/opencode/src/agent/agent.ts index d7530016e8..f43327030d 100644 --- a/packages/opencode/src/agent/agent.ts +++ b/packages/opencode/src/agent/agent.ts @@ -34,7 +34,7 @@ import * as KiloReference from "@/kilocode/reference" // kilocode_change end import { ProviderV2 } from "@opencode-ai/core/provider" import { ModelV2 } from "@opencode-ai/core/model" -import { LocationServiceMap } from "@opencode-ai/core/location-services" // kilocode_change - use the runtime's shared map +import { LocationServiceMap, locationServiceMapLayer } from "@opencode-ai/core/location-services" import { Reference } from "@opencode-ai/core/reference" import { Location } from "@opencode-ai/core/location" import { PluginV2 } from "@opencode-ai/core/plugin" @@ -604,10 +604,16 @@ const layer = Layer.effect( }), ) +const locationServiceMapNode = LayerNode.make({ + service: LocationServiceMap.Service, + layer: locationServiceMapLayer, + deps: [], +}) + export const node = LayerNode.make({ service: Service, layer: layer, - deps: [Config.node, Auth.node, Plugin.node, Skill.node, Provider.node, RuntimeFlags.node, LocationServiceMap.node], // kilocode_change + deps: [Config.node, Auth.node, Plugin.node, Skill.node, Provider.node, RuntimeFlags.node, locationServiceMapNode], // kilocode_change }) export * as Agent from "./agent" diff --git a/packages/opencode/src/effect/app-runtime.ts b/packages/opencode/src/effect/app-runtime.ts index b3c2eb2a93..1536be8553 100644 --- a/packages/opencode/src/effect/app-runtime.ts +++ b/packages/opencode/src/effect/app-runtime.ts @@ -67,11 +67,12 @@ import { ProjectCopy } from "@opencode-ai/core/project/copy" // kilocode_change import { MoveSession } from "@opencode-ai/core/control-plane/move-session" // kilocode_change import { PtyTicket } from "@opencode-ai/core/pty/ticket" // kilocode_change import { Pty } from "@opencode-ai/core/pty" // kilocode_change -import { LocationServiceMap } from "@opencode-ai/core/location-services" // kilocode_change +import { buildLocationServiceMap, LocationServiceMap } from "@opencode-ai/core/location-services" // kilocode_change // kilocode_change start - retain Kilo runtime services in the upstream node graph const memory = LayerNode.make({ service: MemoryService.Service, layer: MemoryService.layer, deps: [] }) const kilo = LayerNode.group([Credential.node, ModelCache.node, AgentManager.node, Notebook.node, memory]) +const locationServiceMap = buildLocationServiceMap() // kilocode_change - bind fallback consumers to one process-wide map // kilocode_change end export const AppLayer = AppNodeBuilderV1.build( @@ -136,6 +137,7 @@ export const AppLayer = AppNodeBuilderV1.build( LocationServiceMap.node, // kilocode_change - expose the process-wide location cache to listeners // kilocode_change end ]), + [[LocationServiceMap.node, locationServiceMap]], ).pipe(Layer.provideMerge(AppNodeBuilderV1.build(Ripgrep.node)), Layer.provideMerge(Observability.layer)) const rt = ManagedRuntime.make(AppLayer, { memoMap }) diff --git a/packages/opencode/src/session/system.ts b/packages/opencode/src/session/system.ts index a01f5fde9a..fedf6a0bde 100644 --- a/packages/opencode/src/session/system.ts +++ b/packages/opencode/src/session/system.ts @@ -21,7 +21,7 @@ import { Permission } from "@/permission" import { Skill } from "@/skill" import { AbsolutePath } from "@opencode-ai/core/schema" import { Location } from "@opencode-ai/core/location" -import { LocationServiceMap } from "@opencode-ai/core/location-services" // kilocode_change - use the runtime's shared map +import { LocationServiceMap, locationServiceMapLayer } from "@opencode-ai/core/location-services" import { Reference } from "@opencode-ai/core/reference" import { MCP } from "@/mcp" import { PermissionV1 } from "@opencode-ai/core/v1/permission" @@ -184,10 +184,16 @@ const layer = Layer.effect( }), ) +const locationServiceMapNode = LayerNode.make({ + service: LocationServiceMap.Service, + layer: locationServiceMapLayer, + deps: [], +}) + export const node = LayerNode.make({ service: Service, layer: layer, - deps: [Skill.node, MCP.node, Config.node, LocationServiceMap.node], // kilocode_change + deps: [Skill.node, MCP.node, Config.node, locationServiceMapNode], // kilocode_change }) export * as SystemPrompt from "./system" diff --git a/packages/opencode/test/kilocode/shared-location-map.test.ts b/packages/opencode/test/kilocode/shared-location-map.test.ts index 677103e7c9..c8f9b8c7ac 100644 --- a/packages/opencode/test/kilocode/shared-location-map.test.ts +++ b/packages/opencode/test/kilocode/shared-location-map.test.ts @@ -13,8 +13,6 @@ describe("shared location service map", () => { "server/routes/instance/httpapi/handlers/file.ts", "server/routes/instance/httpapi/handlers/pty.ts", "kilocode/server/reference-reconciler.ts", - "agent/agent.ts", - "session/system.ts", ] for (const file of files) { From 5bc81ece1d77230760e8ad9f26eb663f816b68c6 Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Mon, 24 Aug 2026 18:14:45 +0200 Subject: [PATCH 05/17] fix(cli): annotate shared location map binding --- packages/opencode/src/effect/app-runtime.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/opencode/src/effect/app-runtime.ts b/packages/opencode/src/effect/app-runtime.ts index 1536be8553..bac49f4a63 100644 --- a/packages/opencode/src/effect/app-runtime.ts +++ b/packages/opencode/src/effect/app-runtime.ts @@ -137,7 +137,7 @@ export const AppLayer = AppNodeBuilderV1.build( LocationServiceMap.node, // kilocode_change - expose the process-wide location cache to listeners // kilocode_change end ]), - [[LocationServiceMap.node, locationServiceMap]], + [[LocationServiceMap.node, locationServiceMap]], // kilocode_change ).pipe(Layer.provideMerge(AppNodeBuilderV1.build(Ripgrep.node)), Layer.provideMerge(Observability.layer)) const rt = ManagedRuntime.make(AppLayer, { memoMap }) From 28b16ea719572f3bcd5611a986c239f033b25327 Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Tue, 25 Aug 2026 10:40:58 +0200 Subject: [PATCH 06/17] test(cli): make editor context path assertion portable --- .../opencode/test/kilocode/editor-context-injection.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/opencode/test/kilocode/editor-context-injection.test.ts b/packages/opencode/test/kilocode/editor-context-injection.test.ts index 3e49a1713a..42ec441f5b 100644 --- a/packages/opencode/test/kilocode/editor-context-injection.test.ts +++ b/packages/opencode/test/kilocode/editor-context-injection.test.ts @@ -1,4 +1,5 @@ import { describe, expect, test } from "bun:test" +import path from "node:path" import { ModelV2 } from "@opencode-ai/core/model" import { ProviderV2 } from "@opencode-ai/core/provider" import type { Provider } from "../../src/provider/provider" @@ -115,7 +116,7 @@ describe("injectEditorContext", () => { expect(blocks(turn2[2])[0].text).toContain("Active file: src/two.ts") expect(blocks(turn2[0])[0].text).toContain("Message time: 2026-08-24T") expect(blocks(turn2[0])[0].text).toContain("Working directory: /repo/session") - expect(blocks(turn2[0])[0].text).toContain("Workspace root folder: /repo") + expect(blocks(turn2[0])[0].text).toContain(`Workspace root folder: ${path.resolve(session.directory, "..")}`) expect(blocks(turn2[2])[0].text).toContain("Working directory: /repo/next") }) From 955b2c578cc034e95f5ab25fc9f8b9a95c139c85 Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Tue, 25 Aug 2026 11:13:47 +0200 Subject: [PATCH 07/17] fix(cli): stabilize editor context history --- .../opencode/src/kilocode/editor-context.ts | 9 +-------- .../opencode/src/kilocode/session/prompt.ts | 1 - packages/opencode/src/session/prompt.ts | 18 ++++++++++-------- .../kilocode/editor-context-injection.test.ts | 4 +--- .../test/kilocode/system-prompt.test.ts | 4 ++-- 5 files changed, 14 insertions(+), 22 deletions(-) diff --git a/packages/opencode/src/kilocode/editor-context.ts b/packages/opencode/src/kilocode/editor-context.ts index 605fba20f8..47575e3ff9 100644 --- a/packages/opencode/src/kilocode/editor-context.ts +++ b/packages/opencode/src/kilocode/editor-context.ts @@ -30,14 +30,7 @@ export function staticEnvLines(ctx?: EditorContext): string[] { * Always includes at least the supplied message timestamp. */ function timestamp(now: Date): string { - const offset = -now.getTimezoneOffset() - const sign = offset >= 0 ? "+" : "-" - const h = Math.floor(Math.abs(offset) / 60) - .toString() - .padStart(2, "0") - const m = (Math.abs(offset) % 60).toString().padStart(2, "0") - const pad = (n: number) => n.toString().padStart(2, "0") - return `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}T${pad(now.getHours())}:${pad(now.getMinutes())}:${pad(now.getSeconds())}${sign}${h}:${m}` + return now.toISOString().replace(".000Z", "Z") } export function environmentDetails(ctx?: EditorContext, now = new Date()): string { diff --git a/packages/opencode/src/kilocode/session/prompt.ts b/packages/opencode/src/kilocode/session/prompt.ts index 3ceb264672..9b088be76b 100644 --- a/packages/opencode/src/kilocode/session/prompt.ts +++ b/packages/opencode/src/kilocode/session/prompt.ts @@ -362,7 +362,6 @@ export namespace KiloSessionPrompt { */ export function injectEditorContext(input: { msgs: MessageV2.WithParts[] - lastUser: MessageV2.User session: Pick sessionID: SessionID cache: EnvCache diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 91fabe53b5..9c9ceb4106 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -138,10 +138,14 @@ function isOrphanedInterruptedTool(part: SessionV1.ToolPart) { export interface Interface { readonly cancel: (sessionID: SessionID) => Effect.Effect - readonly prompt: (input: PromptInput) => Effect.Effect + readonly prompt: ( + input: PromptInput, + ) => Effect.Effect readonly loop: (input: LoopInput) => Effect.Effect readonly shell: (input: ShellInput) => Effect.Effect - readonly command: (input: CommandInput) => Effect.Effect + readonly command: ( + input: CommandInput, + ) => Effect.Effect readonly resolvePromptParts: (template: string) => Effect.Effect } @@ -833,8 +837,6 @@ export const layer = Layer.effect( (ag.variant && full?.variants?.[ag.variant] ? ag.variant : undefined) // kilocode_change end - // kilocode_change start - freeze the route used by dynamic editor context and reuse it for references - const ctx = yield* InstanceState.context const info: SessionV1.User = { id: input.messageID ?? MessageID.ascending(), role: "user", @@ -849,7 +851,7 @@ export const layer = Layer.effect( }, system: input.system, format: input.format, - editorContext: { ...input.editorContext, directory: ctx.directory, worktree: ctx.worktree }, + editorContext: input.editorContext, // kilocode_change } const current = yield* sessions.get(input.sessionID).pipe(Effect.orDie) @@ -879,12 +881,12 @@ export const layer = Layer.effect( id: part.id ? PartID.make(part.id) : PartID.ascending(), }) + const ctx = yield* InstanceState.context // kilocode_change - resolve V1 reference roots for attachment authorization const references = KiloConfiguredReference.resolveAll({ references: (yield* config.get()).reference ?? {}, directory: ctx.directory, worktree: ctx.worktree, }).filter((item) => item.kind !== "invalid") - // kilocode_change end const referenceContextFromFilePart = Effect.fnUntraced(function* ( part: Extract, @@ -1716,7 +1718,7 @@ export const layer = Layer.effect( // kilocode_change start — ephemeral context injection + post-summary // media strip (keeps outgoing body under the gateway body-size limit // even when filterCompacted couldn't trim the pre-summary history). - KiloSessionPrompt.injectEditorContext({ msgs, lastUser, session, sessionID, cache: envCache }) + KiloSessionPrompt.injectEditorContext({ msgs, session, sessionID, cache: envCache }) msgs = KiloSessionPrompt.maybeStripHistoricalMedia(msgs) // kilocode_change end @@ -1740,7 +1742,7 @@ export const layer = Layer.effect( msgs = KiloSessionPromptQueue.scope(sessionID, msgs) msgs = KiloSessionPrompt.trimBeforeLastSummary(msgs) yield* plugin.trigger("experimental.chat.messages.transform", {}, { messages: msgs }) - KiloSessionPrompt.injectEditorContext({ msgs, lastUser, session, sessionID, cache: envCache }) + KiloSessionPrompt.injectEditorContext({ msgs, session, sessionID, cache: envCache }) msgs = KiloSessionPrompt.maybeStripHistoricalMedia(msgs) modelMsgs = yield* MessageV2.toModelMessagesEffect(msgs, model).pipe( Effect.provideService(Database.Service, database), diff --git a/packages/opencode/test/kilocode/editor-context-injection.test.ts b/packages/opencode/test/kilocode/editor-context-injection.test.ts index 42ec441f5b..1e1f75f73a 100644 --- a/packages/opencode/test/kilocode/editor-context-injection.test.ts +++ b/packages/opencode/test/kilocode/editor-context-injection.test.ts @@ -79,9 +79,7 @@ function assistant(parentID: MessageID, text: string) { } function inject(msgs: MessageV2.WithParts[], cache: KiloSessionPrompt.EnvCache = {}) { - const last = msgs.findLast((msg) => msg.info.role === "user") - if (!last || last.info.role !== "user") throw new Error("missing user message") - KiloSessionPrompt.injectEditorContext({ msgs, lastUser: last.info, session, sessionID, cache }) + KiloSessionPrompt.injectEditorContext({ msgs, session, sessionID, cache }) } function blocks(msg: MessageV2.WithParts) { diff --git a/packages/opencode/test/kilocode/system-prompt.test.ts b/packages/opencode/test/kilocode/system-prompt.test.ts index b82b2265fe..e1ca1a0377 100644 --- a/packages/opencode/test/kilocode/system-prompt.test.ts +++ b/packages/opencode/test/kilocode/system-prompt.test.ts @@ -143,8 +143,8 @@ describe("environmentDetails", () => { }) test("formats the supplied message time", () => { - const result = environmentDetails({}, new Date(2026, 7, 24, 12, 34, 56)) + const result = environmentDetails({}, new Date("2026-08-24T12:34:56Z")) - expect(result).toContain("Message time: 2026-08-24T12:34:56") + expect(result).toContain("Message time: 2026-08-24T12:34:56Z") }) }) From c92cac8a8792465d6e2c682ce9402240f9e5dfe0 Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Tue, 25 Aug 2026 11:35:57 +0200 Subject: [PATCH 08/17] fix(cli): keep location map scoped to listener --- packages/opencode/src/effect/app-runtime.ts | 4 ---- .../src/server/routes/instance/httpapi/server.ts | 13 +++++++------ .../test/kilocode/shared-location-map.test.ts | 13 +++++-------- 3 files changed, 12 insertions(+), 18 deletions(-) diff --git a/packages/opencode/src/effect/app-runtime.ts b/packages/opencode/src/effect/app-runtime.ts index bac49f4a63..eb61a88fea 100644 --- a/packages/opencode/src/effect/app-runtime.ts +++ b/packages/opencode/src/effect/app-runtime.ts @@ -67,12 +67,10 @@ import { ProjectCopy } from "@opencode-ai/core/project/copy" // kilocode_change import { MoveSession } from "@opencode-ai/core/control-plane/move-session" // kilocode_change import { PtyTicket } from "@opencode-ai/core/pty/ticket" // kilocode_change import { Pty } from "@opencode-ai/core/pty" // kilocode_change -import { buildLocationServiceMap, LocationServiceMap } from "@opencode-ai/core/location-services" // kilocode_change // kilocode_change start - retain Kilo runtime services in the upstream node graph const memory = LayerNode.make({ service: MemoryService.Service, layer: MemoryService.layer, deps: [] }) const kilo = LayerNode.group([Credential.node, ModelCache.node, AgentManager.node, Notebook.node, memory]) -const locationServiceMap = buildLocationServiceMap() // kilocode_change - bind fallback consumers to one process-wide map // kilocode_change end export const AppLayer = AppNodeBuilderV1.build( @@ -134,10 +132,8 @@ export const AppLayer = AppNodeBuilderV1.build( MoveSession.node, PtyTicket.node, Pty.shutdownNode, // kilocode_change - LocationServiceMap.node, // kilocode_change - expose the process-wide location cache to listeners // kilocode_change end ]), - [[LocationServiceMap.node, locationServiceMap]], // kilocode_change ).pipe(Layer.provideMerge(AppNodeBuilderV1.build(Ripgrep.node)), Layer.provideMerge(Observability.layer)) const rt = ManagedRuntime.make(AppLayer, { memoMap }) diff --git a/packages/opencode/src/server/routes/instance/httpapi/server.ts b/packages/opencode/src/server/routes/instance/httpapi/server.ts index 9f112cbcd4..dc35b2ddbf 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/server.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/server.ts @@ -337,7 +337,7 @@ export function createRoutes( ), Layer.provide(locationServiceMapV2), - Layer.provide(AppNodeBuilderV1.build(app, [[LocationServiceMap.node, locationServiceMapV2]])), // kilocode_change + Layer.provide(AppNodeBuilderV1.build(app)), // Must stay last: layers provided later in this pipe build beneath earlier ones, // so Observability must come after every service graph. Otherwise eagerly forked // fibers (e.g. the ModelsDev background refresh) capture Effect's default stdout @@ -348,9 +348,7 @@ export function createRoutes( // kilocode_change start - keep listener routes local while application services come from AppRuntime export function createListenerRoutes(corsOptions?: CorsOptions) { - // Re-export AppRuntime's process-wide map through listener-local graphs instead of - // materializing another full catalog and native file index for every worktree. - const locationServiceMapV2 = Layer.effect(LocationServiceMap.Service, LocationServiceMap.Service) + const locationServiceMapV2 = buildLocationServiceMap() return Layer.mergeAll( rootApiRoutes, @@ -366,8 +364,11 @@ export function createListenerRoutes(corsOptions?: CorsOptions) { // satisfied when the layer is built, not at request time, so the listener needs the same chain // createRoutes uses. // - // SessionV2 remains listener-local because it uses SessionExecutionLocal. Its location map is - // inherited from AppRuntime so every server graph shares one cache. + // These builds sit inside KiloListener's Layer.fresh boundary, so each one self-provides its own + // dependency subtree rather than resolving AppRuntime's. That is deliberate: SessionV2 is bound + // to this listener's LocationServiceMap and to SessionExecutionLocal, so it cannot be the + // process-wide instance. Everything the graph does not rebind (the nodes listed in AppLayer) + // still comes from AppRuntime, and the scope teardown releases the rest. Layer.provide(sessionLocationLayer), Layer.provide(locationLayer), Layer.provide(PtyEnvironment.layer), diff --git a/packages/opencode/test/kilocode/shared-location-map.test.ts b/packages/opencode/test/kilocode/shared-location-map.test.ts index c8f9b8c7ac..84e363d2a1 100644 --- a/packages/opencode/test/kilocode/shared-location-map.test.ts +++ b/packages/opencode/test/kilocode/shared-location-map.test.ts @@ -20,16 +20,13 @@ describe("shared location service map", () => { } }) - test("server app graph receives the listener location map", () => { + test("listener owns its location map scope", () => { expect(source("server/routes/instance/httpapi/server.ts")).toContain( + "const locationServiceMapV2 = buildLocationServiceMap()", + ) + expect(source("server/routes/instance/httpapi/server.ts")).not.toContain( "AppNodeBuilderV1.build(app, [[LocationServiceMap.node, locationServiceMapV2]])", ) - }) - - test("listener inherits the process-wide location map", () => { - expect(source("server/routes/instance/httpapi/server.ts")).toContain( - "Layer.effect(LocationServiceMap.Service, LocationServiceMap.Service)", - ) - expect(source("effect/app-runtime.ts")).toContain("LocationServiceMap.node") + expect(source("effect/app-runtime.ts")).not.toContain("LocationServiceMap.node") }) }) From 3503136b1e72ca8404966a762c4b28badbf62a4a Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Tue, 25 Aug 2026 12:31:50 +0200 Subject: [PATCH 09/17] test(cli): cover fractional message timestamps --- packages/opencode/src/kilocode/editor-context.ts | 2 +- packages/opencode/test/kilocode/system-prompt.test.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/opencode/src/kilocode/editor-context.ts b/packages/opencode/src/kilocode/editor-context.ts index 47575e3ff9..4350bddd4f 100644 --- a/packages/opencode/src/kilocode/editor-context.ts +++ b/packages/opencode/src/kilocode/editor-context.ts @@ -30,7 +30,7 @@ export function staticEnvLines(ctx?: EditorContext): string[] { * Always includes at least the supplied message timestamp. */ function timestamp(now: Date): string { - return now.toISOString().replace(".000Z", "Z") + return now.toISOString().replace(/\.\d+Z$/, "Z") } export function environmentDetails(ctx?: EditorContext, now = new Date()): string { diff --git a/packages/opencode/test/kilocode/system-prompt.test.ts b/packages/opencode/test/kilocode/system-prompt.test.ts index e1ca1a0377..81db8e3f98 100644 --- a/packages/opencode/test/kilocode/system-prompt.test.ts +++ b/packages/opencode/test/kilocode/system-prompt.test.ts @@ -143,7 +143,7 @@ describe("environmentDetails", () => { }) test("formats the supplied message time", () => { - const result = environmentDetails({}, new Date("2026-08-24T12:34:56Z")) + const result = environmentDetails({}, new Date("2026-08-24T12:34:56.123Z")) expect(result).toContain("Message time: 2026-08-24T12:34:56Z") }) From b1f1dec04e2cf49f85c8745f0b480c9f53a4f3ff Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Tue, 25 Aug 2026 14:40:06 +0200 Subject: [PATCH 10/17] fix(agent-manager): use valid terminal close code --- .changeset/terminal-replay-close-code.md | 5 +++++ .../tests/unit/agent-manager-terminal-layout.test.ts | 5 +++++ .../webview-ui/agent-manager/terminal/TerminalTab.tsx | 4 ++-- 3 files changed, 12 insertions(+), 2 deletions(-) create mode 100644 .changeset/terminal-replay-close-code.md diff --git a/.changeset/terminal-replay-close-code.md b/.changeset/terminal-replay-close-code.md new file mode 100644 index 0000000000..2be3e869de --- /dev/null +++ b/.changeset/terminal-replay-close-code.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Use a browser-valid close code when Agent Manager terminal replay exceeds its buffer limit diff --git a/packages/kilo-vscode/tests/unit/agent-manager-terminal-layout.test.ts b/packages/kilo-vscode/tests/unit/agent-manager-terminal-layout.test.ts index bbf0eee69e..9f9220edf5 100644 --- a/packages/kilo-vscode/tests/unit/agent-manager-terminal-layout.test.ts +++ b/packages/kilo-vscode/tests/unit/agent-manager-terminal-layout.test.ts @@ -85,6 +85,11 @@ test("orders local terminal status lines through the output batcher", () => { expect(terminal).not.toContain("term.writeln(") }) +test("uses a browser-valid close code when replay overflows", () => { + expect(terminal).not.toContain("close(1009,") + expect(terminal).toContain('close(4009, "terminal replay exceeded limit")') +}) + test("keeps raw PTY line endings and initializes Unicode widths before attaching", () => { expect(terminal).toContain("convertEol: false") expect(terminal).toContain('term.unicode.activeVersion = "15-graphemes"') diff --git a/packages/kilo-vscode/webview-ui/agent-manager/terminal/TerminalTab.tsx b/packages/kilo-vscode/webview-ui/agent-manager/terminal/TerminalTab.tsx index 54ef668998..a58e421de6 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/terminal/TerminalTab.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/terminal/TerminalTab.tsx @@ -334,7 +334,7 @@ export const TerminalTab: Component = (props) => { if (typeof event.data === "string") { if (!replay.output(event.data)) { input.clear() - next.close(1009, "terminal replay exceeded limit") + next.close(4009, "terminal replay exceeded limit") return } scheduleFlush() @@ -345,7 +345,7 @@ export const TerminalTab: Component = (props) => { if (replay.frame(bytes)) return if (!replay.output(bytes)) { input.clear() - next.close(1009, "terminal replay exceeded limit") + next.close(4009, "terminal replay exceeded limit") return } scheduleFlush() From 45695e6190687a8d8cde17c09263799f7b793747 Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Tue, 25 Aug 2026 14:41:38 +0200 Subject: [PATCH 11/17] fix(vscode): bound sync filter state --- .changeset/fix-sync-filter-lifecycle.md | 5 + .../cli-backend/connection-service.ts | 4 +- .../services/cli-backend/connection-utils.ts | 3 +- .../tests/unit/connection-utils.test.ts | 135 +++++++++++++++++- 4 files changed, 142 insertions(+), 5 deletions(-) create mode 100644 .changeset/fix-sync-filter-lifecycle.md diff --git a/.changeset/fix-sync-filter-lifecycle.md b/.changeset/fix-sync-filter-lifecycle.md new file mode 100644 index 0000000000..47964a30b1 --- /dev/null +++ b/.changeset/fix-sync-filter-lifecycle.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Prevent duplicate-event tracking from suppressing delayed sync events after reconnects or high event bursts. diff --git a/packages/kilo-vscode/src/services/cli-backend/connection-service.ts b/packages/kilo-vscode/src/services/cli-backend/connection-service.ts index 1b0e8679d3..a40ffb60d8 100644 --- a/packages/kilo-vscode/src/services/cli-backend/connection-service.ts +++ b/packages/kilo-vscode/src/services/cli-backend/connection-service.ts @@ -96,7 +96,6 @@ export class KiloConnectionService { private remoteService: import("../RemoteStatusService").RemoteStatusService | null = null private readonly eventListeners: Set = new Set() - private readonly duplicateEvent = createDuplicateEventFilter() private readonly stateListeners: Set = new Set() private readonly notificationDismissListeners: Set = new Set() private readonly languageChangeListeners: Set = new Set() @@ -821,6 +820,7 @@ export class KiloConnectionService { }, }) const sse = new SdkSSEAdapter(client) + const duplicateEvent = createDuplicateEventFilter() this.client = client this.sseClient = sse @@ -839,7 +839,7 @@ export class KiloConnectionService { sse.onEvent((event, directory) => { if (this.sseClient !== sse) return // EventV2Bridge also emits these durable compatibility envelopes after their normal live events. - if (this.duplicateEvent(event)) return + if (duplicateEvent(event)) return this.handlePermissionEvent(event, directory) this.handleQuestionEvent(event, directory) for (const listener of this.eventListeners) { diff --git a/packages/kilo-vscode/src/services/cli-backend/connection-utils.ts b/packages/kilo-vscode/src/services/cli-backend/connection-utils.ts index 271eec4114..97ea3a568f 100644 --- a/packages/kilo-vscode/src/services/cli-backend/connection-utils.ts +++ b/packages/kilo-vscode/src/services/cli-backend/connection-utils.ts @@ -24,8 +24,7 @@ export function createDuplicateEventFilter() { } if (duplicateLiveEvents.has(event.type)) { - seen.add(event.id) - if (seen.size > DUPLICATE_EVENT_LIMIT) seen.delete(seen.values().next().value!) + if (seen.size < DUPLICATE_EVENT_LIMIT) seen.add(event.id) } return false } diff --git a/packages/kilo-vscode/tests/unit/connection-utils.test.ts b/packages/kilo-vscode/tests/unit/connection-utils.test.ts index c1865616e7..cde5796e8b 100644 --- a/packages/kilo-vscode/tests/unit/connection-utils.test.ts +++ b/packages/kilo-vscode/tests/unit/connection-utils.test.ts @@ -173,7 +173,7 @@ describe("resolveEventSessionId", () => { }) }) -describe("isDuplicateSyncEvent", () => { +describe("createDuplicateEventFilter", () => { it("drops a compatibility envelope only after its live event", () => { const filter = createDuplicateEventFilter() const live = { @@ -233,4 +233,137 @@ describe("isDuplicateSyncEvent", () => { ), ).toBe(false) }) + + it("does not evict pending live events when the cap is reached", () => { + const filter = createDuplicateEventFilter() + for (let index = 0; index < 1024; index++) { + expect( + filter({ + id: `live-${index}`, + type: "message.part.updated", + properties: { sessionID: "s6", part, delta: "x" }, + }), + ).toBe(false) + } + + expect( + filter( + sync({ + type: "sync", + name: "message.part.updated.1", + id: "live-0", + seq: 8, + aggregateID: "s6", + data: { sessionID: "s6", part, time: 0 }, + }), + ), + ).toBe(true) + expect( + filter({ + id: "live-1024", + type: "message.part.updated", + properties: { sessionID: "s6", part, delta: "x" }, + }), + ).toBe(false) + expect( + filter( + sync({ + type: "sync", + name: "message.part.updated.1", + id: "live-1024", + seq: 9, + aggregateID: "s6", + data: { sessionID: "s6", part, time: 0 }, + }), + ), + ).toBe(true) + }) + + it("passes overflow events through without evicting pending IDs", () => { + const filter = createDuplicateEventFilter() + for (let index = 0; index < 1024; index++) { + expect( + filter({ + id: `pending-${index}`, + type: "message.part.updated", + properties: { sessionID: "s6", part, delta: "x" }, + }), + ).toBe(false) + } + + expect( + filter({ + id: "overflow", + type: "message.part.updated", + properties: { sessionID: "s6", part, delta: "x" }, + }), + ).toBe(false) + expect( + filter( + sync({ + type: "sync", + name: "message.part.updated.1", + id: "overflow", + seq: 8, + aggregateID: "s6", + data: { sessionID: "s6", part, time: 0 }, + }), + ), + ).toBe(false) + expect( + filter( + sync({ + type: "sync", + name: "message.part.updated.1", + id: "pending-0", + seq: 9, + aggregateID: "s6", + data: { sessionID: "s6", part, time: 0 }, + }), + ), + ).toBe(true) + expect( + filter({ + id: "after-free", + type: "message.part.updated", + properties: { sessionID: "s6", part, delta: "x" }, + }), + ).toBe(false) + expect( + filter( + sync({ + type: "sync", + name: "message.part.updated.1", + id: "after-free", + seq: 10, + aggregateID: "s6", + data: { sessionID: "s6", part, time: 0 }, + }), + ), + ).toBe(true) + }) + + it("does not carry duplicate IDs between connections", () => { + const first = createDuplicateEventFilter() + const second = createDuplicateEventFilter() + const live = { + id: "connection-event", + type: "message.part.updated", + properties: { sessionID: "s6", part, delta: "x" }, + } satisfies Payload + + expect(first(live)).toBe(false) + expect( + second( + sync({ + type: "sync", + name: "message.part.updated.1", + id: "connection-event", + seq: 11, + aggregateID: "s6", + data: { sessionID: "s6", part, time: 0 }, + }), + ), + ).toBe(false) + }) }) From 78692a7f2a06d6b30e1b75385888fdb2f823a26a Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Tue, 25 Aug 2026 14:46:33 +0200 Subject: [PATCH 12/17] fix(agent-manager): allow explicit provider selection --- .changeset/explicit-agent-manager-provider.md | 6 +++ .../kilo-docs/pages/automate/agent-manager.md | 2 +- .../src/kilocode/tool/agent-manager-models.ts | 2 +- .../kilocode/tool/agent-manager-models.txt | 4 +- .../src/kilocode/tool/agent-manager.ts | 29 +++++++++-- .../src/kilocode/tool/agent-manager.txt | 2 +- .../test/kilocode/agent-manager-tool.test.ts | 51 +++++++++++++++++++ 7 files changed, 86 insertions(+), 10 deletions(-) create mode 100644 .changeset/explicit-agent-manager-provider.md diff --git a/.changeset/explicit-agent-manager-provider.md b/.changeset/explicit-agent-manager-provider.md new file mode 100644 index 0000000000..31ed86c6ab --- /dev/null +++ b/.changeset/explicit-agent-manager-provider.md @@ -0,0 +1,6 @@ +--- +"@kilocode/cli": patch +"kilo-code": patch +--- + +Allow Agent Manager task model overrides to specify an explicit provider when resolving model names. diff --git a/packages/kilo-docs/pages/automate/agent-manager.md b/packages/kilo-docs/pages/automate/agent-manager.md index 70a4dc10db..8c4fa2c233 100644 --- a/packages/kilo-docs/pages/automate/agent-manager.md +++ b/packages/kilo-docs/pages/automate/agent-manager.md @@ -214,7 +214,7 @@ The tool supports two modes: | `worktree` | Creates one Agent Manager git worktree and session per task | | `local` | Creates Agent Manager sessions in the current workspace without git worktree isolation | -Each request can include 1-20 tasks. Each task must include at least one of `prompt`, `name`, or `branchName`. Prompted tasks inherit the model and reasoning variant used by the chat turn that starts them. A task can override that selection with a `model` (by name, e.g. `Claude Opus 4.1`) when you explicitly request a different model, or with one of the current model's reasoning `variant` values when you request a different variant. Agent Manager resolves the provider for a model override, preferring the provider used by the current turn and falling back to the Kilo Gateway; a qualified `provider/model` ID is also accepted to force a specific provider. Prepared sessions without an initial prompt use the normal model defaults. Use `versions: true` only when the tasks are alternate versions of the same work to compare; otherwise, multiple tasks start as independent sessions. +Each request can include 1-20 tasks. Each task must include at least one of `prompt`, `name`, or `branchName`. Prompted tasks inherit the model and reasoning variant used by the chat turn that starts them. A task can override that selection with a `model` (by name, e.g. `Claude Opus 4.1`) when you explicitly request a different model, or with one of the current model's reasoning `variant` values when you request a different variant. Add `provider` beside `model` to force a model-name match to one of the listed provider IDs. Agent Manager resolves the provider for a model override when `provider` is omitted, preferring the provider used by the current turn and falling back to the Kilo Gateway; a qualified `provider/model` ID is also accepted. Prepared sessions without an initial prompt use the normal model defaults. Use `versions: true` only when the tasks are alternate versions of the same work to compare; otherwise, multiple tasks start as independent sessions. The companion `agent_manager_models` tool searches models and their supported reasoning variants on demand. Results are grouped by model name (with the offering providers listed for reference) and limited to 20 per call, so the full catalog is never added to the conversation context. diff --git a/packages/opencode/src/kilocode/tool/agent-manager-models.ts b/packages/opencode/src/kilocode/tool/agent-manager-models.ts index 613489a1c8..d70d697db1 100644 --- a/packages/opencode/src/kilocode/tool/agent-manager-models.ts +++ b/packages/opencode/src/kilocode/tool/agent-manager-models.ts @@ -89,7 +89,7 @@ export const AgentManagerModelsTool = Tool.define< offset, total: matches.length, nextOffset, - hint: "Pass a model name (or one of its providers/IDs) as the agent_manager task `model`. Agent Manager picks the provider, preferring the one used by the current turn.", + hint: "Pass a model name (or one of its providers/IDs) as the agent_manager task `model`. Add the task `provider` to force one of the listed providers; otherwise Agent Manager prefers the provider used by the current turn.", }), metadata: { count: models.length, total: matches.length }, } diff --git a/packages/opencode/src/kilocode/tool/agent-manager-models.txt b/packages/opencode/src/kilocode/tool/agent-manager-models.txt index 8c73797bde..8571d6045d 100644 --- a/packages/opencode/src/kilocode/tool/agent-manager-models.txt +++ b/packages/opencode/src/kilocode/tool/agent-manager-models.txt @@ -1,5 +1,5 @@ Search the models available to Agent Manager sessions and inspect their reasoning variants. -Use this tool before `agent_manager` when you need to pick a model or reasoning effort. Results are grouped by model, not by provider, because you select a model and Agent Manager chooses the provider for you. With no arguments it returns the top available models (capped at 20); pass `query` to search by model name or ID, and `offset` to page further. The query is matched leniently: it is case-insensitive, ignores spacing and punctuation, and is order-independent, so `opus claude`, `glm5.2`, and `gpt5` all work. You do not need the exact model name. +Use this tool before `agent_manager` when you need to pick a model or reasoning effort. Results are grouped by model, not by provider, and list every provider that offers each model so you can constrain the provider when needed. With no arguments it returns the top available models (capped at 20); pass `query` to search by model name or ID, and `offset` to page further. The query is matched leniently: it is case-insensitive, ignores spacing and punctuation, and is order-independent, so `opus claude`, `glm5.2`, and `gpt5` all work. You do not need the exact model name. -Each result includes the model name, its reasoning variant names, and the providers that offer it (informational only). Pass the model name back as the `agent_manager` task `model`. Agent Manager resolves the provider automatically, preferring the provider used by the current turn and falling back to the Kilo Gateway, so you do not need to choose a provider yourself. +Each result includes the model name, its reasoning variant names, and the providers that offer it. Pass the model name back as the `agent_manager` task `model`; pass one of the listed provider IDs as the task `provider` when the provider must be explicit. When `provider` is omitted, Agent Manager resolves it automatically, preferring the one used by the current turn and falling back to the Kilo Gateway. diff --git a/packages/opencode/src/kilocode/tool/agent-manager.ts b/packages/opencode/src/kilocode/tool/agent-manager.ts index 8166f8302e..a1b098d0ff 100644 --- a/packages/opencode/src/kilocode/tool/agent-manager.ts +++ b/packages/opencode/src/kilocode/tool/agent-manager.ts @@ -28,6 +28,10 @@ const Task = Schema.Struct({ description: "Optional model override from agent_manager_models (e.g. 'Claude Opus 4.1'). Omit unless the user requests a different model. Agent Manager otherwise inherits the current turn's model. A qualified provider/model ID is also accepted to force a specific provider.", }), + provider: Schema.optional(Schema.NullOr(Schema.String)).annotate({ + description: + "Optional provider ID to constrain model resolution (e.g. 'anthropic'). Use with model to select a model from a specific provider; omit to use the current-turn provider preference.", + }), variant: Schema.optional(Schema.NullOr(Schema.String)).annotate({ description: "Optional reasoning variant override from agent_manager_models. Specify it without model to override the inherited model's variant. Omit both to inherit the current turn's selection.", @@ -41,6 +45,9 @@ const Task = Schema.Struct({ Schema.makeFilter((task) => task.model?.trim() && !task.prompt?.trim() ? "A task model requires an initial prompt" : undefined, ), + Schema.makeFilter((task) => + task.provider?.trim() && !task.model?.trim() ? "A task provider requires a model" : undefined, + ), Schema.makeFilter((task) => task.variant?.trim() && !task.prompt?.trim() ? "A task variant requires an initial prompt" : undefined, ), @@ -245,7 +252,9 @@ function select( ...(task.branchName != null ? { branchName: task.branchName } : {}), } const value = task.model?.trim() + const provider = task.provider?.trim() const variant = task.variant?.trim() + if (provider && !value) return { error: `Task ${index + 1} provider requires a model.` } if (!value) { if (!variant) { if (!task.prompt?.trim() || !source) return { task: base } @@ -271,12 +280,21 @@ function select( return { task: { ...base, model: source.model, variant } } } - const { pool, names } = lookup(all, value) + const scope = provider ? all.filter((item) => item.providerID === provider) : all + if (provider && scope.length === 0) { + return { + error: `Task ${index + 1} provider is not available for model selection: ${provider}. Requested model: ${value}.`, + } + } + + const { pool, names } = lookup(scope, value) if (pool.length === 0) { - const close = suggest(all, value) + const close = suggest(scope, value) const hint = close.length ? ` Closest matches: ${close.join(", ")}.` : "" return { - error: `Task ${index + 1} model is not available: ${value}.${hint} Use agent_manager_models to search models.`, + error: provider + ? `Task ${index + 1} model is not available from provider "${provider}": ${value}.${hint} Use agent_manager_models to search models.` + : `Task ${index + 1} model is not available: ${value}.${hint} Use agent_manager_models to search models.`, } } if (names.length > 1) { @@ -479,8 +497,9 @@ export const AgentManagerTool = Tool.define< ...(msg.model.variant ? { variant: msg.model.variant } : {}), } : undefined - const need = params.tasks.some((task) => task.model?.trim() || task.variant?.trim()) - const all = need ? candidates(yield* provider.list()) : [] + const need = params.tasks.some((task) => task.model?.trim() || task.provider?.trim() || task.variant?.trim()) + const providers = need ? yield* provider.list() : undefined + const all = providers ? candidates(providers) : [] const preferred = need ? (source?.model.providerID ?? (yield* provider.defaultModel().pipe( diff --git a/packages/opencode/src/kilocode/tool/agent-manager.txt b/packages/opencode/src/kilocode/tool/agent-manager.txt index 1d081b91df..4dbb410b84 100644 --- a/packages/opencode/src/kilocode/tool/agent-manager.txt +++ b/packages/opencode/src/kilocode/tool/agent-manager.txt @@ -14,7 +14,7 @@ Modes: - `worktree`: creates a new Agent Manager git worktree for each task, like the New Worktree dialog. - `local`: creates Agent Manager sessions in the current workspace directory without git worktree isolation. -Each task may provide a prompt, a short display name, a branch name, a `model`, and a model-specific reasoning `variant`. By default, omit `model` and `variant`: prompted tasks inherit the exact model and reasoning variant used by the current turn. Only specify `model` when the user explicitly asks to use or compare a different model, and only specify `variant` when the user explicitly asks for a different reasoning variant. A variant can be specified without a model to override the inherited model's variant. Never choose a different model merely because work is being fanned out. Specify an override `model` by name (e.g. "Claude Opus 4.1"); the name is matched leniently (case-insensitive, punctuation/spacing-insensitive, order-independent), so an approximate name like "opus 4.1" works and you do not need the exact name. Agent Manager picks the provider for you, preferring the provider used by the current turn and falling back to the Kilo Gateway. A qualified `provider/model` ID is also accepted to force a specific provider. If the name is ambiguous and matches several different models, the tool returns the candidates so you can choose. A model or variant selection requires an initial prompt so the session can persist that selection. Keep display names short because Agent Manager cards are narrow. Branch names are sanitized before worktree creation. Use `agent_manager_models` to search available models and variants on demand instead of guessing or loading the full model catalog. Prepared sessions without an initial prompt use the normal defaults. The agent and base branch settings always use the normal defaults. +Each task may provide a prompt, a short display name, a branch name, a `model`, an optional `provider`, and a model-specific reasoning `variant`. By default, omit `model`, `provider`, and `variant`: prompted tasks inherit the exact model and reasoning variant used by the current turn. Only specify `model` when the user explicitly asks to use or compare a different model, and only specify `variant` when the user explicitly asks for a different reasoning variant. A variant can be specified without a model to override the inherited model's variant. Specify `provider` with `model` to force a model-name match to one provider ID. Never choose a different model merely because work is being fanned out. Specify an override `model` by name (e.g. "Claude Opus 4.1"); the name is matched leniently (case-insensitive, punctuation/spacing-insensitive, order-independent), so an approximate name like "opus 4.1" works and you do not need the exact name. Agent Manager picks the provider for you, preferring the provider used by the current turn and falling back to the Kilo Gateway. A qualified `provider/model` ID is also accepted to force a specific provider. If the name is ambiguous and matches several different models, the tool returns the candidates so you can choose. A model or variant selection requires an initial prompt so the session can persist that selection. Keep display names short because Agent Manager cards are narrow. Branch names are sanitized before worktree creation. Use `agent_manager_models` to search available models and variants on demand instead of guessing or loading the full model catalog. Prepared sessions without an initial prompt use the normal defaults. The agent and base branch settings always use the normal defaults. By default, multiple tasks are started as independent Agent Manager sessions. Set `versions` to true only when all tasks are alternate versions of the same work that should be compared together. Versioned worktrees are grouped in Agent Manager and branch names may receive version suffixes. diff --git a/packages/opencode/test/kilocode/agent-manager-tool.test.ts b/packages/opencode/test/kilocode/agent-manager-tool.test.ts index 69b33d4238..411d2d173f 100644 --- a/packages/opencode/test/kilocode/agent-manager-tool.test.ts +++ b/packages/opencode/test/kilocode/agent-manager-tool.test.ts @@ -229,6 +229,14 @@ describe("agent_manager tool", () => { expect(Schema.is(Params)({ action: "stop", sessionID: "invalid" })).toBe(false) }) + test("validates provider selectors at the task level", () => { + expect(Schema.is(Params)({ mode: "local", tasks: [{ prompt: "Fix", model: "Shared", provider: "kilo" }] })).toBe( + true, + ) + expect(Schema.is(Params)({ mode: "local", tasks: [{ prompt: "Fix", provider: "kilo" }] })).toBe(false) + expect(Schema.is(Params)({ mode: "local", tasks: [{ prompt: "Fix", model: "Shared", provider: 42 }] })).toBe(false) + }) + // Regression for #13029: the OpenAI Responses API forces a value for every // advertised property. With action nullable the model can decline it and the // start request survives; with a populated action the action wins instead. @@ -673,6 +681,12 @@ describe("agent_manager tool", () => { expect(task?.variant).toBe("low") }) + test("uses an explicitly selected provider for a shared model name", async () => { + const task = await publish(runtime, { prompt: "Fix", model: " Shared ", provider: " kilo " }) + expect(String(task?.model?.providerID)).toBe("kilo") + expect(String(task?.model?.modelID)).toBe("kilo/shared") + }) + test("uses the provider of a different default model when that is the user's choice", async () => { const rt = makeRuntime("kilo") const task = await publish(rt, { prompt: "Fix", model: "Shared", variant: "low" }) @@ -717,6 +731,43 @@ describe("agent_manager tool", () => { expect(result.metadata.count).toBe(0) }) + test("reports a model unavailable from an explicit provider", async () => { + const tool = await init() + const calls: unknown[] = [] + + const result = await runtime.runPromise( + provideTmpdirInstance(() => + tool.execute( + { mode: "local", tasks: [{ prompt: "Fix", model: "Reasoning Model", provider: "kilo" }] }, + { ...ctx, ask: (input: unknown) => Effect.sync(() => calls.push(input)) }, + ), + ).pipe(Effect.scoped), + ) + + expect(calls).toEqual([]) + expect(result.output).toContain('model is not available from provider "kilo": Reasoning Model') + expect(result.metadata.count).toBe(0) + }) + + test("rejects an unknown provider without touching inherited object properties", async () => { + const tool = await init() + const calls: unknown[] = [] + + const result = await runtime.runPromise( + provideTmpdirInstance(() => + tool.execute( + { mode: "local", tasks: [{ prompt: "Fix", model: "Shared", provider: "__proto__" }] }, + { ...ctx, ask: (input: unknown) => Effect.sync(() => calls.push(input)) }, + ), + ).pipe(Effect.scoped), + ) + + expect(calls).toEqual([]) + expect(result.output).toContain("provider is not available for model selection: __proto__") + expect(result.output).toContain("Requested model: Shared") + expect(result.metadata.count).toBe(0) + }) + test("echoes how each named model resolved", async () => { const tool = await init() const result = await runtime.runPromise( From 0f576d0866b56780b22ab60701e03f2780835d49 Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Tue, 25 Aug 2026 14:55:44 +0200 Subject: [PATCH 13/17] fix(agent-manager): preserve scoped history activation --- .../agent-manager-history-routing-fix.md | 5 +++++ .../src/agent-manager/project/messages.ts | 3 +++ .../src/agent-manager/project/wiring.ts | 1 + .../unit/agent-project-selection.test.ts | 22 +++++++++++++++++++ .../agent-manager/AgentManagerApp.tsx | 11 ++++++---- 5 files changed, 38 insertions(+), 4 deletions(-) create mode 100644 .changeset/agent-manager-history-routing-fix.md diff --git a/.changeset/agent-manager-history-routing-fix.md b/.changeset/agent-manager-history-routing-fix.md new file mode 100644 index 0000000000..a015ab3147 --- /dev/null +++ b/.changeset/agent-manager-history-routing-fix.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Fix project-scoped Agent Manager history activation and session placement. diff --git a/packages/kilo-vscode/src/agent-manager/project/messages.ts b/packages/kilo-vscode/src/agent-manager/project/messages.ts index 55deaf6980..d3fa3bb80b 100644 --- a/packages/kilo-vscode/src/agent-manager/project/messages.ts +++ b/packages/kilo-vscode/src/agent-manager/project/messages.ts @@ -45,6 +45,8 @@ export interface ProjectMessageDeps { expand: (ctx: ProjectContext) => void /** Push the current project snapshots to the webview. */ push: () => void + /** Push one project's managed state to the webview. */ + pushState?: (ctx: ProjectContext) => void /** Acknowledge an atomically validated sidebar selection. */ selected: (target: SidebarTarget) => void /** Show a user-facing error. */ @@ -156,6 +158,7 @@ async function openSessionLocally(projectId: string, sessionId: string, deps: Pr } state?.moveSession(sessionId, null) deps.routeSession?.(projectId, sessionId, ctx.root, ctx.generation) + deps.pushState?.(ctx) deps.push() finish({ projectId, kind: "session", sessionId }, deps) } diff --git a/packages/kilo-vscode/src/agent-manager/project/wiring.ts b/packages/kilo-vscode/src/agent-manager/project/wiring.ts index c4a0044a3d..59a47ed011 100644 --- a/packages/kilo-vscode/src/agent-manager/project/wiring.ts +++ b/packages/kilo-vscode/src/agent-manager/project/wiring.ts @@ -67,6 +67,7 @@ export function createProjectWiring(opts: { expand: opts.expand, ready: opts.ready, push: opts.push, + pushState: opts.pushState, selected: opts.selected, routeSession: opts.routeSession, error: (message) => opts.host.showError(message), diff --git a/packages/kilo-vscode/tests/unit/agent-project-selection.test.ts b/packages/kilo-vscode/tests/unit/agent-project-selection.test.ts index 7e7e746022..171df5b67d 100644 --- a/packages/kilo-vscode/tests/unit/agent-project-selection.test.ts +++ b/packages/kilo-vscode/tests/unit/agent-project-selection.test.ts @@ -19,6 +19,7 @@ function fakeState(persisted?: { current?: unknown }) { return { getWorktree: (id: string) => (id === "wt1" ? { path: "/repo/prj-extra/wt1" } : undefined), getSession: (id: string) => (id === "sess1" ? {} : undefined), + moveSession: () => {}, getActiveTarget: () => store.current, setActiveTarget: (target: unknown) => { store.current = target @@ -172,6 +173,27 @@ describe("activateSelection — cross-project selection", () => { expect(calls.error).toEqual([]) }) + it("pushes moved-session state before acknowledging local activation", async () => { + const { contexts, deps, calls, extra } = setup() + const ctx = contexts.expand(extra)! + ctx.stateManager() + await ctx.ensureReady(async () => ({ ok: true, refsFixed: 0 })) + contexts.activate(extra) + + const order: string[] = [] + deps.push = () => order.push("projects") + deps.pushState = () => order.push("state") + deps.selected = () => order.push("selected") + + await handleProjectMessage( + { type: "agentManager.openSessionLocally", projectId: extra, sessionId: "sess1" } as never, + deps, + ) + + expect(order).toEqual(["state", "projects", "projects", "selected"]) + expect(calls.error).toEqual([]) + }) + it("restores the persisted target when the selection asks for it", async () => { const persisted = { current: undefined as unknown } const { contexts, deps, calls, extra } = setup({ state: () => fakeState(persisted) }) diff --git a/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx b/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx index 113e28acd8..e0a6cd8564 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx @@ -300,13 +300,18 @@ const AgentManagerContent: Component = () => { const [history, setHistory] = createSignal(false) /** Project whose sessions the history view is scoped to (multi-project). */ const [historyProject, setHistoryProject] = createSignal() + const [historySwitch, setHistorySwitch] = createSignal() const closeHistory = () => { setHistory(false) setHistoryProject(undefined) + setHistorySwitch(undefined) } /** Open the sessions view; a project id scopes it and activates that project. */ const openHistory = (pid?: string) => { const scoped = pid !== undefined && multiProject() + setHistorySwitch(scoped && currentProjectId() !== pid ? pid : undefined) + setHistoryProject(scoped ? pid : undefined) + setHistory(true) if (scoped) { // Activating the target project first lets the shared session store and // the pick routing operate in that project only. @@ -315,8 +320,6 @@ const AgentManagerContent: Component = () => { target: { projectId: pid, kind: "local" }, } as never) } - setHistoryProject(scoped ? pid : undefined) - setHistory(true) } const [sidePanel, setSidePanel] = createSignal(null) const diffOpen = () => sidePanel() === SidePanel.Diff @@ -765,7 +768,7 @@ const AgentManagerContent: Component = () => { const pid = historyProject() if (!pid || !multiProject()) return undefined const sessions = projectSessionsLive()[pid] - if (!sessions) return undefined + if (!sessions) return new Set() return new Set(sessions.filter(isKnownRootSession).map((s) => s.id)) }) @@ -1161,7 +1164,7 @@ const AgentManagerContent: Component = () => { first: () => undefined, close: () => setReviewActive(false), hide: () => setSidePanel(null), - history: () => closeHistory(), + history: () => (historySwitch() === state.projectId ? setHistorySwitch(undefined) : closeHistory()), reset: subagents.reset, }) } From e748515a2c45b7833cc4ffd626b0df9d6f925a76 Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Tue, 25 Aug 2026 15:35:44 +0200 Subject: [PATCH 14/17] fix(vscode): guard subagent promotion edge cases --- .../fix-background-promotion-edge-cases.md | 6 +++ packages/kilo-vscode/src/KiloProvider.ts | 7 ++-- packages/kilo-vscode/src/features.ts | 16 +++++++- .../src/kilo-provider/config-snapshot.ts | 9 +++-- packages/kilo-vscode/src/provider-actions.ts | 9 +++-- .../tests/unit/background-agents.test.ts | 38 +++++++++++++----- .../tests/unit/indexing-utils.test.ts | 5 +++ .../kilo-provider-indexing-refresh.test.ts | 5 +++ .../tests/unit/sandboxing-settings.test.ts | 2 +- .../src/components/chat/TaskToolExpanded.tsx | 12 +++++- .../src/components/chat/task-tool-state.ts | 7 +++- .../webview-ui/src/context/config.tsx | 6 ++- .../webview-ui/src/context/session-utils.ts | 6 +++ .../webview-ui/src/stories/StoryProviders.tsx | 1 + .../webview-ui/src/types/messages/config.ts | 1 + .../server/httpapi/handlers/kilocode.ts | 3 ++ .../test/server/session-actions.test.ts | 40 ++++++++++++++++++- 17 files changed, 142 insertions(+), 31 deletions(-) create mode 100644 .changeset/fix-background-promotion-edge-cases.md diff --git a/.changeset/fix-background-promotion-edge-cases.md b/.changeset/fix-background-promotion-edge-cases.md new file mode 100644 index 0000000000..25df1e2f7c --- /dev/null +++ b/.changeset/fix-background-promotion-edge-cases.md @@ -0,0 +1,6 @@ +--- +"@kilocode/cli": patch +"kilo-code": patch +--- + +Prevent stale subagent cards from showing background promotion and respect the background-subagent capability when promoting running tasks. diff --git a/packages/kilo-vscode/src/KiloProvider.ts b/packages/kilo-vscode/src/KiloProvider.ts index 6112de0eb4..25210bb4cf 100644 --- a/packages/kilo-vscode/src/KiloProvider.ts +++ b/packages/kilo-vscode/src/KiloProvider.ts @@ -163,7 +163,7 @@ import type { StoredProviderKey } from "./provider-actions" import { AnacondaDesktopBridge } from "./anaconda-desktop/bridge" import { fetchOpenAIModels, FetchModelsError } from "./shared/fetch-models" import type { Agent } from "@kilocode/sdk/v2/client" -import { configFeatures } from "./features" +import { configFeatures, serverFeatures } from "./features" import { fetchSnapshot } from "./kilo-provider/config-snapshot" import { createAutoApproveBridge } from "./kilo-provider/auto-approve" import type { KiloProviderOptions } from "./kilo-provider/options" @@ -3394,6 +3394,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper const global = snapshot.targets.global.raw as Config const projectConfig = bindings.project ? (snapshot.targets.project.raw as Config) : undefined this.cachedGlobalConfig = global + const features = configFeatures(snapshot.effective, await serverFeatures(this.client, dir)) this.cachedConfigMessage = { type: "configLoaded", config: snapshot.effective, @@ -3401,7 +3402,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper projectConfig, bindings, settings: this.configSettings(), - features: configFeatures(snapshot.effective), + features, } this.postMessage({ type: "configUpdated", @@ -3410,7 +3411,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper projectConfig, bindings, settings: this.configSettings(), - features: configFeatures(snapshot.effective), + features, }) await Promise.all([ refreshProviders ? this.fetchAndSendProviders() : Promise.resolve(), diff --git a/packages/kilo-vscode/src/features.ts b/packages/kilo-vscode/src/features.ts index 0b0b727528..c2d3a1f38f 100644 --- a/packages/kilo-vscode/src/features.ts +++ b/packages/kilo-vscode/src/features.ts @@ -1,4 +1,5 @@ import { hasIndexingPlugin } from "@kilocode/kilo-indexing/detect" +import type { KiloClient } from "@kilocode/sdk/v2" type PluginSpec = string | [string, Record] @@ -9,11 +10,24 @@ type ConfigLike = { export type Features = { indexing: boolean sandboxControls: boolean + backgroundSubagents: boolean } -export function configFeatures(config?: ConfigLike | null): Features { +export function configFeatures(config?: ConfigLike | null, backgroundSubagents = false): Features { return { indexing: hasIndexingPlugin(config?.plugin ?? []), sandboxControls: process.platform !== "win32", + backgroundSubagents, + } +} + +export async function serverFeatures(client: Pick, dir: string) { + if (!client.experimental?.capabilities?.get) return false + try { + const { data } = await client.experimental.capabilities.get({ directory: dir }, { throwOnError: true }) + return data?.backgroundSubagents === true + } catch (error) { + console.warn("[Kilo New] Failed to fetch server capabilities:", error) + return false } } diff --git a/packages/kilo-vscode/src/kilo-provider/config-snapshot.ts b/packages/kilo-vscode/src/kilo-provider/config-snapshot.ts index 90ca1ac6fc..088c8b3c20 100644 --- a/packages/kilo-vscode/src/kilo-provider/config-snapshot.ts +++ b/packages/kilo-vscode/src/kilo-provider/config-snapshot.ts @@ -1,15 +1,16 @@ import type { KiloClient } from "@kilocode/sdk/v2/client" -import { configFeatures } from "../features" +import { configFeatures, serverFeatures } from "../features" import { retry } from "../services/cli-backend/retry" import type { ConfigTarget } from "./config-bindings" -type Client = Pick +type Client = Pick type Settings = { maxCost: number; languageCommitMessage: string; multiProject: boolean } export async function fetchSnapshot(client: Client, dir: string, settings: () => Settings) { - const [{ data: config }, { data: global }, { data: overlay }] = await Promise.all([ + const [{ data: config }, { data: global }, { data: overlay }, capabilities] = await Promise.all([ retry(() => client.config.get({ directory: dir }, { throwOnError: true })), client.global.config.get({ throwOnError: true }), client.config.overlay({ directory: dir, scope: "project" }, { throwOnError: true }), + retry(() => serverFeatures(client, dir)), ]) return { config, @@ -17,6 +18,6 @@ export async function fetchSnapshot(client: Client, dir: string, settings: () => targets: overlay?.targets as { global: ConfigTarget; project: ConfigTarget } | undefined, collections: overlay?.collections, settings: settings(), - features: configFeatures(config), + features: configFeatures(config, capabilities), } } diff --git a/packages/kilo-vscode/src/provider-actions.ts b/packages/kilo-vscode/src/provider-actions.ts index 742380b560..9d67b2797f 100644 --- a/packages/kilo-vscode/src/provider-actions.ts +++ b/packages/kilo-vscode/src/provider-actions.ts @@ -10,7 +10,7 @@ import { withCustomProviderDeletions, } from "./shared/custom-provider" import { isCustomProviderPackage, KILO_AUTO, KILO_PROVIDER_ID, parseModelString } from "./shared/provider-model" -import { configFeatures } from "./features" +import { configFeatures, serverFeatures } from "./features" /** * Compute the default model selection from CLI config, VS Code settings, or hardcoded fallback. @@ -240,7 +240,7 @@ async function refreshConfig(ctx: ActionContext, setCachedConfig: SetCachedConfi ctx.client.global.config.get({ throwOnError: true }), ]) if (!config) return - const features = configFeatures(config) + const features = configFeatures(config, await serverFeatures(ctx.client, ctx.workspaceDir)) setCachedConfig({ type: "configLoaded", config, globalConfig: global, features }) ctx.postMessage({ type: "configUpdated", config, globalConfig: global, features }) } @@ -464,9 +464,10 @@ export async function saveCustomProvider( const merged = await ctx.client.config.get({ directory: ctx.workspaceDir }, { throwOnError: true }) const config = merged.data ?? updated - const msg = { type: "configLoaded", config, globalConfig: updated, features: configFeatures(config) } + const features = configFeatures(config, await serverFeatures(ctx.client, ctx.workspaceDir)) + const msg = { type: "configLoaded", config, globalConfig: updated, features } setCachedConfig(msg) - ctx.postMessage({ type: "configUpdated", config, globalConfig: updated, features: configFeatures(config) }) + ctx.postMessage({ type: "configUpdated", config, globalConfig: updated, features }) const auth = resolveCustomProviderAuth(apiKey, apiKeyChanged) diff --git a/packages/kilo-vscode/tests/unit/background-agents.test.ts b/packages/kilo-vscode/tests/unit/background-agents.test.ts index 4689ddb810..567e663b40 100644 --- a/packages/kilo-vscode/tests/unit/background-agents.test.ts +++ b/packages/kilo-vscode/tests/unit/background-agents.test.ts @@ -5,6 +5,7 @@ import { showBackgroundAgent, } from "../../webview-ui/src/components/chat/background-agents" import { childForeground, showChildPromotion } from "../../webview-ui/src/components/chat/task-tool-state" +import { latestTaskPart } from "../../webview-ui/src/context/session-utils" import type { BackgroundJobInfo, PermissionRequest, @@ -77,17 +78,32 @@ describe("backgroundAgents", () => { it("identifies each parallel foreground child independently", () => { const status = { ses_a: busy, ses_b: busy } - expect(childForeground("ses_a", {}, {}, status)).toBe(true) - expect(childForeground("ses_b", {}, {}, status)).toBe(true) - expect(childForeground("ses_a", { background: true }, {}, status)).toBe(false) - expect(childForeground("ses_b", {}, { background: true }, status)).toBe(false) - expect(childForeground("ses_a", {}, {}, { ses_a: idle })).toBe(false) - expect(childForeground("ses_a", {}, {}, { ses_a: { type: "retry", attempt: 1, message: "retry", next: 1 } })).toBe( - true, - ) - expect(childForeground(undefined, {}, {}, status)).toBe(false) - expect(showChildPromotion("ses_a", {}, {}, status, false)).toBe(true) - expect(showChildPromotion("ses_a", {}, {}, status, true)).toBe(false) + expect(childForeground("ses_a", {}, {}, status, true)).toBe(true) + expect(childForeground("ses_b", {}, {}, status, true)).toBe(true) + expect(childForeground("ses_a", { background: true }, {}, status, true)).toBe(false) + expect(childForeground("ses_b", {}, { background: true }, status, true)).toBe(false) + expect(childForeground("ses_a", {}, {}, { ses_a: idle }, true)).toBe(false) + expect( + childForeground("ses_a", {}, {}, { ses_a: { type: "retry", attempt: 1, message: "retry", next: 1 } }, true), + ).toBe(true) + expect(childForeground(undefined, {}, {}, status, true)).toBe(false) + expect(childForeground("ses_a", {}, {}, status, false)).toBe(false) + expect(showChildPromotion("ses_a", {}, {}, status, true, false, true)).toBe(true) + expect(showChildPromotion("ses_a", {}, {}, status, true, true, true)).toBe(false) + expect(showChildPromotion("ses_a", {}, {}, status, false, false, true)).toBe(false) + expect(showChildPromotion("ses_a", {}, {}, status, undefined, false, true)).toBe(false) + }) + + it("only promotes the latest task part for a resumed child", () => { + const parts = [ + taskPart({ id: "part_old", child: "ses_a" }), + taskPart({ id: "part_new", child: "ses_a" }), + taskPart({ id: "part_other", child: "ses_b" }), + ] + + expect(latestTaskPart("part_old", "ses_a", parts)).toBe(false) + expect(latestTaskPart("part_new", "ses_a", parts)).toBe(true) + expect(latestTaskPart("part_other", "ses_b", parts)).toBe(true) }) it("ignores agents whose session is no longer working", () => { diff --git a/packages/kilo-vscode/tests/unit/indexing-utils.test.ts b/packages/kilo-vscode/tests/unit/indexing-utils.test.ts index ad6551e802..9e9e43d257 100644 --- a/packages/kilo-vscode/tests/unit/indexing-utils.test.ts +++ b/packages/kilo-vscode/tests/unit/indexing-utils.test.ts @@ -121,6 +121,11 @@ describe("indexing SSE mapping", () => { }) describe("indexing feature detection", () => { + it("keeps background subagent capability disabled unless the server reports it", () => { + expect(configFeatures().backgroundSubagents).toBe(false) + expect(configFeatures({}, true).backgroundSubagents).toBe(true) + }) + it("enables indexing settings when the indexing plugin is present", () => { expect(configFeatures({ plugin: ["kilo-indexing"] }).indexing).toBe(true) }) diff --git a/packages/kilo-vscode/tests/unit/kilo-provider-indexing-refresh.test.ts b/packages/kilo-vscode/tests/unit/kilo-provider-indexing-refresh.test.ts index 82621a7e46..ebb3ad1833 100644 --- a/packages/kilo-vscode/tests/unit/kilo-provider-indexing-refresh.test.ts +++ b/packages/kilo-vscode/tests/unit/kilo-provider-indexing-refresh.test.ts @@ -87,6 +87,11 @@ function createConnection() { return { data: snapshot } }, }, + experimental: { + capabilities: { + get: async () => ({ data: { backgroundSubagents: true } }), + }, + }, } return { diff --git a/packages/kilo-vscode/tests/unit/sandboxing-settings.test.ts b/packages/kilo-vscode/tests/unit/sandboxing-settings.test.ts index 55c251d07d..63767afb9a 100644 --- a/packages/kilo-vscode/tests/unit/sandboxing-settings.test.ts +++ b/packages/kilo-vscode/tests/unit/sandboxing-settings.test.ts @@ -2,7 +2,7 @@ import { afterEach, describe, expect, test } from "bun:test" import { configFeatures } from "../../src/features" import { visible } from "../../webview-ui/src/components/settings/sandboxing" -const features = { indexing: false, sandboxControls: false } +const features = { indexing: false, sandboxControls: false, backgroundSubagents: false } const platform = Object.getOwnPropertyDescriptor(process, "platform") function setPlatform(value: string) { diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/TaskToolExpanded.tsx b/packages/kilo-vscode/webview-ui/src/components/chat/TaskToolExpanded.tsx index 3a8d4f72da..568245bcc6 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/TaskToolExpanded.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/chat/TaskToolExpanded.tsx @@ -20,7 +20,8 @@ import { createAutoScroll } from "@kilocode/kilo-ui/hooks" import { useSession } from "../../context/session" import { useVSCode } from "../../context/vscode" import { useWorktreeMode } from "../../context/worktree-mode" -import { childID } from "../../context/session-utils" +import { childID, latestTaskPart } from "../../context/session-utils" +import { useConfig } from "../../context/config" import { openSubagent } from "./open-subagent" import { showChildPromotion, taskResult, taskRunning, taskVisible } from "./task-tool-state" @@ -28,6 +29,7 @@ const TaskToolRenderer: Component = (props) => { const i18n = useI18n() const language = useLanguage() const session = useSession() + const { features } = useConfig() const vscode = useVSCode() const worktree = useWorktreeMode() @@ -45,7 +47,13 @@ const TaskToolRenderer: Component = (props) => { props.partMetadata as Record | undefined, props.metadata as Record | undefined, session.allStatusMap(), + features().backgroundSubagents, props.readonly, + latestTaskPart( + props.partID, + childSessionId(), + session.currentSessionID() ? session.getSessionToolParts(session.currentSessionID()!) : [], + ), ), ) @@ -168,7 +176,7 @@ const TaskToolRenderer: Component = (props) => { - + | undefined, state: Record | undefined, status: Record, + latest: boolean, ) { - if (!id) return false + if (!id || !latest) return false if (part?.background === true || state?.background === true) return false return status[id]?.type === "busy" || status[id]?.type === "retry" } @@ -20,9 +21,11 @@ export function showChildPromotion( part: Record | undefined, state: Record | undefined, status: Record, + enabled: boolean | undefined, readonly: boolean | undefined, + latest: boolean, ) { - return !readonly && childForeground(id, part, state, status) + return enabled === true && !readonly && childForeground(id, part, state, status, latest) } export function taskVisible(open: boolean | undefined, id: string | undefined) { diff --git a/packages/kilo-vscode/webview-ui/src/context/config.tsx b/packages/kilo-vscode/webview-ui/src/context/config.tsx index 53b3bfafc3..4fc09d1e8d 100644 --- a/packages/kilo-vscode/webview-ui/src/context/config.tsx +++ b/packages/kilo-vscode/webview-ui/src/context/config.tsx @@ -88,7 +88,11 @@ export const ConfigProvider: ParentComponent = (props) => { const [projectConfig, setProjectConfig] = createSignal({}) const [collections, setCollections] = createSignal({}) const [settings, setSettings] = createSignal>({}) - const [features, setFeatures] = createSignal({ indexing: false, sandboxControls: false }) + const [features, setFeatures] = createSignal({ + indexing: false, + sandboxControls: false, + backgroundSubagents: false, + }) const [loading, setLoading] = createSignal(true) const [draft, setDraft] = createSignal>({}) const [globalDraft, setGlobalDraft] = createSignal>({}) diff --git a/packages/kilo-vscode/webview-ui/src/context/session-utils.ts b/packages/kilo-vscode/webview-ui/src/context/session-utils.ts index b783496f7a..84212c2c4b 100644 --- a/packages/kilo-vscode/webview-ui/src/context/session-utils.ts +++ b/packages/kilo-vscode/webview-ui/src/context/session-utils.ts @@ -105,6 +105,7 @@ type ToolState = { } type TaskPart = { + id?: string type: string tool?: string metadata?: { sessionId?: string } @@ -116,6 +117,11 @@ export function childID(part: TaskPart): string | undefined { return part.metadata?.sessionId ?? part.state?.metadata?.sessionId } +export function latestTaskPart(partID: string | undefined, child: string | undefined, parts: readonly TaskPart[]) { + if (!partID || !child) return false + return parts.findLast((part) => childID(part) === child)?.id === partID +} + function stringField(value: unknown): string | undefined { return typeof value === "string" ? value : undefined } diff --git a/packages/kilo-vscode/webview-ui/src/stories/StoryProviders.tsx b/packages/kilo-vscode/webview-ui/src/stories/StoryProviders.tsx index 00cf1e4875..d682b3ab6b 100644 --- a/packages/kilo-vscode/webview-ui/src/stories/StoryProviders.tsx +++ b/packages/kilo-vscode/webview-ui/src/stories/StoryProviders.tsx @@ -337,6 +337,7 @@ const ConfigWrapper: ParentComponent<{ return { indexing: props.features?.indexing ?? hasIndexingPlugin(config.plugin ?? []), sandboxControls: props.features?.sandboxControls ?? false, + backgroundSubagents: props.features?.backgroundSubagents ?? false, } }) diff --git a/packages/kilo-vscode/webview-ui/src/types/messages/config.ts b/packages/kilo-vscode/webview-ui/src/types/messages/config.ts index ccca142648..de4014161a 100644 --- a/packages/kilo-vscode/webview-ui/src/types/messages/config.ts +++ b/packages/kilo-vscode/webview-ui/src/types/messages/config.ts @@ -173,4 +173,5 @@ export interface Config { export interface FeatureFlags { indexing: boolean sandboxControls: boolean + backgroundSubagents: boolean } diff --git a/packages/opencode/src/kilocode/server/httpapi/handlers/kilocode.ts b/packages/opencode/src/kilocode/server/httpapi/handlers/kilocode.ts index 10fa39f21d..1963357320 100644 --- a/packages/opencode/src/kilocode/server/httpapi/handlers/kilocode.ts +++ b/packages/opencode/src/kilocode/server/httpapi/handlers/kilocode.ts @@ -25,6 +25,7 @@ import { Skill } from "@/skill" import { BackgroundJob } from "@/background/job" import { SessionRunState } from "@/session/run-state" import { SessionID } from "@/session/schema" +import { RuntimeFlags } from "@/effect/runtime-flags" import { AgentManagerRejectPayload, AgentManagerReplyPayload, @@ -48,6 +49,7 @@ export const kilocodeHandlers = HttpApiBuilder.group(InstanceHttpApi, "kilocode" const notebook = yield* Notebook.Service const background = yield* BackgroundJob.Service const runState = yield* SessionRunState.Service + const flags = yield* RuntimeFlags.Service const locations = yield* LocationServiceMap.Service // Location-scoped services, keyed by the request's directory and workspace. @@ -237,6 +239,7 @@ export const kilocodeHandlers = HttpApiBuilder.group(InstanceHttpApi, "kilocode" const backgroundJobPromote = Effect.fn("KilocodeHttpApi.backgroundJobPromote")(function* (ctx: { params: { jobID: string } }) { + if (!flags.experimentalBackgroundSubagents) return false const job = yield* background.get(ctx.params.jobID) if (!job) return yield* new HttpApiError.NotFound({}) const promoted = yield* background.promote(ctx.params.jobID) diff --git a/packages/opencode/test/server/session-actions.test.ts b/packages/opencode/test/server/session-actions.test.ts index ee318274b3..306c3cf5c3 100644 --- a/packages/opencode/test/server/session-actions.test.ts +++ b/packages/opencode/test/server/session-actions.test.ts @@ -1,6 +1,6 @@ import { afterEach, describe, expect, mock } from "bun:test" import { LayerNode } from "@opencode-ai/core/effect/layer-node" -import { Effect, Fiber, Layer } from "effect" // kilocode_change +import { ConfigProvider, Effect, Fiber, Layer } from "effect" // kilocode_change import { BackgroundJob } from "@/background/job" // kilocode_change import { Session as SessionNs } from "@/session/session" import { disposeAllInstances, TestInstance } from "../fixture/fixture" @@ -9,7 +9,23 @@ import { httpApiLayer, requestInDirectory } from "./httpapi-layer" // kilocode_change start - provide the background-job service for promotion coverage const it = testEffect( - Layer.mergeAll(LayerNode.compile(SessionNs.node), LayerNode.compile(BackgroundJob.node), httpApiLayer), // kilocode_change + Layer.mergeAll( + LayerNode.compile(SessionNs.node), + LayerNode.compile(BackgroundJob.node), + httpApiLayer, + ), // kilocode_change +) +const disabled = testEffect( + Layer.mergeAll(LayerNode.compile(SessionNs.node), LayerNode.compile(BackgroundJob.node), httpApiLayer).pipe( + Layer.provide( + ConfigProvider.layer( + ConfigProvider.fromUnknown({ + KILO_EXPERIMENTAL_BACKGROUND_SUBAGENTS: "false", + KILO_EXPERIMENTAL_DISABLE_FILEWATCHER: "true", + }), + ), + ), + ), ) // kilocode_change end @@ -127,6 +143,26 @@ describe("session action routes", () => { { git: true }, ) + disabled.instance( + "background job promotion is disabled when the flag is off", + () => + Effect.gen(function* () { + const test = yield* TestInstance + const jobs = yield* BackgroundJob.Service + const job = yield* jobs.start({ type: "task", metadata: { parentSessionId: "ses_parent" }, run: Effect.never }) + + const res = yield* requestInDirectory(`/kilocode/background-jobs/${job.id}/promote`, test.directory, { + method: "POST", + }) + + expect(res.status).toBe(200) + expect(yield* res.json).toBe(false) + expect((yield* jobs.get(job.id))?.metadata?.background).toBeUndefined() + yield* jobs.cancel(job.id) + }), + { git: true }, + ) + // kilocode_change start - verify HTTP promotion of a running task it.instance( "experimental background route backgrounds a synchronous subagent", From 1f6c8ef0c9c2b42e23c004845c2cba962c267ce0 Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Tue, 25 Aug 2026 16:25:03 +0200 Subject: [PATCH 15/17] fix(agent-manager): handle overlapping history switches --- .../webview-ui/agent-manager/AgentManagerApp.tsx | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx b/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx index e0a6cd8564..e61dcee4c4 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx @@ -298,18 +298,16 @@ const AgentManagerContent: Component = () => { let sidebarRaf: number | undefined let pendingSidebarWidth: number | undefined const [history, setHistory] = createSignal(false) - /** Project whose sessions the history view is scoped to (multi-project). */ const [historyProject, setHistoryProject] = createSignal() - const [historySwitch, setHistorySwitch] = createSignal() + const [historySwitches, setHistorySwitches] = createSignal([]) const closeHistory = () => { setHistory(false) setHistoryProject(undefined) - setHistorySwitch(undefined) + setHistorySwitches([]) } - /** Open the sessions view; a project id scopes it and activates that project. */ const openHistory = (pid?: string) => { const scoped = pid !== undefined && multiProject() - setHistorySwitch(scoped && currentProjectId() !== pid ? pid : undefined) + if (scoped) setHistorySwitches((prev) => (prev.includes(pid) ? prev : [...prev, pid])) setHistoryProject(scoped ? pid : undefined) setHistory(true) if (scoped) { @@ -1164,7 +1162,10 @@ const AgentManagerContent: Component = () => { first: () => undefined, close: () => setReviewActive(false), hide: () => setSidePanel(null), - history: () => (historySwitch() === state.projectId ? setHistorySwitch(undefined) : closeHistory()), + history: () => + state.projectId && historySwitches().includes(state.projectId) + ? setHistorySwitches((prev) => prev.filter((id) => id !== state.projectId)) + : closeHistory(), reset: subagents.reset, }) } From 3f834675819a6599d5c8d0102c20f07be5f58269 Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Tue, 25 Aug 2026 16:25:38 +0200 Subject: [PATCH 16/17] fix(vscode): keep sync filter window moving --- .../services/cli-backend/connection-utils.ts | 3 +- .../tests/unit/connection-utils.test.ts | 63 +++++++++---------- 2 files changed, 30 insertions(+), 36 deletions(-) diff --git a/packages/kilo-vscode/src/services/cli-backend/connection-utils.ts b/packages/kilo-vscode/src/services/cli-backend/connection-utils.ts index 97ea3a568f..8a25d85274 100644 --- a/packages/kilo-vscode/src/services/cli-backend/connection-utils.ts +++ b/packages/kilo-vscode/src/services/cli-backend/connection-utils.ts @@ -24,7 +24,8 @@ export function createDuplicateEventFilter() { } if (duplicateLiveEvents.has(event.type)) { - if (seen.size < DUPLICATE_EVENT_LIMIT) seen.add(event.id) + if (seen.size >= DUPLICATE_EVENT_LIMIT) seen.delete(seen.values().next().value!) + seen.add(event.id) } return false } diff --git a/packages/kilo-vscode/tests/unit/connection-utils.test.ts b/packages/kilo-vscode/tests/unit/connection-utils.test.ts index cde5796e8b..3a31216509 100644 --- a/packages/kilo-vscode/tests/unit/connection-utils.test.ts +++ b/packages/kilo-vscode/tests/unit/connection-utils.test.ts @@ -234,7 +234,7 @@ describe("createDuplicateEventFilter", () => { ).toBe(false) }) - it("does not evict pending live events when the cap is reached", () => { + it("continues tracking new live events after the cap is reached", () => { const filter = createDuplicateEventFilter() for (let index = 0; index < 1024; index++) { expect( @@ -246,18 +246,6 @@ describe("createDuplicateEventFilter", () => { ).toBe(false) } - expect( - filter( - sync({ - type: "sync", - name: "message.part.updated.1", - id: "live-0", - seq: 8, - aggregateID: "s6", - data: { sessionID: "s6", part, time: 0 }, - }), - ), - ).toBe(true) expect( filter({ id: "live-1024", @@ -277,9 +265,33 @@ describe("createDuplicateEventFilter", () => { }), ), ).toBe(true) + expect( + filter( + sync({ + type: "sync", + name: "message.part.updated.1", + id: "live-0", + seq: 8, + aggregateID: "s6", + data: { sessionID: "s6", part, time: 0 }, + }), + ), + ).toBe(false) + expect( + filter( + sync({ + type: "sync", + name: "message.part.updated.1", + id: "live-1024", + seq: 9, + aggregateID: "s6", + data: { sessionID: "s6", part, time: 0 }, + }), + ), + ).toBe(false) }) - it("passes overflow events through without evicting pending IDs", () => { + it("forwards delayed envelopes for evicted IDs", () => { const filter = createDuplicateEventFilter() for (let index = 0; index < 1024; index++) { expect( @@ -303,7 +315,7 @@ describe("createDuplicateEventFilter", () => { sync({ type: "sync", name: "message.part.updated.1", - id: "overflow", + id: "pending-0", seq: 8, aggregateID: "s6", data: { sessionID: "s6", part, time: 0 }, @@ -315,32 +327,13 @@ describe("createDuplicateEventFilter", () => { sync({ type: "sync", name: "message.part.updated.1", - id: "pending-0", + id: "pending-1023", seq: 9, aggregateID: "s6", data: { sessionID: "s6", part, time: 0 }, }), ), ).toBe(true) - expect( - filter({ - id: "after-free", - type: "message.part.updated", - properties: { sessionID: "s6", part, delta: "x" }, - }), - ).toBe(false) - expect( - filter( - sync({ - type: "sync", - name: "message.part.updated.1", - id: "after-free", - seq: 10, - aggregateID: "s6", - data: { sessionID: "s6", part, time: 0 }, - }), - ), - ).toBe(true) }) it("does not carry duplicate IDs between connections", () => { From 0ab894d649f53a661bca6ba38bf45769286a3518 Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Tue, 25 Aug 2026 16:26:30 +0200 Subject: [PATCH 17/17] fix(agent-manager): remove unreachable provider check --- packages/opencode/src/kilocode/tool/agent-manager.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/opencode/src/kilocode/tool/agent-manager.ts b/packages/opencode/src/kilocode/tool/agent-manager.ts index a1b098d0ff..630f08200f 100644 --- a/packages/opencode/src/kilocode/tool/agent-manager.ts +++ b/packages/opencode/src/kilocode/tool/agent-manager.ts @@ -254,7 +254,6 @@ function select( const value = task.model?.trim() const provider = task.provider?.trim() const variant = task.variant?.trim() - if (provider && !value) return { error: `Task ${index + 1} provider requires a model.` } if (!value) { if (!variant) { if (!task.prompt?.trim() || !source) return { task: base }