fix(cli): inject plan message immediately, append handover in-place

Show the plan text right away in the new session tab without waiting
for the handover LLM call. The message part is created with plan+todos
as soon as the session opens; the handover section is upserted onto the
same part once the slow LLM call resolves.
This commit is contained in:
Alex Alecu
2026-04-21 13:28:48 +03:00
parent 0d0dabe598
commit e696fe6ea4
3 changed files with 147 additions and 20 deletions
+1 -1
View File
@@ -2,4 +2,4 @@
"@kilocode/cli": patch
---
Fix the "Start new session" button on the plan follow-up prompt not switching the VS Code Agent Manager to the new session when handover generation is slow. The new session is now created first so the UI can switch to it immediately, and the handover message is injected as it becomes available.
Fix the "Start new session" button on the plan follow-up prompt not switching the VS Code Agent Manager to the new session when handover generation is slow. The new session now opens immediately with the plan text already visible, and the handover summary from the planning session is appended once it finishes generating.
+48 -19
View File
@@ -332,35 +332,64 @@ export namespace PlanFollowup {
await Bus.publish(TuiEvent.SessionSelect, { sessionID: next.id })
const file = Session.plan(session)
const [handover, todos] = await Promise.all([
generateHandover({ messages: input.messages, model: input.model, abort: input.abort }),
PlanFollowupRuntime.todo.get(input.sessionID),
])
const sections = [
`Plan file: ${file}\nRead this file first and treat it as the source of truth for implementation.`,
`Implement the following plan:\n\n${input.plan}`,
]
if (handover) {
sections.push(`## Handover from Planning Session\n\n${handover}`)
}
const todos = await PlanFollowupRuntime.todo.get(input.sessionID)
const todoList = formatTodos(todos)
if (todoList) {
sections.push(`## Todo List\n\n${todoList}`)
// Assemble the user message text with or without a handover section.
// The section order is fixed so the initial and final renders stay
// aligned — only the handover block grows in between.
const compose = (handover: string) => {
const sections = [
`Plan file: ${file}\nRead this file first and treat it as the source of truth for implementation.`,
`Implement the following plan:\n\n${input.plan}`,
]
if (handover) sections.push(`## Handover from Planning Session\n\n${handover}`)
if (todoList) sections.push(`## Todo List\n\n${todoList}`)
return sections.join("\n\n")
}
await inject({
// Inject the plan and todos immediately so the new session tab shows
// real content right away. The handover section is appended to this
// same part in-place once the slow LLM call resolves below.
const msg: MessageV2.User = {
id: MessageID.ascending(),
sessionID: next.id,
role: "user",
time: { created: Date.now() },
agent: "code",
model: code.model,
text: sections.join("\n\n"),
}
await Session.updateMessage(msg)
const pid = PartID.ascending()
await Session.updatePart({
id: pid,
messageID: msg.id,
sessionID: next.id,
type: "text",
text: compose(""),
synthetic: false,
})
} satisfies MessageV2.TextPart)
if (todos.length) {
await PlanFollowupRuntime.todo.update({ sessionID: next.id, todos })
}
const handover = await generateHandover({
messages: input.messages,
model: input.model,
abort: input.abort,
})
if (handover) {
await Session.updatePart({
id: pid,
messageID: msg.id,
sessionID: next.id,
type: "text",
text: compose(handover),
synthetic: false,
} satisfies MessageV2.TextPart)
}
void Instance.provide({
directory: next.directory,
fn: () => PlanFollowupRuntime.loop(next.id),
@@ -1025,6 +1025,104 @@ describe("plan follow-up", () => {
expect(createdAt!).toBeLessThan(handoverResolvedAt!)
}))
test("ask - injects plan message before generateHandover resolves on Start new session", () =>
withInstance(async () => {
// Regression guard: the plan text must appear in the new session tab
// immediately after the tab switch — without waiting for the slow handover
// LLM call. The handover is then appended to the same part in-place.
const seeded = await seed({ text: "1. Build" })
let followup: SessionID | undefined
const unsub = Bus.subscribe(Session.Event.Created, (event) => {
if (event.properties.info.id === seeded.sessionID) return
if (!followup) followup = event.properties.info.id
})
const deferred = Promise.withResolvers<string>()
const agentSpy = spyOn(PlanFollowupRuntime, "agent").mockResolvedValue(fakeAgent as any)
const modelSpy = spyOn(PlanFollowupRuntime, "model").mockResolvedValue(fakeModel)
const llmSpy = spyOn(LLM, "stream").mockResolvedValue({
text: deferred.promise,
} as any)
const loop = spyOn(PlanFollowupRuntime, "loop").mockResolvedValue({
info: {
id: MessageID.make("msg_test"),
role: "assistant",
sessionID: SessionID.make("ses_test"),
time: { created: Date.now() },
parentID: MessageID.make("msg_parent"),
modelID: ModelID.make("test"),
providerID: ProviderID.make("test"),
mode: "code",
agent: "code",
path: { cwd: "/tmp", root: "/tmp" },
cost: 0,
tokens: { total: 0, input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
},
parts: [],
} as MessageV2.WithParts)
using _ = {
[Symbol.dispose]() {
agentSpy.mockRestore()
modelSpy.mockRestore()
llmSpy.mockRestore()
loop.mockRestore()
unsub()
},
}
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]],
})
// Poll until the plan text lands. Handover is still pending because
// deferred has not resolved yet.
for (let i = 0; i < 100; i++) {
if (followup) {
const msgs = await Session.messages({ sessionID: followup })
const user = msgs.find((m) => m.info.role === "user")
const part = user?.parts.find((p) => p.type === "text")
if (part?.type === "text" && part.text.includes("Implement the following plan:")) break
}
await Bun.sleep(10)
}
expect(followup).toBeDefined()
if (!followup) return
const initial = await Session.messages({ sessionID: followup })
const initialUser = initial.find((m) => m.info.role === "user")
const initialPart = initialUser?.parts.find((p) => p.type === "text")
expect(initialPart?.type).toBe("text")
if (initialPart?.type !== "text") return
expect(initialPart.text).toContain("Implement the following plan:")
expect(initialPart.text).toContain("1. Build")
// Handover is still deferred — must not be present yet.
expect(initialPart.text).not.toContain("## Handover from Planning Session")
deferred.resolve("## Discoveries\n\nexample")
await expect(pending).resolves.toBe("break")
// Same part ID updated in-place — handover section now present.
const final = await Session.messages({ sessionID: followup })
const finalUser = final.find((m) => m.info.role === "user")
const finalPart = finalUser?.parts.find((p) => p.type === "text")
if (finalPart?.type !== "text") return
expect(finalPart.id).toBe(initialPart.id)
expect(finalPart.text).toContain("Implement the following plan:")
expect(finalPart.text).toContain("## Handover from Planning Session")
expect(finalPart.text).toContain("example")
}))
test("ask - returns break when assistant text is empty", () =>
withInstance(async () => {
const seeded = await seed({ text: " " })