mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-09-01 15:32:11 +08:00
fix(vscode): guard subagent promotion edge cases
This commit is contained in:
@@ -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.
|
||||
@@ -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(),
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user