fix(vscode): guard subagent promotion edge cases

This commit is contained in:
marius-kilocode
2026-08-25 15:35:44 +02:00
parent a79c2f36ac
commit e748515a2c
17 changed files with 142 additions and 31 deletions
@@ -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.
+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(),
+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)
@@ -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", () => {
@@ -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) {
@@ -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
}
@@ -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)
@@ -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",