Merge origin/main into fix-abort-error-flash

This commit is contained in:
marius-kilocode
2026-08-25 16:40:06 +02:00
48 changed files with 674 additions and 119 deletions
@@ -0,0 +1,5 @@
---
"kilo-code": patch
---
Fix project-scoped Agent Manager history activation and session placement.
@@ -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.
@@ -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.
+5
View File
@@ -0,0 +1,5 @@
---
"kilo-code": patch
---
Prevent duplicate-event tracking from suppressing delayed sync events after reconnects or high event bursts.
@@ -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.
@@ -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.
+5
View File
@@ -0,0 +1,5 @@
---
"kilo-code": patch
---
Use a browser-valid close code when Agent Manager terminal replay exceeds its buffer limit
@@ -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.
+4 -3
View File
@@ -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(),
@@ -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)
}
@@ -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),
+15 -1
View File
@@ -1,4 +1,5 @@
import { hasIndexingPlugin } from "@kilocode/kilo-indexing/detect"
import type { KiloClient } from "@kilocode/sdk/v2"
type PluginSpec = string | [string, Record<string, unknown>]
@@ -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<KiloClient, "experimental">, 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
}
}
@@ -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<KiloClient, "config" | "global">
type Client = Pick<KiloClient, "config" | "global" | "experimental">
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),
}
}
+5 -4
View File
@@ -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)
@@ -99,7 +99,6 @@ export class KiloConnectionService {
private readonly eventListeners: Set<SSEEventListener> = new Set()
private readonly filteredListeners = new Set<{ filter: SSEEventFilter; listener: SSEEventListener }>()
private readonly explicitAborts = new ExplicitAbortState()
private readonly duplicateEvent = createDuplicateEventFilter()
private readonly stateListeners: Set<StateListener> = new Set()
private readonly notificationDismissListeners: Set<NotificationDismissListener> = new Set()
private readonly languageChangeListeners: Set<LanguageChangeListener> = 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)
})
@@ -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
}
@@ -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"')
@@ -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) })
@@ -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", () => {
@@ -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)
})
})
@@ -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)
})
@@ -87,6 +87,11 @@ function createConnection() {
return { data: snapshot }
},
},
experimental: {
capabilities: {
get: async () => ({ data: { backgroundSubagents: true } }),
},
},
}
return {
@@ -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) {
@@ -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<string | undefined>()
const [historySwitches, setHistorySwitches] = createSignal<string[]>([])
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<SidePanelState>(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<string>()
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,
})
}
@@ -334,7 +334,7 @@ export const TerminalTab: Component<Props> = (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> = (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()
@@ -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<ToolProps> = (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<ToolProps> = (props) => {
props.partMetadata as Record<string, unknown> | undefined,
props.metadata as Record<string, unknown> | undefined,
session.allStatusMap(),
features().backgroundSubagents,
props.readonly,
latestTaskPart(
props.partID,
childSessionId(),
session.currentSessionID() ? session.getSessionToolParts(session.currentSessionID()!) : [],
),
),
)
@@ -168,7 +176,7 @@ const TaskToolRenderer: Component<ToolProps> = (props) => {
</Show>
</div>
<Show when={childSessionId()}>
<Show when={promotable()}>
<Show when={features().backgroundSubagents && promotable()}>
<Tooltip value={language.t("task.backgroundAgents.continueInBackground")} placement="top">
<IconButton
icon="arrow-down-to-line"
@@ -9,8 +9,9 @@ export function childForeground(
part: Record<string, unknown> | undefined,
state: Record<string, unknown> | undefined,
status: Record<string, SessionStatusInfo>,
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<string, unknown> | undefined,
state: Record<string, unknown> | undefined,
status: Record<string, SessionStatusInfo>,
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) {
@@ -88,7 +88,11 @@ export const ConfigProvider: ParentComponent = (props) => {
const [projectConfig, setProjectConfig] = createSignal<Config>({})
const [collections, setCollections] = createSignal<ConfigCollections>({})
const [settings, setSettings] = createSignal<Record<string, unknown>>({})
const [features, setFeatures] = createSignal<FeatureFlags>({ indexing: false, sandboxControls: false })
const [features, setFeatures] = createSignal<FeatureFlags>({
indexing: false,
sandboxControls: false,
backgroundSubagents: false,
})
const [loading, setLoading] = createSignal(true)
const [draft, setDraft] = createSignal<Partial<Config>>({})
const [globalDraft, setGlobalDraft] = createSignal<Partial<Config>>({})
@@ -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
}
@@ -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,
}
})
@@ -173,4 +173,5 @@ export interface Config {
export interface FeatureFlags {
indexing: boolean
sandboxControls: boolean
backgroundSubagents: boolean
}
@@ -27,22 +27,14 @@ export function staticEnvLines(ctx?: EditorContext): string[] {
* Build a per-message <environment_details> 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}`)
}
@@ -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)
@@ -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))
)
@@ -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<string, string>
}
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<Session.Info, "directory" | "path">
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("<environment_details>"),
)
)
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)
}
}
@@ -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 },
}
@@ -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.
@@ -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(
@@ -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.
@@ -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
@@ -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
+6 -3
View File
@@ -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<Schema.Schema.Type<typeof PromptInput>, "parts" | "editorContext"> & {
parts: PartInputUnion[]
editorContext?: MessageV2.EditorContext
@@ -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(
@@ -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("<environment_details>"),
)
}
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("<environment_details>example</environment_details>", 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)
})
})
@@ -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),
)
@@ -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")
})
})
@@ -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")
})
})
@@ -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",