feat: Handover task list and compacted context to next session after planning

This commit is contained in:
Alex Alecu
2026-02-20 12:28:21 +02:00
parent 104c34ff49
commit d5bceac21e
2 changed files with 285 additions and 246 deletions
+105 -51
View File
@@ -1,9 +1,13 @@
import { Agent } from "@/agent/agent"
import { Bus } from "@/bus"
import { TuiEvent } from "@/cli/cmd/tui/event"
import { Identifier } from "@/id/id"
import { Provider } from "@/provider/provider"
import { Question } from "@/question"
import { Session } from "@/session"
import { LLM } from "@/session/llm"
import { MessageV2 } from "@/session/message-v2"
import { Todo } from "@/session/todo"
import { Log } from "@/util/log"
function toText(item: MessageV2.WithParts): string {
@@ -14,61 +18,90 @@ function toText(item: MessageV2.WithParts): string {
.trim()
}
const CONTEXT_LIMIT = 10_000
const HANDOVER_PROMPT = `You are summarizing a planning session to hand off to an implementation session.
function isTool(part: MessageV2.Part): part is MessageV2.ToolPart & { state: MessageV2.ToolStateCompleted } {
return part.type === "tool" && part.state.status === "completed"
The plan itself will be provided separately — do NOT repeat it. Instead, focus on information discovered during planning that would help the implementing agent but is NOT already in the plan text.
Produce a concise summary using this template:
---
## Discoveries
[Key findings from code exploration — architecture patterns, gotchas, edge cases, relevant existing code that the plan references but doesn't fully explain]
## Relevant Files
[Structured list of files/directories that were read or discussed, with brief notes on what's relevant in each]
## Implementation Notes
[Any important context: conventions to follow, potential pitfalls, dependencies between steps, things the implementing agent should watch out for]
---
If there is nothing useful to add beyond what the plan already says, respond with an empty string.
Keep the summary concise — focus on high-entropy information that would save the implementing agent time.`
export function formatTodos(todos: Todo.Info[]): string {
if (!todos.length) return ""
const icons: Record<string, string> = {
completed: "[x]",
in_progress: "[~]",
cancelled: "[-]",
}
return todos.map((t) => `- ${icons[t.status] ?? "[ ]"} ${t.content}`).join("\n")
}
export function extractContext(messages: MessageV2.WithParts[]): string {
const tasks = [] as string[]
const files = [] as string[]
const seen = new Set<string>()
export async function generateHandover(input: {
messages: MessageV2.WithParts[]
model: MessageV2.User["model"]
abort?: AbortSignal
}): Promise<string> {
const log = Log.create({ service: "plan.followup" })
try {
const agent = await Agent.get("compaction")
const model = agent?.model
? await Provider.getModel(agent.model.providerID, agent.model.modelID)
: await Provider.getModel(input.model.providerID, input.model.modelID)
for (const msg of messages) {
for (const part of msg.parts) {
if (!isTool(part)) continue
if (part.tool === "task" && part.state.output.trim()) {
const match = part.state.output.match(/<task_result>([\s\S]*?)<\/task_result>/)
tasks.push(match ? match[1].trim() : part.state.output.trim())
}
if (part.tool === "read" && part.state.input.filePath) {
const path = part.state.input.filePath as string
const offset = part.state.input.offset as number | undefined
const limit = part.state.input.limit as number | undefined
const range =
offset !== undefined && limit !== undefined
? ` (lines ${offset}-${offset + limit - 1})`
: offset !== undefined
? ` (from line ${offset})`
: limit !== undefined
? ` (first ${limit} lines)`
: ""
const entry = `- ${path}${range}`
if (!seen.has(entry)) {
seen.add(entry)
files.push(entry)
}
}
const sessionID = Identifier.ascending("session")
const userMsg: MessageV2.User = {
id: Identifier.ascending("message"),
sessionID,
role: "user",
time: { created: Date.now() },
agent: "plan",
model: input.model,
}
}
if (!tasks.length && !files.length) return ""
const stream = await LLM.stream({
agent: agent ?? {
name: "compaction",
mode: "subagent",
permission: [],
options: {},
},
user: userMsg,
tools: {},
model,
small: true,
messages: [
...MessageV2.toModelMessages(input.messages, model),
{
role: "user" as const,
content: HANDOVER_PROMPT,
},
],
abort: input.abort ? AbortSignal.any([input.abort, AbortSignal.timeout(60_000)]) : AbortSignal.timeout(60_000),
sessionID,
system: [],
retries: 1,
})
const sections = [] as string[]
if (tasks.length) {
sections.push("### Explored\n\n" + tasks.join("\n\n"))
const result = await stream.text
return result.trim()
} catch (error) {
log.error("handover generation failed", { error })
return ""
}
if (files.length) {
sections.push("### Files read\n\n" + files.join("\n"))
}
const full = "\n\n## Context from planning research\n\n" + sections.join("\n\n")
if (full.length <= CONTEXT_LIMIT) return full
const marker = "\n\n[context truncated]"
const cut = full.slice(0, CONTEXT_LIMIT - marker.length)
const last = cut.lastIndexOf("\n")
return (last > 0 ? cut.slice(0, last) : cut) + marker
}
export namespace PlanFollowup {
@@ -138,14 +171,35 @@ export namespace PlanFollowup {
})
}
async function startNew(input: { plan: string; messages: MessageV2.WithParts[]; model: MessageV2.User["model"] }) {
const context = extractContext(input.messages)
async function startNew(input: {
sessionID: string
plan: string
messages: MessageV2.WithParts[]
model: MessageV2.User["model"]
abort?: AbortSignal
}) {
const [handover, todos] = await Promise.all([
generateHandover({ messages: input.messages, model: input.model, abort: input.abort }),
Todo.get(input.sessionID),
])
const sections = [`Implement the following plan:\n\n${input.plan}`]
if (handover) {
sections.push(`## Handover from Planning Session\n\n${handover}`)
}
const todoList = formatTodos(todos)
if (todoList) {
sections.push(`## Todo List\n\n${todoList}`)
}
const next = await Session.create({})
await inject({
sessionID: next.id,
agent: "code",
model: input.model,
text: `Implement the following plan:\n\n${input.plan}${context ? `\n${context}` : ""}`,
text: sections.join("\n\n"),
})
await Bus.publish(TuiEvent.SessionSelect, { sessionID: next.id })
void import("@/session/prompt")
@@ -179,7 +233,7 @@ export namespace PlanFollowup {
if (!answer) return "break"
if (answer === ANSWER_NEW_SESSION) {
await startNew({ plan, messages: input.messages, model: user.model })
await startNew({ sessionID: input.sessionID, plan, messages: input.messages, model: user.model, abort: input.abort })
return "break"
}
@@ -1,13 +1,17 @@
import { describe, expect, spyOn, test } from "bun:test"
import { Agent } from "../../src/agent/agent"
import { Bus } from "../../src/bus"
import { TuiEvent } from "../../src/cli/cmd/tui/event"
import { Identifier } from "../../src/id/id"
import { extractContext, PlanFollowup } from "../../src/kilocode/plan-followup"
import { formatTodos, generateHandover, PlanFollowup } from "../../src/kilocode/plan-followup"
import { Instance } from "../../src/project/instance"
import { Provider } from "../../src/provider/provider"
import { Question } from "../../src/question"
import { Session } from "../../src/session"
import { LLM } from "../../src/session/llm"
import { MessageV2 } from "../../src/session/message-v2"
import { SessionPrompt } from "../../src/session/prompt"
import { Todo } from "../../src/session/todo"
import { Log } from "../../src/util/log"
import { tmpdir } from "../fixture/fixture"
@@ -122,6 +126,39 @@ async function sessions() {
return Array.fromAsync(Session.list())
}
const fakeAgent: Agent.Info = {
name: "compaction",
mode: "subagent",
permission: [],
options: {},
}
const fakeModel = {
id: "gpt-4",
providerID: "openai",
limit: { context: 128000, input: 0 },
api: { id: "openai", npm: "@ai-sdk/openai" },
capabilities: {},
} as Provider.Model
function mockHandoverDeps(text: string, opts?: { agent?: Agent.Info | null }) {
const agentSpy = spyOn(Agent, "get").mockResolvedValue((opts?.agent === null ? undefined : (opts?.agent ?? fakeAgent)) as any)
const modelSpy = spyOn(Provider, "getModel").mockResolvedValue(fakeModel)
const llmSpy = spyOn(LLM, "stream").mockResolvedValue({
text: Promise.resolve(text),
} as any)
return {
agentSpy,
modelSpy,
llmSpy,
[Symbol.dispose]() {
agentSpy.mockRestore()
modelSpy.mockRestore()
llmSpy.mockRestore()
},
}
}
describe("plan follow-up", () => {
test("ask - returns break when dismissed", () =>
withInstance(async () => {
@@ -196,7 +233,7 @@ describe("plan follow-up", () => {
expect(part.synthetic).toBe(true)
}))
test("ask - creates a new session on Start new session", () =>
test("ask - creates a new session on Start new session with handover and todos", () =>
withInstance(async () => {
const loop = spyOn(SessionPrompt, "loop").mockResolvedValue({
info: {
@@ -229,26 +266,24 @@ describe("plan follow-up", () => {
},
parts: [],
})
using _ = {
using _mocks = mockHandoverDeps("## Discoveries\n\nFound REST endpoints in src/api.ts\n\n## Relevant Files\n\n- src/api.ts: REST endpoints\n- src/db.ts: Database layer")
using _loop = {
[Symbol.dispose]() {
loop.mockRestore()
},
}
const seeded = await seed({
text: "1. Add API\n2. Add tests",
tools: [
{
tool: "task",
input: { prompt: "explore the codebase", subagent_type: "explore" },
output: "Found src/api.ts with REST endpoints and src/db.ts with database layer",
},
{
tool: "read",
input: { filePath: "/project/src/api.ts", offset: 1, limit: 50 },
output: "file content here",
},
})
await Todo.update({
sessionID: seeded.sessionID,
todos: [
{ id: "1", content: "Add API endpoint", status: "completed", priority: "high" },
{ id: "2", content: "Write tests", status: "pending", priority: "medium" },
],
})
const before = await sessions()
const created = [] as string[]
const unsub = Bus.subscribe(TuiEvent.SessionSelect, (event) => {
@@ -275,6 +310,7 @@ describe("plan follow-up", () => {
expect(added).toHaveLength(1)
expect(created).toHaveLength(1)
expect(loop).toHaveBeenCalledTimes(1)
expect(_mocks.llmSpy).toHaveBeenCalledTimes(1)
const newSessionID = created[0]
expect(added[0].id).toBe(newSessionID)
@@ -289,14 +325,79 @@ describe("plan follow-up", () => {
if (!part || part.type !== "text") throw new Error("expected text part")
expect(part.text).toContain("Implement the following plan:")
expect(part.text).toContain("1. Add API\n2. Add tests")
expect(part.text).toContain("## Context from planning research")
expect(part.text).toContain("Found src/api.ts with REST endpoints")
expect(part.text).toContain("- /project/src/api.ts (lines 1-50)")
expect(part.text).toContain("## Handover from Planning Session")
expect(part.text).toContain("Found REST endpoints in src/api.ts")
expect(part.text).toContain("## Todo List")
expect(part.text).toContain("[x] Add API endpoint")
expect(part.text).toContain("[ ] Write tests")
expect(part.synthetic).toBe(true)
SessionPrompt.cancel(newSessionID)
}))
test("ask - new session omits handover section when LLM returns empty", () =>
withInstance(async () => {
const loop = spyOn(SessionPrompt, "loop").mockResolvedValue({
info: {
id: "msg_test",
role: "assistant",
sessionID: "ses_test",
time: { created: Date.now() },
parentID: "msg_parent",
modelID: "test",
providerID: "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: [],
})
using _mocks = mockHandoverDeps("")
using _loop = {
[Symbol.dispose]() {
loop.mockRestore()
},
}
const seeded = await seed({ text: "1. Add API\n2. Add tests" })
const created = [] as string[]
const unsub = Bus.subscribe(TuiEvent.SessionSelect, (event) => {
created.push(event.properties.sessionID)
})
const pending = PlanFollowup.ask({
sessionID: seeded.sessionID,
messages: seeded.messages,
abort: AbortSignal.any([]),
})
await Question.reply({
requestID: (await Question.list())[0].id,
answers: [[PlanFollowup.ANSWER_NEW_SESSION]],
})
await expect(pending).resolves.toBe("break")
unsub()
const messages = await Session.messages({ sessionID: created[0] })
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")
if (!part || part.type !== "text") throw new Error("expected text part")
expect(part.text).toContain("Implement the following plan:")
expect(part.text).not.toContain("## Handover from Planning Session")
expect(part.text).not.toContain("## Todo List")
SessionPrompt.cancel(created[0])
}))
test("ask - returns break when assistant text is empty", () =>
withInstance(async () => {
const seeded = await seed({ text: " " })
@@ -361,195 +462,79 @@ describe("plan follow-up", () => {
expect((await Session.messages({ sessionID: seeded.sessionID })).length).toBe(2)
}))
test("extractContext - returns empty string with no tool results", () =>
test("formatTodos - returns empty string for no todos", () => {
expect(formatTodos([])).toBe("")
})
test("formatTodos - formats todos with status icons", () => {
const todos: Todo.Info[] = [
{ id: "1", content: "Set up project", status: "completed", priority: "high" },
{ id: "2", content: "Write code", status: "in_progress", priority: "high" },
{ id: "3", content: "Add tests", status: "pending", priority: "medium" },
{ id: "4", content: "Dropped task", status: "cancelled", priority: "low" },
]
const result = formatTodos(todos)
expect(result).toBe(
"- [x] Set up project\n- [~] Write code\n- [ ] Add tests\n- [-] Dropped task",
)
})
test("generateHandover - returns empty string on LLM.stream failure", () =>
withInstance(async () => {
const agentSpy = spyOn(Agent, "get").mockResolvedValue(fakeAgent)
const modelSpy = spyOn(Provider, "getModel").mockResolvedValue(fakeModel)
const llmSpy = spyOn(LLM, "stream").mockRejectedValue(new Error("provider unavailable"))
using _ = {
[Symbol.dispose]() {
agentSpy.mockRestore()
modelSpy.mockRestore()
llmSpy.mockRestore()
},
}
const seeded = await seed({ text: "1. Build\n2. Test" })
expect(extractContext(seeded.messages)).toBe("")
const result = await generateHandover({ messages: seeded.messages, model })
expect(result).toBe("")
}))
test("extractContext - includes task outputs and read paths", () =>
test("generateHandover - returns empty string on stream.text rejection", () =>
withInstance(async () => {
const seeded = await seed({
text: "Plan text",
tools: [
{
tool: "task",
input: { prompt: "explore", subagent_type: "explore" },
output: "The auth module lives in src/auth/",
},
{
tool: "read",
input: { filePath: "/project/src/auth/login.ts" },
output: "file content",
},
{
tool: "read",
input: { filePath: "/project/src/auth/session.ts", offset: 10, limit: 20 },
output: "file content",
},
],
const agentSpy = spyOn(Agent, "get").mockResolvedValue(fakeAgent)
const modelSpy = spyOn(Provider, "getModel").mockResolvedValue(fakeModel)
const textPromise = new Promise<string>((_, reject) => {
setTimeout(() => reject(new Error("stream aborted")), 0)
})
const context = extractContext(seeded.messages)
expect(context).toContain("## Context from planning research")
expect(context).toContain("### Explored")
expect(context).toContain("The auth module lives in src/auth/")
expect(context).toContain("### Files read")
expect(context).toContain("- /project/src/auth/login.ts")
expect(context).toContain("- /project/src/auth/session.ts (lines 10-29)")
textPromise.catch(() => {})
const llmSpy = spyOn(LLM, "stream").mockResolvedValue({
text: textPromise,
} as any)
using _ = {
[Symbol.dispose]() {
agentSpy.mockRestore()
modelSpy.mockRestore()
llmSpy.mockRestore()
},
}
const seeded = await seed({ text: "1. Build\n2. Test" })
const result = await generateHandover({ messages: seeded.messages, model })
expect(result).toBe("")
}))
test("extractContext - filters empty and whitespace-only task outputs", () =>
test("generateHandover - uses fallback agent when compaction agent is not configured", () =>
withInstance(async () => {
const seeded = await seed({
text: "Plan text",
tools: [
{
tool: "task",
input: { prompt: "explore", subagent_type: "explore" },
output: "",
},
{
tool: "task",
input: { prompt: "explore more", subagent_type: "explore" },
output: " ",
},
],
})
expect(extractContext(seeded.messages)).toBe("")
using mocks = mockHandoverDeps("## Discoveries\n\nFallback works", { agent: null })
const seeded = await seed({ text: "1. Build\n2. Test" })
const result = await generateHandover({ messages: seeded.messages, model })
expect(result).toBe("## Discoveries\n\nFallback works")
expect(mocks.agentSpy).toHaveBeenCalledWith("compaction")
expect(mocks.llmSpy).toHaveBeenCalledTimes(1)
}))
test("extractContext - deduplicates file reads", () =>
test("generateHandover - returns LLM output on success", () =>
withInstance(async () => {
const seeded = await seed({
text: "Plan text",
tools: [
{
tool: "read",
input: { filePath: "/project/src/api.ts" },
output: "content",
},
{
tool: "read",
input: { filePath: "/project/src/api.ts" },
output: "content again",
},
{
tool: "read",
input: { filePath: "/project/src/api.ts", offset: 10, limit: 20 },
output: "different range",
},
],
})
const context = extractContext(seeded.messages)
const matches = context.match(/- \/project\/src\/api\.ts\b/g)
expect(matches).toHaveLength(2)
}))
test("extractContext - includes only explored section when no reads", () =>
withInstance(async () => {
const seeded = await seed({
text: "Plan text",
tools: [
{
tool: "task",
input: { prompt: "explore", subagent_type: "explore" },
output: "Found important patterns",
},
],
})
const context = extractContext(seeded.messages)
expect(context).toContain("### Explored")
expect(context).toContain("Found important patterns")
expect(context).not.toContain("### Files read")
}))
test("extractContext - includes only files section when no tasks", () =>
withInstance(async () => {
const seeded = await seed({
text: "Plan text",
tools: [
{
tool: "read",
input: { filePath: "/project/src/index.ts" },
output: "content",
},
],
})
const context = extractContext(seeded.messages)
expect(context).not.toContain("### Explored")
expect(context).toContain("### Files read")
expect(context).toContain("- /project/src/index.ts")
}))
test("extractContext - ignores non-task non-read tools", () =>
withInstance(async () => {
const seeded = await seed({
text: "Plan text",
tools: [
{
tool: "grep",
input: { pattern: "foo", path: "/project" },
output: "Found 5 matches",
},
{
tool: "bash",
input: { command: "ls" },
output: "file1.ts\nfile2.ts",
},
],
})
expect(extractContext(seeded.messages)).toBe("")
}))
test("extractContext - strips task_id prefix and task_result tags", () =>
withInstance(async () => {
const seeded = await seed({
text: "Plan text",
tools: [
{
tool: "task",
input: { prompt: "explore", subagent_type: "explore" },
output:
"task_id: ses_abc123 (for resuming)\n\n<task_result>\nThe auth module is in src/auth/\n</task_result>",
},
],
})
const context = extractContext(seeded.messages)
expect(context).toContain("The auth module is in src/auth/")
expect(context).not.toContain("task_id:")
expect(context).not.toContain("<task_result>")
}))
test("extractContext - shows first N lines for limit-only reads", () =>
withInstance(async () => {
const seeded = await seed({
text: "Plan text",
tools: [
{
tool: "read",
input: { filePath: "/project/src/config.ts", limit: 50 },
output: "content",
},
],
})
const context = extractContext(seeded.messages)
expect(context).toContain("- /project/src/config.ts (first 50 lines)")
}))
test("extractContext - truncates at 10000 chars", () =>
withInstance(async () => {
const seeded = await seed({
text: "Plan text",
tools: [
{
tool: "task",
input: { prompt: "explore", subagent_type: "explore" },
output: "x".repeat(12_000),
},
],
})
const context = extractContext(seeded.messages)
expect(context.length).toBeLessThanOrEqual(10_000)
expect(context).toEndWith("[context truncated]")
using mocks = mockHandoverDeps("## Discoveries\n\nKey finding here")
const seeded = await seed({ text: "1. Build\n2. Test" })
const result = await generateHandover({ messages: seeded.messages, model })
expect(result).toBe("## Discoveries\n\nKey finding here")
expect(mocks.llmSpy).toHaveBeenCalledTimes(1)
}))
})