Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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 @@ -788,6 +788,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 @@ -1804,11 +1817,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 @@ -3872,7 +3887,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
23 changes: 18 additions & 5 deletions packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -294,12 +294,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 this.pruneSubagents(state)
for (const s of state.getSessions()) this.panel?.sessions.trackSession(s.id)
this.pushState()

// Refresh sessions so worktree sessions appear in the list
Expand All @@ -314,6 +312,21 @@ export class AgentManagerProvider implements Disposable {
this.panel?.sessions.recoverPendingPrompts()
}

private async pruneSubagents(state: WorktreeStateManager): Promise<void> {
const sessions = this.panel?.sessions
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)
this.log(`Removed subagent session ${result.item.id} from managed state`)
}
}

private async ensureGitExclude(manager: WorktreeManager): Promise<void> {
await manager.ensureGitExclude().catch((err) => {
this.log("Failed to update git exclude:", err)
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
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 @@ -77,7 +79,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 @@ -110,6 +112,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
104 changes: 93 additions & 11 deletions packages/kilo-vscode/tests/unit/navigate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
restoreLocalSessions,
reconcileLocalSessions,
filterUnassignedSessions,
admitCreatedSession,
remoteSessions,
LOCAL,
} from "../../webview-ui/agent-manager/navigate"
Expand Down Expand Up @@ -192,16 +193,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 @@ -289,6 +290,31 @@ describe("filterUnassignedSessions", () => {
})
})

describe("admitCreatedSession", () => {
const local = ["local", "pending"]
const worktree = new Set(["worktree"])

it("admits new root sessions and resolves their pending draft", () => {
expect(admitCreatedSession({ id: "root", parentID: null }, "pending", local, worktree)).toEqual({
pending: "pending",
})
expect(admitCreatedSession({ id: "root", parentID: null }, undefined, local, worktree)).toEqual({
pending: undefined,
})
})

it("rejects subagents and sparse updates before they can become tabs", () => {
expect(admitCreatedSession({ id: "unknown" }, undefined, local, worktree)).toBeUndefined()
expect(admitCreatedSession({ id: "child", parentID: "root" }, undefined, local, worktree)).toBeUndefined()
expect(admitCreatedSession({ id: "child", parentID: "" }, undefined, local, worktree)).toBeUndefined()
})

it("rejects existing local and worktree sessions", () => {
expect(admitCreatedSession({ id: "local", parentID: null }, undefined, local, worktree)).toBeUndefined()
expect(admitCreatedSession({ id: "worktree", parentID: null }, undefined, local, worktree)).toBeUndefined()
})
})

describe("restoreLocalSessions", () => {
const identity = (items: { id: string }[], _order: string[]) => items
const isPending = (id: string) => id.startsWith("pending-")
Expand Down Expand Up @@ -423,6 +449,8 @@ describe("remoteSessions", () => {

describe("reconcileLocalSessions", () => {
const isPending = (id: string) => id.startsWith("pending-")
const loaded = (...ids: string[]) => ids.map((id) => ({ id, parentID: null }))
const sparse = (...ids: string[]) => ids.map((id) => ({ id }))

it("keeps restored local sessions through a partial restart refresh", () => {
const managed = [
Expand All @@ -431,7 +459,7 @@ describe("reconcileLocalSessions", () => {
]
const restored = restoreLocalSessions(managed, [], undefined, isPending, (items) => items)?.filter(Boolean) ?? []

const result = reconcileLocalSessions(restored, ["worktree-1"], managed, isPending)
const result = reconcileLocalSessions(restored, loaded("worktree-1"), managed, isPending)

expect(restored).toEqual(["local-1"])
expect(result).toBeUndefined()
Expand All @@ -454,7 +482,7 @@ describe("reconcileLocalSessions", () => {
it("does not forget persisted local sessions when only worktree sessions loaded", () => {
const result = reconcileLocalSessions(
["local-1"],
["worktree-1"],
loaded("worktree-1"),
[
{ id: "local-1", worktreeId: null },
{ id: "worktree-1", worktreeId: "wt-1" },
Expand All @@ -466,10 +494,10 @@ describe("reconcileLocalSessions", () => {
})

it("waits for managed state before removing sessions restored from webview state", () => {
const beforeState = reconcileLocalSessions(["local-1"], ["worktree-1"], [], isPending)
const beforeState = reconcileLocalSessions(["local-1"], loaded("worktree-1"), [], isPending)
const afterState = reconcileLocalSessions(
["local-1"],
["worktree-1"],
loaded("worktree-1"),
[
{ id: "local-1", worktreeId: null },
{ id: "worktree-1", worktreeId: "wt-1" },
Expand All @@ -482,15 +510,15 @@ describe("reconcileLocalSessions", () => {
})

it("forgets stale local sessions missing from loaded and managed state", () => {
const result = reconcileLocalSessions(["s1", "gone"], ["s1"], [{ id: "s1", worktreeId: null }], isPending)
const result = reconcileLocalSessions(["s1", "gone"], loaded("s1"), [{ id: "s1", worktreeId: null }], isPending)

expect(result).toEqual({ ids: ["s1"], forget: ["gone"] })
})

it("evicts worktree sessions that raced into local state without forgetting them", () => {
const result = reconcileLocalSessions(
["local-1", "worktree-1"],
["local-1", "worktree-1"],
loaded("local-1", "worktree-1"),
[
{ id: "local-1", worktreeId: null },
{ id: "worktree-1", worktreeId: "wt-1" },
Expand All @@ -501,6 +529,60 @@ describe("reconcileLocalSessions", () => {
expect(result).toEqual({ ids: ["local-1"], forget: [] })
})

it("evicts sparse local sessions until ancestry is known", () => {
const result = reconcileLocalSessions(["child"], sparse("child"), [{ id: "child", worktreeId: null }], isPending)

expect(result).toEqual({ ids: [], forget: [] })
})

it("does not forget sparse managed sessions until ancestry is known", () => {
const result = reconcileLocalSessions(
["root"],
sparse("child"),
[
{ id: "root", worktreeId: null },
{ id: "child", worktreeId: "wt-1" },
],
isPending,
)

expect(result).toBeUndefined()
})

it("evicts and forgets a subagent leaked into local tabs", () => {
const result = reconcileLocalSessions(
["root", "child"],
[
{ id: "root", parentID: null },
{ id: "child", parentID: "root" },
],
[
{ id: "root", worktreeId: null },
{ id: "child", worktreeId: null },
],
isPending,
)

expect(result).toEqual({ ids: ["root"], forget: ["child"] })
})

it("forgets a subagent leaked into a worktree", () => {
const result = reconcileLocalSessions(
["root"],
[
{ id: "root", parentID: null },
{ id: "child", parentID: "root" },
],
[
{ id: "root", worktreeId: null },
{ id: "child", worktreeId: "wt-1" },
],
isPending,
)

expect(result).toEqual({ ids: ["root"], forget: ["child"] })
})

it("keeps pending local tabs during reconciliation", () => {
const result = reconcileLocalSessions(["pending-1", "gone"], [], [], isPending)

Expand Down
Loading
Loading