Merge branch 'dev' into fix/fix-orphan-sub-agents

This commit is contained in:
Marian Alexandru Alecu
2026-02-20 17:52:59 +02:00
committed by GitHub
118 changed files with 8947 additions and 975 deletions
@@ -1,6 +1,7 @@
import { describe, expect, test, beforeEach } from "bun:test"
import { describe, expect, test, beforeEach, mock } from "bun:test"
// Mock Bun.spawnSync before importing the module under test
// Mock Bun.spawnSync via mock.module so it integrates properly with bun:test
// and doesn't conflict with other test files that mock "../git-context".
const spawnSyncResults: Record<string, string> = {}
function setGitOutput(args: string, output: string) {
@@ -13,17 +14,171 @@ function clearGitOutputs() {
}
}
// Replace global Bun.spawnSync — the git() helper in git-context.ts calls
// result.stdout.toString().trim(), so we return a Buffer and let git() trim.
Bun.spawnSync = ((cmd: string[], _opts?: any) => {
const args = cmd.slice(1).join(" ")
const output = spawnSyncResults[args] ?? ""
return {
stdout: Buffer.from(output),
stderr: Buffer.from(""),
exitCode: 0,
// Override the git-context module with a version that uses our mock spawnSync.
// This avoids conflicts with generate.test.ts which also mocks this module.
mock.module("../git-context", () => {
function git(args: string[], cwd: string): string {
const key = args.join(" ")
return spawnSyncResults[key] ?? ""
}
}) as typeof Bun.spawnSync
const LOCK_FILES = new Set([
"package-lock.json",
"npm-shrinkwrap.json",
"yarn.lock",
"pnpm-lock.yaml",
"shrinkwrap.yaml",
"bun.lockb",
"bun.lock",
".pnp.js",
".pnp.cjs",
"jspm.lock",
"Pipfile.lock",
"poetry.lock",
"pdm.lock",
".pdm-lock.toml",
"uv.lock",
"conda-lock.yml",
"pylock.toml",
"Gemfile.lock",
"composer.lock",
"gradle.lockfile",
"lockfile.json",
"dependency-lock.json",
"dependency-reduced-pom.xml",
"coursier.lock",
"build.sbt.lock",
"packages.lock.json",
"paket.lock",
"project.assets.json",
"Cargo.lock",
"go.sum",
"Gopkg.lock",
"glide.lock",
"build.zig.zon.lock",
"dune.lock",
"opam.lock",
"Package.resolved",
"Podfile.lock",
"Cartfile.resolved",
"pubspec.lock",
"mix.lock",
"rebar.lock",
"stack.yaml.lock",
"cabal.project.freeze",
"exact-dependencies.json",
"shard.lock",
"Manifest.toml",
"JuliaManifest.toml",
"renv.lock",
"packrat.lock",
"nimble.lock",
"dub.selections.json",
"rocks.lock",
"carton.lock",
"cpanfile.snapshot",
"conan.lock",
"vcpkg-lock.json",
".terraform.lock.hcl",
"Berksfile.lock",
"Puppetfile.lock",
"MODULE.bazel.lock",
"flake.lock",
"deno.lock",
"devcontainer.lock.json",
])
const MAX_DIFF_LENGTH = 4000
function isLockFile(filepath: string): boolean {
const name = filepath.split("/").pop() ?? filepath
return LOCK_FILES.has(name)
}
function parseNameStatus(output: string): Array<{ status: string; path: string }> {
if (!output) return []
return output.split("\n").map((line) => {
const [status, ...rest] = line.split("\t")
const path = status!.startsWith("R") ? (rest[1] ?? rest[0]) : rest.join("\t")
return { status: status!, path }
})
}
function parsePorcelain(output: string): Array<{ status: string; path: string }> {
if (!output) return []
return output
.split("\n")
.filter((line) => line.length > 0)
.map((line) => {
const xy = line.slice(0, 2)
const filepath = line.slice(3)
return { status: xy.trim(), path: filepath }
})
}
type FileStatus = "added" | "modified" | "deleted" | "renamed"
function mapStatus(code: string): FileStatus {
if (code.startsWith("R")) return "renamed"
if (code === "A" || code === "??" || code === "?") return "added"
if (code === "D") return "deleted"
if (code === "M") return "modified"
return "modified"
}
function isUntracked(code: string): boolean {
return code === "??" || code === "?"
}
async function getGitContext(repoPath: string, selectedFiles?: string[]) {
const branch = git(["branch", "--show-current"], repoPath) || "HEAD"
const log = git(["log", "--oneline", "-5"], repoPath)
const recentCommits = log ? log.split("\n") : []
const staged = parseNameStatus(git(["diff", "--name-status", "--cached"], repoPath))
const useStaged = staged.length > 0
const raw = useStaged ? staged : parsePorcelain(git(["status", "--porcelain"], repoPath))
const selected = selectedFiles ? new Set(selectedFiles) : undefined
const files: Array<{ status: FileStatus; path: string; diff: string }> = []
for (const entry of raw) {
if (isLockFile(entry.path)) continue
if (selected && !selected.has(entry.path)) continue
const status = mapStatus(entry.status)
const untracked = isUntracked(entry.status)
let diff: string
if (untracked) {
diff = `New untracked file: ${entry.path}`
} else if (status === "deleted") {
diff = useStaged
? git(["diff", "--cached", "--", entry.path], repoPath)
: git(["diff", "--", entry.path], repoPath)
} else {
const raw = useStaged
? git(["diff", "--cached", "--", entry.path], repoPath)
: git(["diff", "--", entry.path], repoPath)
if (raw.includes("Binary files") || raw.includes("GIT binary patch")) {
diff = `Binary file ${entry.path} has been modified`
} else {
diff = raw
}
}
if (diff.length > MAX_DIFF_LENGTH) {
diff = diff.slice(0, MAX_DIFF_LENGTH) + "\n... [truncated]"
}
files.push({ status, path: entry.path, diff })
}
return { branch, recentCommits, files }
}
return { getGitContext }
})
import { getGitContext } from "../git-context"
@@ -0,0 +1,274 @@
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 {
return item.parts
.filter((part): part is MessageV2.TextPart => part.type === "text")
.map((part) => part.text)
.join("\n")
.trim()
}
const HANDOVER_PROMPT = `You are summarizing a planning session to hand off to an implementation session.
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 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)
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,
}
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 result = await stream.text
return result.trim()
} catch (error) {
log.error("handover generation failed", { error })
return ""
}
}
export namespace PlanFollowup {
const log = Log.create({ service: "plan.followup" })
export const ANSWER_NEW_SESSION = "Start new session"
export const ANSWER_CONTINUE = "Continue here"
async function inject(input: {
sessionID: string
agent: string
model: MessageV2.User["model"]
text: string
synthetic?: boolean
}) {
const msg: MessageV2.User = {
id: Identifier.ascending("message"),
sessionID: input.sessionID,
role: "user",
time: {
created: Date.now(),
},
agent: input.agent,
model: input.model,
}
await Session.updateMessage(msg)
await Session.updatePart({
id: Identifier.ascending("part"),
messageID: msg.id,
sessionID: input.sessionID,
type: "text",
text: input.text,
synthetic: input.synthetic ?? true,
} satisfies MessageV2.TextPart)
}
function prompt(input: { sessionID: string; abort: AbortSignal }) {
const promise = Question.ask({
sessionID: input.sessionID,
questions: [
{
question: "Ready to implement?",
header: "Implement",
custom: true,
options: [
{
label: ANSWER_NEW_SESSION,
description: "Implement in a fresh session with a clean context",
},
{
label: ANSWER_CONTINUE,
description: "Implement the plan in this session",
},
],
},
],
})
const listener = () =>
Question.list().then((qs) => {
const match = qs.find((q) => q.sessionID === input.sessionID)
if (match) Question.reject(match.id)
})
input.abort.addEventListener("abort", listener, { once: true })
return promise
.catch((error) => {
if (error instanceof Question.RejectedError) return undefined
throw error
})
.finally(() => {
input.abort.removeEventListener("abort", listener)
})
}
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: 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) => 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: {
sessionID: string
messages: MessageV2.WithParts[]
abort: AbortSignal
}): Promise<"continue" | "break"> {
if (input.abort.aborted) return "break"
const latest = input.messages.slice().reverse()
const assistant = latest.find((msg) => msg.info.role === "assistant")
if (!assistant) return "break"
const plan = toText(assistant)
if (!plan) return "break"
const user = latest.find((msg) => msg.info.role === "user")?.info
if (!user || user.role !== "user" || !user.model) return "break"
const answers = await prompt({ sessionID: input.sessionID, abort: input.abort })
if (!answers) return "break"
const answer = answers[0]?.[0]?.trim()
if (!answer) return "break"
if (answer === ANSWER_NEW_SESSION) {
await startNew({
sessionID: input.sessionID,
plan,
messages: input.messages,
model: user.model,
abort: input.abort,
})
return "break"
}
if (answer === ANSWER_CONTINUE) {
await inject({
sessionID: input.sessionID,
agent: "code",
model: user.model,
text: "Implement the plan above.",
})
return "continue"
}
await inject({
sessionID: input.sessionID,
agent: "plan",
model: user.model,
text: answer,
})
return "continue"
}
}
+7
View File
@@ -45,6 +45,7 @@ import { LLM } from "./llm"
import { iife } from "@/util/iife"
import { Shell } from "@/shell/shell"
import { Truncate } from "@/tool/truncation"
import { PlanFollowup } from "@/kilocode/plan-followup" // kilocode_change
// @ts-ignore
globalThis.AI_SDK_LOG_WARNINGS = false
@@ -336,6 +337,12 @@ export namespace SessionPrompt {
!["tool-calls", "unknown"].includes(lastAssistant.finish) &&
lastUser.id < lastAssistant.id
) {
// kilocode_change start - ask follow-up after plan agent completes
if (lastUser.agent === "plan" && !abort.aborted && ["cli", "vscode"].includes(Flag.KILO_CLIENT)) {
const action = await PlanFollowup.ask({ sessionID, messages: msgs, abort })
if (action === "continue") continue
}
// kilocode_change end
log.info("exiting loop", { sessionID })
break
}