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/.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/.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/.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/.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/.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/.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-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/kilo-vscode/src/KiloProvider.ts b/packages/kilo-vscode/src/KiloProvider.ts index 441dd20124..206972ce9e 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/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/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/src/services/cli-backend/connection-service.ts b/packages/kilo-vscode/src/services/cli-backend/connection-service.ts index ed4b3b2177..73585d1534 100644 --- a/packages/kilo-vscode/src/services/cli-backend/connection-service.ts +++ b/packages/kilo-vscode/src/services/cli-backend/connection-service.ts @@ -99,7 +99,6 @@ export class KiloConnectionService { private readonly eventListeners: Set = new Set() private readonly filteredListeners = new Set<{ filter: SSEEventFilter; listener: SSEEventListener }>() private readonly explicitAborts = new ExplicitAbortState() - private readonly duplicateEvent = createDuplicateEventFilter() private readonly stateListeners: Set = new Set() private readonly notificationDismissListeners: Set = new Set() private readonly languageChangeListeners: Set = new Set() @@ -849,6 +848,7 @@ export class KiloConnectionService { }, }) const sse = new SdkSSEAdapter(client) + const duplicateEvent = createDuplicateEventFilter() this.client = client this.sseClient = sse @@ -867,7 +867,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.broadcast(event, directory) }) 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..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,8 +24,8 @@ export function createDuplicateEventFilter() { } if (duplicateLiveEvents.has(event.type)) { + if (seen.size >= DUPLICATE_EVENT_LIMIT) seen.delete(seen.values().next().value!) seen.add(event.id) - if (seen.size > DUPLICATE_EVENT_LIMIT) seen.delete(seen.values().next().value!) } return false } 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/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/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/connection-utils.test.ts b/packages/kilo-vscode/tests/unit/connection-utils.test.ts index c1865616e7..3a31216509 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,130 @@ describe("isDuplicateSyncEvent", () => { ), ).toBe(false) }) + + it("continues tracking new live events after 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({ + 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) + 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("forwards delayed envelopes for evicted 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: "pending-0", + seq: 8, + aggregateID: "s6", + data: { sessionID: "s6", part, time: 0 }, + }), + ), + ).toBe(false) + expect( + filter( + sync({ + type: "sync", + name: "message.part.updated.1", + id: "pending-1023", + seq: 9, + 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) + }) }) 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/agent-manager/AgentManagerApp.tsx b/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx index 113e28acd8..e61dcee4c4 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx @@ -298,15 +298,18 @@ 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 [historySwitches, setHistorySwitches] = createSignal([]) const closeHistory = () => { setHistory(false) setHistoryProject(undefined) + setHistorySwitches([]) } - /** Open the sessions view; a project id scopes it and activates that project. */ const openHistory = (pid?: string) => { const scoped = pid !== undefined && multiProject() + if (scoped) setHistorySwitches((prev) => (prev.includes(pid) ? prev : [...prev, pid])) + 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 +318,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 +766,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 +1162,10 @@ const AgentManagerContent: Component = () => { first: () => undefined, close: () => setReviewActive(false), hide: () => setSidePanel(null), - history: () => closeHistory(), + history: () => + state.projectId && historySwitches().includes(state.projectId) + ? setHistorySwitches((prev) => prev.filter((id) => id !== state.projectId)) + : closeHistory(), reset: subagents.reset, }) } 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() 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/editor-context.ts b/packages/opencode/src/kilocode/editor-context.ts index ef7c077580..4350bddd4f 100644 --- a/packages/opencode/src/kilocode/editor-context.ts +++ b/packages/opencode/src/kilocode/editor-context.ts @@ -27,22 +27,14 @@ 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() - 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}` +function timestamp(now: Date): string { + return now.toISOString().replace(/\.\d+Z$/, "Z") } -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/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/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/kilocode/session/prompt.ts b/packages/opencode/src/kilocode/session/prompt.ts index 4b997d2a5f..9b088be76b 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,46 +355,54 @@ 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[] - lastUser: MessageV2.User + session: Pick 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 + const route = { + directory: input.session.directory, + worktree: path.resolve( + input.session.directory, + ...(input.session.path + ?.split("/") + .filter(Boolean) + .map(() => "..") ?? []), + ), } - 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, - ], + 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( + { + ...route, + ...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/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..630f08200f 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,6 +252,7 @@ function select( ...(task.branchName != null ? { branchName: task.branchName } : {}), } const value = task.model?.trim() + const provider = task.provider?.trim() const variant = task.variant?.trim() if (!value) { if (!variant) { @@ -271,12 +279,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 +496,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/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/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 092666a3ef..9c9ceb4106 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -1718,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, sessionID, cache: envCache }) + KiloSessionPrompt.injectEditorContext({ msgs, session, sessionID, cache: envCache }) msgs = KiloSessionPrompt.maybeStripHistoricalMedia(msgs) // kilocode_change end @@ -1742,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, 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), @@ -2518,7 +2518,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/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( 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..1e1f75f73a --- /dev/null +++ b/packages/opencode/test/kilocode/editor-context-injection.test.ts @@ -0,0 +1,142 @@ +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" +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 session = { + directory: "/repo/session", + path: "session", +} +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?: string) { + const id = MessageID.ascending() + return { + info: { + id, + role: "user" as const, + sessionID, + time: { created }, + agent: "code", + model, + editorContext: { + ...(route ? { 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 = {}) { + KiloSessionPrompt.injectEditorContext({ msgs, session, 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/session") + 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") + }) + + 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/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..84e363d2a1 --- /dev/null +++ b/packages/opencode/test/kilocode/shared-location-map.test.ts @@ -0,0 +1,32 @@ +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", + ] + + for (const file of files) { + expect(source(file), file).not.toContain("locationServiceMapLayer") + } + }) + + 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]])", + ) + expect(source("effect/app-runtime.ts")).not.toContain("LocationServiceMap.node") + }) +}) diff --git a/packages/opencode/test/kilocode/system-prompt.test.ts b/packages/opencode/test/kilocode/system-prompt.test.ts index 10cea1161d..81db8e3f98 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-08-24T12:34:56.123Z")) + + expect(result).toContain("Message time: 2026-08-24T12:34:56Z") + }) }) 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",