mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-09-24 16:02:55 +08:00
Merge branch 'dev' into fix/fix-orphan-sub-agents
This commit is contained in:
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user