mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-08-29 03:44:06 +08:00
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:
@@ -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 {
|
||||
|
||||
@@ -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", () =>
|
||||
|
||||
Reference in New Issue
Block a user