fix: keep plan follow-up sessions in the same worktree (#7945)

* fix: keep plan follow-up sessions in the same worktree

Route follow-up question replies by the owning session and create child implementation sessions from the planning session directory so "Start new session" stays visible and runs in the right worktree.

Refs #6339

* fix: keep worktree follow-up sessions reloadable

Adopt backend-created worktree sessions by normalized directory matching and keep plan follow-up sessions as roots so Agent Manager still restores them after reload.\n\nRefs #6339

* fix(vscode): track plan follow-up sessions in the sidebar

Record pending "Start new session" replies in single-session providers so the follow-up session.created event can be adopted and opened in the sidebar and regular tabs, while Agent Manager keeps using its own worktree adoption path.\n\nRefs #6339

* fix(vscode): forward pending follow-up session events

Allow pending plan follow-up session.created events through the KiloProvider SSE filter so sidebar and tab providers can adopt and open them before the new session is tracked.\n\nRefs #6339

* fix(agent-manager): open tab for adopted follow-up sessions

Navigate to the correct worktree or add a local tab when Agent Manager adopts a backend-created follow-up session, matching the existing sessionForked behavior.\n\nRefs #6339

* refactor(vscode): unify follow-up session adoption across all providers

Remove the separate adopt-session mechanism for Agent Manager and use the same KiloProvider follow-up path that sidebar and tabs use. The sessionCreated webview handler now works regardless of the current sidebar selection, so follow-up sessions appear as local tabs everywhere.\n\nRefs #6339

* test: align followup test with parentID field from main
This commit is contained in:
Marius
2026-04-01 14:02:47 +02:00
committed by GitHub
parent c9d4d1ac87
commit a2fc2ad936
12 changed files with 485 additions and 44 deletions
+47 -2
View File
@@ -39,6 +39,7 @@ import { MarketplaceService } from "./services/marketplace"
import { resolveProjectDirectory } from "./project-directory"
import { getBusySessionCount, seedSessionStatuses } from "./session-status"
import { slimPart, slimParts } from "./kilo-provider/slim-metadata"
import { matchFollowup, recordFollowup, type Followup } from "./kilo-provider/followup-session"
// legacy-migration start
import {
checkAndShowMigrationWizard,
@@ -154,6 +155,8 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
private chatAutocomplete: ChatTextAreaAutocomplete | null = null
private projectDirectory: string | null | undefined
private slimEditMetadata = true
private pendingFollowup: Followup | null = null
/** Worktree diff stats poller for the sidebar badge — reuses GitStatsPoller (local stats only) */
private statsPoller: GitStatsPoller | null = null
private cachedStats: unknown = null
@@ -175,6 +178,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
) {
this.projectDirectory = options?.projectDirectory
this.slimEditMetadata = options?.slimEditMetadata ?? true
TelemetryProxy.getInstance().setProvider(this)
}
@@ -675,10 +679,14 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
break
case "questionReply":
await handleQuestionReply(this.questionCtx, message.requestID, message.answers)
this.noteFollowup(message.answers, message.sessionID)
if (!(await handleQuestionReply(this.questionCtx, message.requestID, message.answers, message.sessionID))) {
this.pendingFollowup = null
}
break
case "questionReject":
await handleQuestionReject(this.questionCtx, message.requestID)
this.pendingFollowup = null
await handleQuestionReject(this.questionCtx, message.requestID, message.sessionID)
break
case "requestConfig":
this.fetchAndSendConfig().catch((e) => console.error("[Kilo New] fetchAndSendConfig failed:", e))
@@ -972,6 +980,10 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
return event.type !== "message.part.updated" && event.type !== "message.part.delta"
}
if (event.type === "session.created" && this.matchesPendingFollowup(event.properties.info)) {
return true
}
// session.status must always pass through — even for sessions not tracked by this
// KiloProvider instance. The Settings panel is a separate provider with no tracked
// sessions, but it needs session.status to populate sessionStatusMap and allStatusMap
@@ -2515,6 +2527,10 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
}
// Extract sessionID from the event
if (event.type === "session.created" && this.adoptPendingFollowup(event.properties.info)) {
return
}
const sessionID = this.connectionService.resolveEventSessionId(event)
// Events without sessionID (server.connected, server.heartbeat) → always forward
@@ -2773,6 +2789,35 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
this.sessionDirectories.set(sessionId, dir)
}
private noteFollowup(answers: string[][], sessionID?: string) {
const dir = this.getWorkspaceDirectory(sessionID)
this.pendingFollowup = recordFollowup({ answers, dir, now: Date.now() }) ?? null
}
private matchesPendingFollowup(session: Session) {
return matchFollowup({ pending: this.pendingFollowup, dir: session.directory, now: Date.now() })
}
private adoptPendingFollowup(session: Session) {
const now = Date.now()
const match = this.matchesPendingFollowup(session)
if (!match) {
if (
this.pendingFollowup &&
!matchFollowup({ pending: this.pendingFollowup, dir: this.pendingFollowup.dir, now })
) {
this.pendingFollowup = null
}
return false
}
this.pendingFollowup = null
this.trackDirectory(session.id, session.directory)
this.registerSession(session)
void this.handleLoadMessages(session.id)
return true
}
private getProjectDirectory(sessionId?: string): string | undefined {
return resolveProjectDirectory(this.projectDirectory, () => this.getWorkspaceDirectory(sessionId))
}
@@ -19,6 +19,7 @@ import { createTerminalHost } from "./terminal-host"
import { executeVscodeTask } from "./task-runner"
import { forkSession } from "./fork-session"
import { continueInWorktree } from "./continue-in-worktree"
import { shouldStopDiffPolling } from "./delete-worktree"
import { buildKeybindingMap } from "./format-keybinding"
import { resolveVersionModels, buildInitialMessages, type CreatedVersion } from "./multi-version"
@@ -58,11 +59,11 @@ export class AgentManagerProvider implements Disposable {
private cachedWorktreeStats: AgentManagerOutMessage | undefined
private cachedLocalStats: AgentManagerOutMessage | undefined
private applyingWorktreeId: string | undefined
/** Session ID most recently loaded via a `loadMessages` message from the webview.
* Updated synchronously — unlike the session provider's currentSession which depends on
* an async `session.get` round-trip and can be stale during rapid tab switches. */
private activeSessionId: string | undefined
constructor(
private readonly host: Host,
private readonly connectionService: KiloConnectionService,
@@ -0,0 +1,25 @@
import { normalizePath } from "../agent-manager/git-import"
const TTL = 30_000
const LABEL = "Start new session"
export interface Followup {
dir: string
time: number
}
export function recordFollowup(input: { answers: string[][]; dir: string; now: number }): Followup | undefined {
const answer = input.answers[0]?.[0]?.trim()
if (answer !== LABEL) return
return {
dir: input.dir,
time: input.now,
}
}
export function matchFollowup(input: { pending: Followup | null; dir: string; now: number }): boolean {
const item = input.pending
if (!item) return false
if (input.now - item.time > TTL) return false
return normalizePath(item.dir) === normalizePath(input.dir)
}
@@ -15,37 +15,51 @@ interface QuestionContext {
}
/** Handle question reply from the webview. */
export async function handleQuestionReply(ctx: QuestionContext, requestID: string, answers: string[][]): Promise<void> {
export async function handleQuestionReply(
ctx: QuestionContext,
requestID: string,
answers: string[][],
sessionID?: string,
): Promise<boolean> {
if (!ctx.client) {
ctx.postMessage({ type: "questionError", requestID })
return
return false
}
const sid = sessionID ?? ctx.currentSessionId
try {
await ctx.client.question.reply(
{ requestID, answers, directory: ctx.getWorkspaceDirectory(ctx.currentSessionId) },
{ requestID, answers, directory: ctx.getWorkspaceDirectory(sid) },
{ throwOnError: true },
)
return true
} catch (error) {
console.error("[Kilo New] KiloProvider: Failed to reply to question:", error)
ctx.postMessage({ type: "questionError", requestID })
return false
}
}
/** Handle question reject (dismiss) from the webview. */
export async function handleQuestionReject(ctx: QuestionContext, requestID: string): Promise<void> {
export async function handleQuestionReject(
ctx: QuestionContext,
requestID: string,
sessionID?: string,
): Promise<boolean> {
if (!ctx.client) {
ctx.postMessage({ type: "questionError", requestID })
return
return false
}
const sid = sessionID ?? ctx.currentSessionId
try {
await ctx.client.question.reject(
{ requestID, directory: ctx.getWorkspaceDirectory(ctx.currentSessionId) },
{ throwOnError: true },
)
await ctx.client.question.reject({ requestID, directory: ctx.getWorkspaceDirectory(sid) }, { throwOnError: true })
return true
} catch (error) {
console.error("[Kilo New] KiloProvider: Failed to reject question:", error)
ctx.postMessage({ type: "questionError", requestID })
return false
}
}
@@ -0,0 +1,38 @@
import { describe, expect, it } from "bun:test"
import { matchFollowup, recordFollowup } from "../../src/kilo-provider/followup-session"
describe("followup-session", () => {
it("records a pending follow-up for Start new session replies", () => {
const pending = recordFollowup({
answers: [["Start new session"]],
dir: "/repo",
now: 1,
})
expect(pending).toEqual({
dir: "/repo",
time: 1,
})
})
it("ignores other question replies", () => {
const pending = recordFollowup({
answers: [["Continue here"]],
dir: "/repo",
now: 1,
})
expect(pending).toBeUndefined()
})
it("matches pending follow-ups by normalized directory before expiry", () => {
const pending = {
dir: "c:/repo/.kilo/worktrees/feature",
time: 1,
}
expect(matchFollowup({ pending, dir: "C:\\repo\\.kilo\\worktrees\\feature\\", now: 2 })).toBe(true)
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)
})
})
@@ -0,0 +1,129 @@
import { describe, expect, it } from "bun:test"
import type { Event, Session } from "@kilocode/sdk/v2/client"
// vscode mock is provided by the shared preload (tests/setup/vscode-mock.ts)
const { KiloProvider } = await import("../../src/KiloProvider")
type Internals = {
webview: { postMessage: (message: unknown) => Promise<unknown> } | null
trackedSessionIds: Set<string>
currentSession: Session | null
pendingFollowup: { dir: string; time: number } | null
handleLoadMessages: (sessionID: string) => Promise<void>
initializeConnection: () => Promise<void>
syncWebviewState: () => Promise<void>
flushPendingSessionRefresh: () => Promise<void>
fetchAndSendProviders: () => Promise<void>
fetchAndSendAgents: () => Promise<void>
fetchAndSendSkills: () => Promise<void>
fetchAndSendCommands: () => Promise<void>
fetchAndSendConfig: () => Promise<void>
fetchAndSendNotifications: () => Promise<void>
seedSessionStatusMap: () => Promise<void>
sendNotificationSettings: () => void
startStatsPolling: () => void
}
function created(input: { id: string; directory: string }): Event {
return {
type: "session.created",
properties: {
info: {
id: input.id,
slug: `${input.id}-slug`,
projectID: "project-1",
directory: input.directory,
title: "Session",
version: "1",
time: { created: 1, updated: 1 },
},
},
} as Event
}
function connection() {
let filter: ((event: Event) => boolean) | undefined
let listener: ((event: Event) => void) | undefined
return {
emit(event: Event) {
if (!filter || !listener) throw new Error("expected SSE subscription")
if (!filter(event)) return
listener(event)
},
connect: async () => {},
getClient: () => ({}) as never,
onEventFiltered: (next: (event: Event) => boolean, cb: (event: Event) => void) => {
filter = next
listener = cb
return () => undefined
},
onStateChange: () => () => undefined,
onNotificationDismissed: () => () => undefined,
onLanguageChanged: () => () => undefined,
onProfileChanged: () => () => undefined,
onMigrationComplete: () => () => undefined,
getServerInfo: () => ({ port: 12345 }),
getConnectionState: () => "connected" as const,
resolveEventSessionId: (event: Event) => (event.type === "session.created" ? event.properties.info.id : undefined),
recordMessageSessionId: () => undefined,
notifyNotificationDismissed: () => undefined,
}
}
describe("KiloProvider follow-up sessions", () => {
it("adopts pending follow-up sessions for single-session views", async () => {
const service = connection()
const provider = new KiloProvider({} as never, service as never)
const internal = provider as unknown as Internals
const sent: unknown[] = []
const loaded: string[] = []
internal.webview = {
postMessage: async (message: unknown) => {
sent.push(message)
return true
},
}
internal.syncWebviewState = async () => {}
internal.flushPendingSessionRefresh = async () => {}
internal.fetchAndSendProviders = async () => {}
internal.fetchAndSendAgents = async () => {}
internal.fetchAndSendSkills = async () => {}
internal.fetchAndSendCommands = async () => {}
internal.fetchAndSendConfig = async () => {}
internal.fetchAndSendNotifications = async () => {}
internal.seedSessionStatusMap = async () => {}
internal.sendNotificationSettings = () => {}
internal.startStatsPolling = () => {}
await internal.initializeConnection()
sent.length = 0
internal.pendingFollowup = { dir: "/repo", time: Date.now() }
internal.handleLoadMessages = async (sessionID: string) => {
loaded.push(sessionID)
}
service.emit(created({ id: "ses-followup", directory: "/repo" }))
await Promise.resolve()
expect(internal.currentSession?.id).toBe("ses-followup")
expect(internal.trackedSessionIds.has("ses-followup")).toBe(true)
expect(loaded).toEqual(["ses-followup"])
expect(sent).toEqual([
{
type: "sessionCreated",
session: {
id: "ses-followup",
title: "Session",
createdAt: new Date(1).toISOString(),
updatedAt: new Date(1).toISOString(),
parentID: null,
revert: null,
summary: null,
},
},
])
})
})
@@ -0,0 +1,104 @@
import { describe, expect, it } from "bun:test"
import type { KiloClient } from "@kilocode/sdk/v2/client"
import { handleQuestionReject, handleQuestionReply } from "../../src/kilo-provider/handlers/question"
describe("question handlers", () => {
it("routes replies using the question session when provided", async () => {
const calls: Array<Record<string, unknown>> = []
const client = {
question: {
reply: async (input: Record<string, unknown>) => {
calls.push(input)
return true
},
reject: async () => true,
},
} as unknown as KiloClient
const ok = await handleQuestionReply(
{
client,
currentSessionId: "ses-root",
postMessage() {},
getWorkspaceDirectory(sessionId) {
return sessionId ? `/repo/${sessionId}` : "/repo"
},
},
"req-1",
[["Start new session"]],
"ses-worktree",
)
expect(ok).toBe(true)
expect(calls).toEqual([
{
requestID: "req-1",
answers: [["Start new session"]],
directory: "/repo/ses-worktree",
},
])
})
it("falls back to the current session when no question session is provided", async () => {
const calls: Array<Record<string, unknown>> = []
const client = {
question: {
reply: async (input: Record<string, unknown>) => {
calls.push(input)
return true
},
reject: async () => true,
},
} as unknown as KiloClient
const ok = await handleQuestionReply(
{
client,
currentSessionId: "ses-root",
postMessage() {},
getWorkspaceDirectory(sessionId) {
return sessionId ? `/repo/${sessionId}` : "/repo"
},
},
"req-2",
[["Continue here"]],
)
expect(ok).toBe(true)
expect(calls[0]?.directory).toBe("/repo/ses-root")
})
it("routes rejects using the question session when provided", async () => {
const calls: Array<Record<string, unknown>> = []
const client = {
question: {
reply: async () => true,
reject: async (input: Record<string, unknown>) => {
calls.push(input)
return true
},
},
} as unknown as KiloClient
const ok = await handleQuestionReject(
{
client,
currentSessionId: "ses-root",
postMessage() {},
getWorkspaceDirectory(sessionId) {
return sessionId ? `/repo/${sessionId}` : "/repo"
},
},
"req-3",
"ses-worktree",
)
expect(ok).toBe(true)
expect(calls).toEqual([
{
requestID: "req-3",
directory: "/repo/ses-worktree",
},
])
})
})
@@ -1084,20 +1084,24 @@ const AgentManagerContent: Component = () => {
}
window.addEventListener("focus", onWindowFocus)
// When a session is created while on local, replace the current pending tab with the real session.
// Guard against duplicate sessionCreated events (HTTP response + SSE can both fire).
// When a session is created, add it as a local tab. This handles both direct
// creation from the prompt input and backend-created follow-up sessions (plan
// follow-up "Start new session"). Guard against duplicates (HTTP + SSE can both fire).
const unsubCreate = vscode.onMessage((msg) => {
if (msg.type === "sessionCreated" && selection() === LOCAL) {
const created = msg as { type: string; session: { id: string } }
if (localSessionIDs().includes(created.session.id)) return
const pending = activePendingId()
if (pending) {
setLocalSessionIDs((prev) => prev.map((id) => (id === pending ? created.session.id : id)))
setActivePendingId(undefined)
} else {
setLocalSessionIDs((prev) => [...prev, created.session.id])
}
if (msg.type !== "sessionCreated") return
const created = msg as { type: string; session: { id: string } }
if (localSessionIDs().includes(created.session.id)) return
if (worktreeSessionIds().has(created.session.id)) return
const pending = selection() === LOCAL ? activePendingId() : undefined
if (pending) {
setLocalSessionIDs((prev) => prev.map((id) => (id === pending ? created.session.id : id)))
setActivePendingId(undefined)
} else {
saveTabMemory()
setLocalSessionIDs((prev) => [...prev, created.session.id])
setSelection(LOCAL)
}
session.selectSession(created.session.id)
})
// Mark sessions loaded as soon as the session context receives data (even if empty)
@@ -1157,6 +1161,8 @@ const AgentManagerContent: Component = () => {
if (msg.type === "agentManager.sessionAdded") {
const ev = msg as { type: string; sessionId: string; worktreeId: string }
saveTabMemory()
setSelection(ev.worktreeId)
session.selectSession(ev.sessionId)
}
@@ -1450,18 +1450,24 @@ export const SessionProvider: ParentComponent = (props) => {
function replyToQuestion(requestID: string, answers: string[][]) {
clearQuestionError(requestID)
const question = questions().find((item) => item.id === requestID)
const sessionID = question?.sessionID ?? currentSessionID() ?? ""
vscode.postMessage({
type: "questionReply",
requestID,
sessionID,
answers,
})
}
function rejectQuestion(requestID: string) {
clearQuestionError(requestID)
const question = questions().find((item) => item.id === requestID)
const sessionID = question?.sessionID ?? currentSessionID() ?? ""
vscode.postMessage({
type: "questionReject",
requestID,
sessionID,
})
}
@@ -1562,12 +1562,14 @@ export interface SetLanguageRequest {
export interface QuestionReplyRequest {
type: "questionReply"
requestID: string
sessionID?: string
answers: string[][]
}
export interface QuestionRejectRequest {
type: "questionReject"
requestID: string
sessionID?: string
}
export interface DeleteSessionRequest {
+29 -17
View File
@@ -5,6 +5,7 @@ import { TuiEvent } from "@/cli/cmd/tui/event"
import { Flag } from "@/flag/flag"
import { Global } from "@/global"
import { Identifier } from "@/id/id"
import { Instance } from "@/project/instance"
import { Provider } from "@/provider/provider"
import { Question } from "@/question"
import { Session } from "@/session"
@@ -266,6 +267,7 @@ export namespace PlanFollowup {
model: input.model,
variant: input.variant,
})
const session = await Session.get(input.sessionID)
const [handover, todos] = await Promise.all([
generateHandover({ messages: input.messages, model: input.model, abort: input.abort }),
Todo.get(input.sessionID),
@@ -282,24 +284,34 @@ export namespace PlanFollowup {
sections.push(`## Todo List\n\n${todoList}`)
}
const next = await Session.create({})
await inject({
sessionID: next.id,
agent: "code",
model: code.model,
variant: code.variant,
text: sections.join("\n\n"),
synthetic: false,
await Instance.provide({
directory: session.directory,
fn: async () => {
const next = await Session.create({})
await inject({
sessionID: next.id,
agent: "code",
model: code.model,
variant: code.variant,
text: sections.join("\n\n"),
synthetic: false,
})
if (todos.length) {
await Todo.update({ sessionID: next.id, todos })
}
await Bus.publish(TuiEvent.SessionSelect, { sessionID: next.id })
void import("@/session/prompt")
.then((item) =>
Instance.provide({
directory: next.directory,
fn: () => item.SessionPrompt.loop({ sessionID: next.id }),
}),
)
.catch((error) => {
log.error("failed to start follow-up session", { sessionID: next.id, error })
})
},
})
if (todos.length) {
await Todo.update({ sessionID: next.id, todos })
}
await Bus.publish(TuiEvent.SessionSelect, { sessionID: next.id })
void import("@/session/prompt")
.then((item) => item.SessionPrompt.loop({ sessionID: next.id }))
.catch((error) => {
log.error("failed to start follow-up session", { sessionID: next.id, error })
})
}
export async function ask(input: {
@@ -445,7 +445,10 @@ describe("plan follow-up", () => {
expect(_mocks.llmSpy).toHaveBeenCalledTimes(1)
const newSessionID = created[0]
expect(added[0].id).toBe(newSessionID)
const next = added[0]
if (!newSessionID || !next) throw new Error("expected follow-up session")
expect(next.id).toBe(newSessionID)
expect(next.parentID).toBeUndefined()
const messages = await Session.messages({ sessionID: newSessionID })
const user = messages.find((item) => item.info.role === "user")
expect(user?.info.role).toBe("user")
@@ -474,6 +477,60 @@ describe("plan follow-up", () => {
SessionPrompt.cancel(newSessionID)
}))
test("ask - creates a new session in the planning session directory when the current instance differs", () =>
withInstance(async () => {
const get = spyOn(Agent, "get").mockImplementation(async () => undefined as any)
const modelSpy = spyOn(Provider, "getModel").mockResolvedValue(fakeModel)
const llmSpy = spyOn(LLM, "stream").mockResolvedValue({
text: Promise.resolve(""),
} as any)
using _mocks = {
[Symbol.dispose]() {
get.mockRestore()
modelSpy.mockRestore()
llmSpy.mockRestore()
},
}
const dir = path.join(Instance.directory, "worktrees", "feature")
await fs.mkdir(dir, { recursive: true })
const seeded = await Instance.provide({
directory: dir,
fn: async () => seed({ text: "1. Add API\n2. Add tests" }),
})
const before = await sessions()
const pending = PlanFollowup.ask({
sessionID: seeded.sessionID,
messages: seeded.messages,
abort: AbortSignal.any([]),
})
const item = await waitQuestion(seeded.sessionID)
expect(item).toBeDefined()
if (!item) return
await Question.reply({
requestID: item.id,
answers: [[PlanFollowup.ANSWER_NEW_SESSION]],
})
await expect(pending).resolves.toBe("break")
const after = await sessions()
const prev = new Set(before.map((item) => item.id))
const added = after.filter((item) => !prev.has(item.id))
expect(added).toHaveLength(1)
const next = added[0]
expect(next?.directory).toBe(dir)
expect(next?.parentID).toBeUndefined()
if (next) {
SessionPrompt.cancel(next.id)
}
}))
test("ask - prefers saved code variant over configured code variant", () =>
withInstance(async () => {
await writeState({
@@ -670,7 +727,9 @@ describe("plan follow-up", () => {
await expect(pending).resolves.toBe("break")
unsub()
const messages = await Session.messages({ sessionID: created[0] })
const newSessionID = created[0]
if (!newSessionID) throw new Error("expected follow-up session")
const messages = await Session.messages({ sessionID: newSessionID })
const user = messages.find((item) => item.info.role === "user")
if (!user || user.info.role !== "user") throw new Error("expected user message")
const part = user.parts.find((item) => item.type === "text")
@@ -679,7 +738,7 @@ describe("plan follow-up", () => {
expect(part.text).not.toContain("## Handover from Planning Session")
expect(part.text).not.toContain("## Todo List")
SessionPrompt.cancel(created[0])
SessionPrompt.cancel(newSessionID)
}))
test("ask - returns break when assistant text is empty", () =>