Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/quiet-agent-tabs.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"kilo-code": patch
---

Keep Task tool subagents out of Agent Manager tabs.
30 changes: 25 additions & 5 deletions packages/kilo-vscode/src/KiloProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -787,6 +787,19 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
return this.sessionDirectories
}

public async getSessionInfo(sessionId: string): Promise<Session | undefined> {
await this.initializeConnection()
const client = this.client
if (!client) return
const directory = this.getWorkspaceDirectory(sessionId)
return retry(() => client.session.get({ sessionID: sessionId, directory }, { throwOnError: true }))
.then((result) => result.data)
.catch((error: unknown) => {
console.warn("[Kilo New] KiloProvider: Failed to resolve managed session:", error)
return undefined
})
}

/** Return the currently active session ID, if any. */
public getCurrentSessionId(): string | undefined {
return this.currentSession?.id ?? undefined
Expand Down Expand Up @@ -1917,11 +1930,13 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper

try {
const workspaceDir = this.getWorkspaceDirectory(sessionID)
const { data: messagesData } = await retry(() =>
this.client!.session.messages({ sessionID, directory: workspaceDir }, { throwOnError: true }),
)
const [info, history] = await Promise.all([
retry(() => this.client!.session.get({ sessionID, directory: workspaceDir }, { throwOnError: true })),
retry(() => this.client!.session.messages({ sessionID, directory: workspaceDir }, { throwOnError: true })),
])
this.postMessage({ type: "sessionUpdated", session: this.sessionToWebview(info.data) })

const messages = messagesData.map((m) => ({
const messages = history.data.map((m) => ({
...this.slimInfo(m.info),
parts: this.slimParts(m.parts),
createdAt: new Date(m.info.time.created).toISOString(),
Expand Down Expand Up @@ -4302,7 +4317,12 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
}

private matchesPendingFollowup(session: Session) {
return matchFollowup({ pending: this.pendingFollowup, dir: session.directory, now: Date.now() })
return matchFollowup({
pending: this.pendingFollowup,
dir: session.directory,
now: Date.now(),
parentID: session.parentID,
})
}

private adoptPendingFollowup(session: Session) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import { createLocalDiff, diffSummary as localDiffSummary } from "./local-diff"
import { parseToolRequest, startFromTool, type ToolRequest } from "./tool-start"
import { stopSessionProcesses } from "../kilo-provider/background-process"
import { sandboxSessionMetadata } from "../shared/sandbox-session"
import { pruneSubagents } from "./prune-subagents"

import { startSession } from "./mcp-warmup"
import { readTerminalFont, watchTerminalFont } from "./terminal-font"
Expand Down Expand Up @@ -318,12 +319,10 @@ export class AgentManagerProvider implements Disposable {
}

for (const wt of state.getWorktrees()) {
for (const s of state.getSessions(wt.id)) {
this.panel?.sessions.setSessionDirectory(s.id, wt.path)
this.panel?.sessions.trackSession(s.id)
}
for (const s of state.getSessions(wt.id)) this.panel?.sessions.setSessionDirectory(s.id, wt.path)
}
for (const s of state.getSessions()) if (!s.worktreeId) this.panel?.sessions.trackSession(s.id)
await pruneSubagents(state, this.panel?.sessions, (message) => this.log(message))
for (const s of state.getSessions()) this.panel?.sessions.trackSession(s.id)
this.pushState()

// Refresh sessions so worktree sessions appear in the list
Expand Down
1 change: 1 addition & 0 deletions packages/kilo-vscode/src/agent-manager/host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ export interface SessionProvider {
setSessionDirectory(id: string, directory: string): void
clearSessionDirectory(id: string): void
getSessionDirectories(): ReadonlyMap<string, string>
getSessionInfo?(id: string): Promise<Session | undefined>
trackSession(id: string): void
refreshSessions(): void
registerSession(session: Session): void
Expand Down
20 changes: 20 additions & 0 deletions packages/kilo-vscode/src/agent-manager/prune-subagents.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import type { SessionProvider } from "./host"
import type { WorktreeStateManager } from "./WorktreeStateManager"

export async function pruneSubagents(
state: WorktreeStateManager,
sessions: SessionProvider | undefined,
log: (message: string) => void,
): Promise<void> {
const get = sessions?.getSessionInfo
if (!sessions || !get) return
const managed = state.getSessions()
const infos = await Promise.all(managed.map(async (item) => ({ item, info: await get(item.id) })))
for (const result of infos) {
const parent = result.info?.parentID
if (parent === undefined || parent === null) continue
state.removeSession(result.item.id)
sessions.clearSessionDirectory(result.item.id)
log(`Removed subagent session ${result.item.id} from managed state`)
}
}
1 change: 1 addition & 0 deletions packages/kilo-vscode/src/agent-manager/vscode-host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,7 @@ export class VscodeHost implements Host {
setSessionDirectory: (id, dir) => provider.setSessionDirectory(id, dir),
clearSessionDirectory: (id) => provider.clearSessionDirectory(id),
getSessionDirectories: () => provider.getSessionDirectories(),
getSessionInfo: (id) => provider.getSessionInfo(id),
trackSession: (id) => provider.trackSession(id),
refreshSessions: () => provider.refreshSessions(),
registerSession: (s) => provider.registerSession(s),
Expand Down
8 changes: 7 additions & 1 deletion packages/kilo-vscode/src/kilo-provider/followup-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,13 @@ export function recordFollowup(input: { answers: string[][]; dir: string; now: n
return { dir: input.dir, time: input.now }
}

export function matchFollowup(input: { pending: Followup | null; dir: string; now: number }): boolean {
export function matchFollowup(input: {
pending: Followup | null
dir: string
now: number
parentID?: string | null
}): boolean {
if (input.parentID !== undefined && input.parentID !== null) return false
const item = input.pending
if (!item) return false
if (input.now - item.time > TTL) return false
Expand Down
7 changes: 7 additions & 0 deletions packages/kilo-vscode/tests/unit/followup-session.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,4 +35,11 @@ describe("followup-session", () => {
expect(matchFollowup({ pending, dir: "c:/repo/.kilo/worktrees/other", now: 2 })).toBe(false)
expect(matchFollowup({ pending, dir: "c:/repo/.kilo/worktrees/feature", now: 30_002 })).toBe(false)
})

it("never matches a subagent session", () => {
const pending = { dir: "/repo", time: 1 }

expect(matchFollowup({ pending, dir: "/repo", now: 2, parentID: "root" })).toBe(false)
expect(matchFollowup({ pending, dir: "/repo", now: 2, parentID: "" })).toBe(false)
})
})
15 changes: 13 additions & 2 deletions packages/kilo-vscode/tests/unit/kilo-provider-followup.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,10 +24,11 @@ type Internals = {
startStatsPolling: () => void
}

function created(input: { id: string; directory: string }): Event {
function created(input: { id: string; directory: string; parentID?: string }): Event {
return {
type: "session.created",
properties: {
sessionID: input.id,
info: {
id: input.id,
slug: `${input.id}-slug`,
Expand All @@ -36,6 +37,7 @@ function created(input: { id: string; directory: string }): Event {
title: "Session",
version: "1",
time: { created: 1, updated: 1 },
parentID: input.parentID,
},
},
} as Event
Expand Down Expand Up @@ -78,7 +80,7 @@ function connection() {
}

describe("KiloProvider follow-up sessions", () => {
it("adopts pending follow-up sessions for single-session views", async () => {
it("ignores subagents before adopting pending follow-up sessions", async () => {
const service = connection()
const provider = new KiloProvider({} as never, service as never)
const internal = provider as unknown as Internals
Expand Down Expand Up @@ -111,6 +113,15 @@ describe("KiloProvider follow-up sessions", () => {
loaded.push(sessionID)
}

service.emit(created({ id: "ses-child", directory: "/repo", parentID: "ses-parent" }))
await Promise.resolve()

expect(internal.currentSession).toBeNull()
expect(internal.trackedSessionIds.has("ses-child")).toBe(false)
expect(internal.pendingFollowup).not.toBeNull()
expect(loaded).toEqual([])
expect(sent).toEqual([])

service.emit(created({ id: "ses-followup", directory: "/repo" }))
await Promise.resolve()

Expand Down
47 changes: 47 additions & 0 deletions packages/kilo-vscode/tests/unit/local-tabs.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
restoreTabs,
restoreTrackedTabs,
showTabStrip,
trackedSessionInventory,
type LocalTabState,
} from "../../webview-ui/src/utils/local-tabs"
import { reorderTabs } from "../../webview-ui/src/utils/tab-order"
Expand Down Expand Up @@ -43,6 +44,21 @@ const reorder = (items: { id: string }[], order: string[]) => {
return result
}
const inventory = (local: string[], external: string[] = []) => ({ local, external: new Set(external) })
const tracked = () =>
trackedSessionInventory(
[
{ id: "local", worktreeId: null },
{ id: "worktree", worktreeId: "wt-1" },
{ id: "sparse", worktreeId: null },
{ id: "child", worktreeId: "wt-1" },
],
[
{ id: "local", parentID: null },
{ id: "worktree", parentID: null },
{ id: "sparse" },
{ id: "child", parentID: "root" },
],
)

describe("local session tabs", () => {
it("hides the tab strip when only one tab remains", () => {
Expand Down Expand Up @@ -167,6 +183,16 @@ describe("shared close selection", () => {
})

describe("tracked tab restore", () => {
it("restores only sessions with known root ancestry", () => {
expect(restoreTrackedTabs(tracked(), [], undefined, trackedPending, identity)).toEqual(["local"])
})

it("evicts sparse and child sessions from restored tabs", () => {
expect(restoreTrackedTabs(tracked(), ["local", "sparse", "child"], undefined, trackedPending, identity)).toEqual([
"local",
])
})

it("restores durable local sessions when the current list has no real tabs", () => {
expect(restoreTrackedTabs(inventory(["s1", "s2"]), [], undefined, trackedPending, identity)).toEqual(["s1", "s2"])
})
Expand Down Expand Up @@ -202,6 +228,27 @@ describe("tracked tab restore", () => {
})

describe("tracked tab reconcile", () => {
it("evicts sparse sessions without forgetting them", () => {
const data = trackedSessionInventory(
[
{ id: "local", worktreeId: null },
{ id: "sparse", worktreeId: null },
],
[{ id: "local", parentID: null }, { id: "sparse" }],
)
expect(reconcileTrackedTabs(["local", "sparse"], ["local"], data, trackedPending)).toEqual({
ids: ["local"],
forget: [],
})
})

it("forgets explicit child sessions even when they only exist in managed state", () => {
expect(reconcileTrackedTabs(["local"], ["local"], tracked(), trackedPending)).toEqual({
ids: ["local"],
forget: ["child"],
})
})

it("preserves durable local sessions before loaded sessions include them", () => {
expect(reconcileTrackedTabs(["s1", "s2"], [], inventory(["s1", "s2"]), trackedPending)).toBeUndefined()
})
Expand Down
22 changes: 17 additions & 5 deletions packages/kilo-vscode/tests/unit/navigate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
resolveNavigation,
validateLocalSession,
adjacentHint,
canOpenRootSession,
filterUnassignedSessions,
remoteSessions,
LOCAL,
Expand Down Expand Up @@ -190,16 +191,16 @@ describe("adjacentHint", () => {

describe("filterUnassignedSessions", () => {
const at = (day: number) => `2026-01-${String(day).padStart(2, "0")}T00:00:00.000Z`
const info = (id: string, day: number, parentID?: string | null) => ({
const info = (id: string, day: number, parentID: string | null = null) => ({
id,
createdAt: at(day),
...(parentID === undefined ? {} : { parentID }),
parentID,
})

it("keeps root sessions with undefined parent IDs", () => {
const result = filterUnassignedSessions([info("old", 1), info("new", 3)], new Set(), new Set())
it("filters sparse session updates until ancestry is known", () => {
const result = filterUnassignedSessions([{ id: "unknown", createdAt: at(1) }], new Set(), new Set())

expect(result.map((s) => s.id)).toEqual(["new", "old"])
expect(result).toEqual([])
})

it("keeps root sessions with null parent IDs", () => {
Expand Down Expand Up @@ -287,6 +288,17 @@ describe("filterUnassignedSessions", () => {
})
})

describe("canOpenRootSession", () => {
const sessions = [{ id: "root", parentID: null }, { id: "child", parentID: "root" }, { id: "sparse" }]

it("only opens sessions with known root ancestry", () => {
expect(canOpenRootSession("root", sessions)).toBe(true)
expect(canOpenRootSession("child", sessions)).toBe(false)
expect(canOpenRootSession("sparse", sessions)).toBe(false)
expect(canOpenRootSession("missing", sessions)).toBe(false)
})
})

describe("remoteSessions", () => {
const pending = (id: string) => id.startsWith("pending:")

Expand Down
Loading
Loading