revert: remove suggest tool feature (#8994)

Reverts PR #6404 (feat: suggest code review tool) and PR #8988 (fix: dismiss suggestions on new message).
This commit is contained in:
Marius
2026-04-15 16:13:42 +00:00
committed by GitHub
parent ae8e7b6e1c
commit d4ab3331c8
60 changed files with 50 additions and 2563 deletions
-2
View File
@@ -102,7 +102,6 @@ export namespace Agent {
"*": "ask",
...Object.fromEntries(whitelistedDirs.map((dir) => [dir, "allow"])),
},
suggest: "deny", // kilocode_change
question: "deny",
plan_enter: "deny",
plan_exit: "deny",
@@ -131,7 +130,6 @@ export namespace Agent {
defaults,
Permission.fromConfig({
question: "allow",
suggest: "allow", // kilocode_change
plan_enter: "allow",
}),
user,
@@ -9,7 +9,6 @@ import type {
Command,
PermissionRequest,
QuestionRequest,
SuggestionRequest, // kilocode_change
SessionNetworkWait, // kilocode_change
LspStatus,
McpStatus,
@@ -28,7 +27,6 @@ import type { Snapshot } from "@/snapshot"
import { useExit } from "./exit"
import { useArgs } from "./args"
import { batch, onMount } from "solid-js"
import { handleSuggestionEvent } from "@/kilocode/suggestion/tui/sync" // kilocode_change
import { Log } from "@/util/log"
import { useToast } from "@tui/ui/toast" // kilocode_change
import type { Path } from "@kilocode/sdk"
@@ -54,9 +52,6 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
[sessionID: string]: QuestionRequest[]
}
// kilocode_change start
suggestion: {
[sessionID: string]: SuggestionRequest[]
}
network: {
[sessionID: string]: SessionNetworkWait[]
}
@@ -103,7 +98,6 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
permission: {},
question: {},
// kilocode_change start
suggestion: {},
network: {},
// kilocode_change end
command: [],
@@ -149,9 +143,6 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
delete draft.session_diff[sessionID]
delete draft.session_status[sessionID]
delete draft.todo[sessionID]
delete draft.permission[sessionID]
delete draft.question[sessionID]
delete draft.suggestion[sessionID]
delete draft.network[sessionID]
}),
)
@@ -265,15 +256,6 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
break
}
// kilocode_change start
case "suggestion.accepted":
case "suggestion.dismissed":
case "suggestion.shown": {
handleSuggestionEvent(event, store, setStore)
break
}
// kilocode_change end
case "session.network.restored": {
const requests = store.network[event.properties.sessionID]
if (!requests) break
@@ -306,6 +288,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
break
}
// kilocode_change end
case "todo.updated":
setStore("todo", event.properties.sessionID, event.properties.todos)
break
@@ -21,7 +21,7 @@ import { Spinner } from "@tui/component/spinner"
import { selectedForeground, useTheme } from "@tui/context/theme"
import { BoxRenderable, ScrollBoxRenderable, addDefaultParsers, TextAttributes, RGBA } from "@opentui/core"
import { Prompt, type PromptRef } from "@tui/component/prompt"
import type { AssistantMessage, Part, Provider, ToolPart, UserMessage, TextPart, ReasoningPart } from "@kilocode/sdk/v2" // kilocode_change
import type { AssistantMessage, Part, Provider, ToolPart, UserMessage, TextPart, ReasoningPart } from "@kilocode/sdk/v2" // kilocode_change // kilocode_change
import { useLocal } from "@tui/context/local"
import { Locale } from "@/util/locale"
import type { Tool } from "@/tool/tool"
@@ -69,8 +69,6 @@ import { Filesystem } from "@/util/filesystem"
import { Global } from "@/global"
import { PermissionPrompt } from "./permission"
import { QuestionPrompt } from "./question"
import { Suggest } from "@/kilocode/suggestion/tui/render" // kilocode_change
import { SuggestPrompt } from "@/kilocode/suggestion/tui/prompt" // kilocode_change
import { NetworkPrompt } from "./network" // kilocode_change
import { DialogExportOptions } from "../../ui/dialog-export-options"
import * as Model from "../../util/model"
@@ -135,42 +133,18 @@ export function Session() {
return children().flatMap((x) => sync.data.question[x.id] ?? [])
})
// kilocode_change start
const suggestions = createMemo(() => {
if (session()?.parentID) return []
return children().flatMap((x) => sync.data.suggestion[x.id] ?? [])
})
const network = createMemo(() => {
if (session()?.parentID) return []
return children().flatMap((x) => sync.data.network[x.id] ?? [])
})
const blockingQuestions = createMemo(() => questions().filter((q) => q.blocking !== false))
const nonBlockingQuestions = createMemo(() => questions().filter((q) => q.blocking === false))
const question = createMemo(() => blockingQuestions()[0] ?? nonBlockingQuestions()[0])
const blockingSuggestions = createMemo(() => suggestions().filter((s) => s.blocking !== false))
const nonBlockingSuggestions = createMemo(() => suggestions().filter((s) => s.blocking === false))
const suggestion = createMemo(() => blockingSuggestions()[0] ?? nonBlockingSuggestions()[0])
const visible = createMemo(
() =>
!session()?.parentID &&
permissions().length === 0 &&
blockingQuestions().length === 0 &&
blockingSuggestions().length === 0 &&
network().length === 0,
() => !session()?.parentID && permissions().length === 0 && questions().length === 0 && network().length === 0,
)
const networkVisible = createMemo(
() =>
permissions().length === 0 &&
blockingQuestions().length === 0 &&
blockingSuggestions().length === 0 &&
network().length > 0,
)
const disabled = createMemo(
() =>
permissions().length > 0 ||
blockingQuestions().length > 0 ||
blockingSuggestions().length > 0 ||
network().length > 0,
() => permissions().length === 0 && questions().length === 0 && network().length > 0,
)
const disabled = createMemo(() => permissions().length > 0 || questions().length > 0 || network().length > 0)
// kilocode_change end
const pending = createMemo(() => {
return messages().findLast((x) => x.role === "assistant" && !x.time.completed)?.id
@@ -179,7 +153,6 @@ export function Session() {
const lastAssistant = createMemo(() => {
return messages().findLast((x) => x.role === "assistant")
})
// kilocode_change end
// kilocode_change start - ring terminal bell on task completion
createEffect(
@@ -214,7 +187,7 @@ export function Session() {
)
createEffect(
on(
() => [route.sessionID, suggestions().length + network().length] as const, // kilocode_change
() => [route.sessionID, network().length] as const,
([id, len], prev) => {
if (!prev || prev[0] !== id) return
if (len > prev[1] && bellEnabled()) bell()
@@ -1258,32 +1231,15 @@ export function Session() {
<PermissionPrompt request={permissions()[0]} />
</Show>
{/* kilocode_change start */}
<Show when={permissions().length === 0 && question()} keyed>
{(request) => (
<QuestionPrompt
request={request}
nonBlocking={request.blocking === false}
inputFocused={() => prompt?.focused ?? false}
/>
)}
</Show>
<Show when={permissions().length === 0 && !question()}>
{/* kilocode_change end */}
{/* kilocode_change start */}
<Show when={suggestion()} keyed>
{(request) => (
<SuggestPrompt
request={request}
nonBlocking={request.blocking === false}
inputFocused={() => prompt?.focused ?? false}
/>
)}
</Show>
{/* kilocode_change start */}
<Show when={permissions().length === 0 && questions().length > 0}>
<QuestionPrompt request={questions()[0]} />
</Show>
{/* kilocode_change end */}
{/* kilocode_change end */}
<Show when={session()?.parentID}>
<SubagentFooter />
</Show>
{/* kilocode_change end */}
{/* kilocode_change start */}
<Show when={networkVisible()}>
<NetworkPrompt request={network()[0]} />
@@ -1701,11 +1657,6 @@ function ToolPart(props: { last: boolean; part: ToolPart; message: AssistantMess
<Match when={props.part.tool === "question"}>
<Question {...toolprops} />
</Match>
{/* kilocode_change start */}
<Match when={props.part.tool === "suggest"}>
<Suggest {...toolprops} InlineTool={InlineTool} BlockTool={BlockTool} />
</Match>
{/* kilocode_change end */}
<Match when={props.part.tool === "skill"}>
<Skill {...toolprops} />
</Match>
@@ -11,11 +11,7 @@ import { useTextareaKeybindings } from "../../component/textarea-keybindings"
import { useDialog } from "../../ui/dialog"
// kilocode_change start
export function QuestionPrompt(props: {
request: QuestionRequest
nonBlocking?: boolean
inputFocused?: () => boolean
}) {
export function QuestionPrompt(props: { request: QuestionRequest }) {
// kilocode_change end
const sdk = useSDK()
const { theme } = useTheme()
@@ -132,10 +128,6 @@ export function QuestionPrompt(props: {
// Skip processing if a dialog (e.g., command palette) is open
if (dialog.stack.length > 0) return
// kilocode_change start - avoid intrusive key capture for non-blocking review suggestions
if (props.nonBlocking && props.inputFocused?.()) return
// kilocode_change end
// When editing custom answer textarea
if (store.editing && !confirm()) {
if (evt.name === "escape") {
@@ -1,2 +0,0 @@
// kilocode_change - new file
export { SuggestPrompt } from "../../../../../kilocode/suggestion/tui/prompt"
-6
View File
@@ -202,10 +202,4 @@ export namespace Command {
export async function list() {
return runPromise((svc) => svc.list())
}
// kilocode_change start
export async function get(name: string) {
return runPromise((svc) => svc.get(name))
}
// kilocode_change end
}
-1
View File
@@ -8,7 +8,6 @@ export namespace Identifier {
message: "msg",
permission: "per",
question: "que",
suggestion: "sug", // kilocode_change
user: "usr",
part: "prt",
pty: "pty",
@@ -5,7 +5,6 @@ import { Instance } from "@/project/instance"
import { Session } from "@/session"
import { SessionPrompt } from "@/session/prompt"
import { Question } from "@/question"
import { Suggestion } from "@/kilocode/suggestion" // kilocode_change
import { Permission } from "@/permission"
import { PermissionID } from "@/permission/schema"
import { SessionID } from "@/session/schema"
@@ -25,11 +24,6 @@ const PermissionData = z.object({
message: z.string().optional(),
})
const SuggestionData = z.object({
requestID: z.string(),
index: z.number().int().nonnegative(),
})
// kilocode_change start — lazy init to avoid circular dependency
// (Server → RemoteRoutes → RemoteSender → SessionPrompt at module load time)
let _remotePromptInput: ReturnType<typeof SessionPrompt.PromptInput.extend> | undefined
@@ -39,6 +33,7 @@ function getRemotePromptInput() {
}))
}
// kilocode_change end
function normalizeModel(model: string | undefined) {
if (!model) return undefined
return {
@@ -119,24 +114,11 @@ export namespace RemoteSender {
}
}
// Replay pending suggestions/questions/permissions so a newly-subscribed web client
// Replay pending questions/permissions so a newly-subscribed web client
// sees state that was asked before it connected — analogous to the Cloud
// Agent's `connected` event carrying pending question/permission fields.
async function replay(sessionId: string) {
const [suggestions, questions, permissions] = await Promise.all([
Suggestion.list(),
Question.list(),
Permission.list(),
])
for (const suggestion of suggestions) {
if (suggestion.sessionID !== sessionId) continue
options.conn.send({
type: "event",
sessionId,
event: "suggestion.shown",
data: suggestion,
})
}
const [questions, permissions] = await Promise.all([Question.list(), Permission.list()])
for (const q of questions) {
if (q.sessionID !== sessionId) continue
options.conn.send({
@@ -307,37 +289,6 @@ export namespace RemoteSender {
dispatchQuick(msg, dir, () => Question.reject(QuestionID.make(parsed.data.requestID)))
return
}
if (msg.command === "suggestion_accept") {
const parsed = SuggestionData.safeParse(msg.data)
if (!parsed.success) {
options.conn.send({
type: "response",
id: msg.id,
error: "invalid suggestion_accept data: " + parsed.error.message,
})
return
}
const dir = msg.sessionId ? directoryFor(msg.sessionId) : Promise.resolve(options.directory)
dispatchQuick(msg, dir, async () => {
const ok = await Suggestion.accept(parsed.data)
if (!ok) throw new Error("suggestion not found or invalid action index")
})
return
}
if (msg.command === "suggestion_dismiss") {
const parsed = z.object({ requestID: z.string() }).safeParse(msg.data)
if (!parsed.success) {
options.conn.send({
type: "response",
id: msg.id,
error: "invalid suggestion_dismiss data: " + parsed.error.message,
})
return
}
const dir = msg.sessionId ? directoryFor(msg.sessionId) : Promise.resolve(options.directory)
dispatchQuick(msg, dir, () => Suggestion.dismiss(parsed.data.requestID))
return
}
if (msg.command === "permission_respond") {
const parsed = PermissionData.safeParse(msg.data)
if (!parsed.success) {
@@ -231,7 +231,6 @@ export function patchAgents(
defaults,
Permission.fromConfig({
question: "allow",
suggest: "allow", // kilocode_change
plan_exit: "allow",
bash: readOnlyBash,
...kilo.mcpRules,
@@ -290,7 +289,6 @@ export function patchAgents(
defaults,
Permission.fromConfig({
question: "allow",
suggest: "allow", // kilocode_change
plan_enter: "allow",
}),
user,
@@ -314,7 +312,6 @@ export function patchAgents(
glob: "allow",
list: "allow",
question: "allow",
suggest: "allow", // kilocode_change
task: "allow",
todoread: "allow",
todowrite: "allow",
@@ -115,7 +115,6 @@ export async function generateHandover(input: {
export namespace PlanFollowup {
const log = Log.create({ service: "plan.followup" })
export const PLAN_PREFIX = "Implement the following plan:"
export const ANSWER_NEW_SESSION = "Start new session"
export const ANSWER_CONTINUE = "Continue here"
@@ -351,51 +351,27 @@ export namespace Review {
}
/**
* Get uncommitted changes (staged + unstaged + untracked)
* Get uncommitted changes (staged + unstaged)
* Implements SCOPE-01
*
* Uses: git diff HEAD for tracked changes, plus git ls-files for untracked files
* Uses: git diff HEAD to capture both staged and unstaged changes
*/
export async function getUncommittedChanges(): Promise<DiffResult> {
log.info("getting uncommitted changes")
// git diff HEAD shows all uncommitted changes (staged + unstaged) for tracked files
// git diff HEAD shows all uncommitted changes (staged + unstaged)
// Using -c core.quotepath=false to handle unicode filenames
const result = await $`git -c core.quotepath=false diff HEAD`.cwd(Instance.directory).quiet().nothrow()
let raw = result.exitCode === 0 ? result.stdout.toString() : ""
if (result.exitCode !== 0) {
log.warn("git diff failed", {
exitCode: result.exitCode,
stderr: result.stderr.toString(),
})
return { files: [], raw: "" }
}
// Also include untracked files — git diff HEAD misses brand-new files
const untracked = await $`git ls-files --others --exclude-standard -z`.cwd(Instance.directory).quiet().nothrow()
if (untracked.exitCode === 0) {
const paths = untracked.stdout.toString().split("\0").filter(Boolean)
// Process in batches to avoid spawning hundreds of git processes
const batch = 20
for (let i = 0; i < paths.length; i += batch) {
const chunk = paths.slice(i, i + batch)
const diffs = await Promise.all(
chunk.map((p) =>
// --no-index exits 1 when files differ, which is expected
$`git -c core.quotepath=false diff --no-index -- /dev/null ${p}`
.cwd(Instance.directory)
.quiet()
.nothrow()
.then((fd) => fd.stdout.toString()),
),
)
for (const out of diffs) {
if (out) raw += out
}
}
}
const raw = result.stdout.toString()
const parsed = parseDiff(raw)
log.info("parsed uncommitted changes", {
@@ -12,7 +12,6 @@ import { KilocodeRoutes } from "../../server/routes/kilocode"
import { PermissionKilocodeRoutes } from "../permission/routes"
import { RemoteRoutes } from "../../server/routes/remote"
import { NetworkRoutes } from "../../server/routes/network"
import { SuggestionRoutes } from "../suggestion/routes"
import { createKiloRoutes } from "@kilocode/kilo-gateway"
import { Auth } from "../../auth"
import { errors } from "../../server/error"
@@ -28,7 +27,6 @@ export function register(app: Hono): Hono {
return app
.route("/permission", PermissionKilocodeRoutes())
.route("/network", NetworkRoutes())
.route("/suggestion", SuggestionRoutes())
.route("/telemetry", TelemetryRoutes())
.route("/remote", RemoteRoutes())
.route("/commit-message", CommitMessageRoutes())
-13
View File
@@ -12,16 +12,3 @@ You are Kilo, a highly skilled software engineer with extensive knowledge in man
# Code
- When making changes to code, always consider the context in which the code is being used. Ensure that your changes are compatible with the existing codebase and that they follow the project's coding standards and best practices.
## Suggestions
- Use the `question` tool only when you need an actual answer from the user.
- If the `suggest` tool is available, use it ONLY to offer a local code review — never for other actions like committing, pushing, running tests, or any other next step.
- When you have completed implementation work and you are at least 90% confident the task is done, use `suggest` to offer a code review of uncommitted changes.
- Only suggest review when the user's request appears fully addressed. Do not suggest it after every edit or partial implementation turn.
- Do not repeat a review suggestion that was already dismissed in this conversation.
- Keep suggestion text concise, use at most 1-2 actions, and make each accepted action prompt self-contained.
- When suggesting a code review, choose the right command for the action prompt:
- `/local-review-uncommitted` — for reviewing uncommitted working-tree changes (staged, unstaged, and untracked files).
- `/local-review` — for reviewing all committed changes on the current branch vs its base branch.
- Prefer `/local-review-uncommitted` when the work you just did has not been committed yet.
@@ -1,194 +0,0 @@
import { Bus } from "../../bus"
import { BusEvent } from "../../bus/bus-event"
import { Identifier } from "../../id/id"
import { Instance } from "../../project/instance"
import { Log } from "../../util/log"
import z from "zod"
export namespace Suggestion {
const log = Log.create({ service: "suggestion" })
export const Action = z
.object({
label: z.string().describe("Button or option label (1-5 words)"),
description: z.string().optional().describe("Brief explanation of what this action does"),
prompt: z.string().describe("Synthetic user prompt to inject when this action is accepted"),
})
.meta({
ref: "SuggestionAction",
})
export type Action = z.infer<typeof Action>
export const Info = z
.object({
text: z.string().describe("Suggestion text shown to the user"),
actions: z.array(Action).min(1).max(2).describe("Available actions the user can take"),
})
.meta({
ref: "SuggestionInfo",
})
export type Info = z.infer<typeof Info>
export const Request = z
.object({
id: Identifier.schema("suggestion"),
sessionID: Identifier.schema("session"),
text: z.string().describe("Suggestion text shown to the user"),
actions: z.array(Action).min(1).max(2).describe("Available actions the user can take"),
blocking: z.boolean().optional().describe("Whether this suggestion blocks prompt input (default: true)"),
tool: z
.object({
messageID: z.string(),
callID: z.string(),
})
.optional(),
})
.meta({
ref: "SuggestionRequest",
})
export type Request = z.infer<typeof Request>
export const Accept = z.object({
index: z.number().int().nonnegative().describe("Zero-based action index to accept"),
})
export type Accept = z.infer<typeof Accept>
export const Event = {
Shown: BusEvent.define("suggestion.shown", Request),
Accepted: BusEvent.define(
"suggestion.accepted",
z.object({
sessionID: z.string(),
requestID: z.string(),
index: z.number().int().nonnegative(),
action: Action,
}),
),
Dismissed: BusEvent.define(
"suggestion.dismissed",
z.object({
sessionID: z.string(),
requestID: z.string(),
}),
),
}
const state = Instance.state(async () => {
const pending: Record<
string,
{
info: Request
resolve: (action: Action) => void
reject: (error: any) => void
}
> = {}
return {
pending,
}
})
export async function show(input: {
sessionID: string
text: string
actions: Action[]
blocking?: boolean
tool?: { messageID: string; callID: string }
}): Promise<Action> {
const s = await state()
const id = Identifier.ascending("suggestion")
log.info("shown", { id, actions: input.actions.length })
return new Promise<Action>((resolve, reject) => {
const info: Request = {
id,
sessionID: input.sessionID,
text: input.text,
actions: input.actions,
blocking: input.blocking,
tool: input.tool,
}
s.pending[id] = {
info,
resolve,
reject,
}
Bus.publish(Event.Shown, info)
})
}
export async function accept(input: { requestID: string; index: number }): Promise<boolean> {
const s = await state()
const existing = s.pending[input.requestID]
if (!existing) {
log.warn("accept for unknown request", { requestID: input.requestID })
return false
}
const action = existing.info.actions[input.index]
if (!action) {
log.warn("accept for invalid action index", { requestID: input.requestID, index: input.index })
delete s.pending[input.requestID]
existing.reject(new Error(`Invalid action index: ${input.index}`))
return false
}
delete s.pending[input.requestID]
log.info("accepted", { requestID: input.requestID, index: input.index, label: action.label })
Bus.publish(Event.Accepted, {
sessionID: existing.info.sessionID,
requestID: existing.info.id,
index: input.index,
action,
})
existing.resolve(action)
return true
}
export async function dismiss(requestID: string): Promise<void> {
const s = await state()
const existing = s.pending[requestID]
if (!existing) {
log.warn("dismiss for unknown request", { requestID })
return
}
delete s.pending[requestID]
log.info("dismissed", { requestID })
Bus.publish(Event.Dismissed, {
sessionID: existing.info.sessionID,
requestID: existing.info.id,
})
existing.reject(new DismissedError())
}
export class DismissedError extends Error {
constructor() {
super("The user dismissed this suggestion")
}
}
export async function dismissAll(sessionID: string): Promise<void> {
const s = await state()
for (const [id, entry] of Object.entries(s.pending)) {
if (entry.info.sessionID !== sessionID) continue
delete s.pending[id]
log.info("dismissed", { requestID: id })
Bus.publish(Event.Dismissed, {
sessionID: entry.info.sessionID,
requestID: entry.info.id,
})
entry.reject(new DismissedError())
}
}
export async function list() {
return state().then((state) => Object.values(state.pending).map((item) => item.info))
}
}
@@ -1,99 +0,0 @@
import { errors } from "../../server/error"
import { NotFoundError } from "../../storage/db"
import { lazy } from "../../util/lazy"
import { Hono } from "hono"
import { describeRoute, resolver, validator } from "hono-openapi"
import z from "zod"
import { Suggestion } from "./index"
export const SuggestionRoutes = lazy(() =>
new Hono()
.get(
"/",
describeRoute({
summary: "List pending suggestions",
description: "Get all pending suggestion requests across all sessions.",
operationId: "suggestion.list",
responses: {
200: {
description: "List of pending suggestions",
content: {
"application/json": {
schema: resolver(Suggestion.Request.array()),
},
},
},
},
}),
async (c) => {
const suggestions = await Suggestion.list()
return c.json(suggestions)
},
)
.post(
"/:requestID/accept",
describeRoute({
summary: "Accept suggestion request",
description: "Accept a suggestion request from the AI assistant.",
operationId: "suggestion.accept",
responses: {
200: {
description: "Suggestion accepted successfully",
content: {
"application/json": {
schema: resolver(z.boolean()),
},
},
},
...errors(400, 404),
},
}),
validator(
"param",
z.object({
requestID: z.string(),
}),
),
validator("json", Suggestion.Accept),
async (c) => {
const params = c.req.valid("param")
const json = c.req.valid("json")
const ok = await Suggestion.accept({
requestID: params.requestID,
index: json.index,
})
if (!ok) throw new NotFoundError({ message: `Suggestion not found: ${params.requestID}` })
return c.json(true)
},
)
.post(
"/:requestID/dismiss",
describeRoute({
summary: "Dismiss suggestion request",
description: "Dismiss a suggestion request from the AI assistant.",
operationId: "suggestion.dismiss",
responses: {
200: {
description: "Suggestion dismissed successfully",
content: {
"application/json": {
schema: resolver(z.boolean()),
},
},
},
...errors(400, 404),
},
}),
validator(
"param",
z.object({
requestID: z.string(),
}),
),
async (c) => {
const params = c.req.valid("param")
await Suggestion.dismiss(params.requestID)
return c.json(true)
},
),
)
@@ -1,108 +0,0 @@
import { Command } from "../../command"
import { Flag } from "../../flag/flag"
import { Log } from "../../util/log"
import z from "zod"
import DESCRIPTION from "./tool.txt"
import { Tool } from "../../tool/tool"
import { Suggestion } from "./index"
const log = Log.create({ service: "tool.suggest" })
const Params = z.object({
suggest: z.string().describe("Short suggestion text shown to the user"),
actions: z.array(Suggestion.Action).min(1).max(2).describe("Available actions the user can take"),
})
type Meta = {
accepted?: Suggestion.Action
dismissed: boolean
truncated: boolean
}
/**
* If prompt starts with `/`, treat it as a slash-command reference.
* Resolve the command template and return its content so the LLM can
* act on it in the current turn — without injecting a synthetic user
* message or trying to dispatch a command on the same session (which
* would deadlock).
*/
async function resolve(prompt: string): Promise<string> {
if (!prompt.startsWith("/")) return prompt
const name = prompt.slice(1).split(/\s/, 1)[0]
if (!name) return prompt
const args = prompt.slice(1 + name.length).trim()
const cmd = await Command.get(name)
if (!cmd) {
log.warn("unknown command in suggestion action", { name })
return prompt
}
try {
const template = await cmd.template
log.info("resolved command template", { name, length: template.length })
return args ? `${template}\n\n${args}` : template
} catch (err) {
log.warn("failed to resolve command template", { name, err })
return prompt
}
}
export const SuggestTool = Tool.define<typeof Params, Meta>("suggest", {
description: DESCRIPTION,
parameters: Params,
async execute(params, ctx) {
const promise = Suggestion.show({
sessionID: ctx.sessionID,
text: params.suggest,
actions: params.actions,
blocking: Flag.KILO_CLIENT !== "vscode",
tool: ctx.callID ? { messageID: ctx.messageID, callID: ctx.callID } : undefined,
})
const listener = () =>
Suggestion.list().then((items: Suggestion.Request[]) => {
const match = items.find((item: Suggestion.Request) => item.tool?.callID === ctx.callID)
if (match) return Suggestion.dismiss(match.id)
})
ctx.abort.addEventListener("abort", listener, { once: true })
const action = await promise
.catch((error) => {
if (error instanceof Suggestion.DismissedError) return undefined
throw error
})
.finally(() => {
ctx.abort.removeEventListener("abort", listener)
})
if (!action) {
const metadata: Meta = {
accepted: undefined,
dismissed: true,
truncated: false,
}
return {
title: "Suggestion dismissed",
output: "User dismissed the suggestion.",
metadata,
}
}
const resolved = await resolve(action.prompt)
const metadata: Meta = {
accepted: action,
dismissed: false,
truncated: false,
}
return {
title: `User accepted: ${action.label}`,
output: `User accepted the suggestion "${action.label}". Carry out the following request now:\n\n${resolved}`,
metadata,
}
},
})
@@ -1,17 +0,0 @@
Use this tool to suggest a local code review to the user after completing implementation work.
This tool is ONLY for suggesting code review. Do NOT use it to suggest running tests, committing, pushing, or any other action.
Guidelines:
- Only suggest review when you are at least 90% confident the user's request is fully addressed
- Do not suggest review after every edit or partial implementation turn
- Do not repeat a review suggestion that was already dismissed in this conversation
- Keep the suggestion text concise and actionable
- Provide 1-2 actions maximum
- Make each action prompt self-contained so it can be injected as a synthetic user message
- If you need a real answer from the user, use the `question` tool instead
Choosing the right review command for the action prompt:
- Use `/local-review-uncommitted` as the action prompt for uncommitted working-tree changes (staged, unstaged, and untracked files)
- Use `/local-review` as the action prompt for committed branch-level changes
- Prefer `/local-review-uncommitted` when the work you just did has not been committed yet
@@ -1,173 +0,0 @@
/** @jsxImportSource @opentui/solid */
import { useKeyboard } from "@opentui/solid"
import type { SuggestionRequest } from "@kilocode/sdk/v2"
import { createMemo, createSignal, For } from "solid-js"
import { SplitBorder } from "../../../cli/cmd/tui/component/border"
import { useKeybind } from "../../../cli/cmd/tui/context/keybind"
import { useSDK } from "../../../cli/cmd/tui/context/sdk"
import { tint, useTheme } from "../../../cli/cmd/tui/context/theme"
import { useDialog } from "../../../cli/cmd/tui/ui/dialog"
const dismiss = {
label: "Dismiss",
description: "Dismiss this suggestion and continue",
}
export function SuggestPrompt(props: {
request: SuggestionRequest
nonBlocking?: boolean
inputFocused?: () => boolean
}) {
const sdk = useSDK()
const { theme } = useTheme()
const keybind = useKeybind()
const dialog = useDialog()
const options = createMemo(() => [...props.request.actions, dismiss])
const [selected, setSelected] = createSignal(0)
const [busy, setBusy] = createSignal(false)
function accept(index: number) {
if (busy()) return
setBusy(true)
sdk.client.suggestion
.accept({
requestID: props.request.id,
index,
})
.catch(() => {
setBusy(false)
})
}
function reject() {
if (busy()) return
setBusy(true)
sdk.client.suggestion
.dismiss({
requestID: props.request.id,
})
.catch(() => {
setBusy(false)
})
}
function choose(index: number) {
if (index >= props.request.actions.length) {
reject()
return
}
accept(index)
}
useKeyboard((evt) => {
if (dialog.stack.length > 0) return
if (props.nonBlocking && props.inputFocused?.()) return
const total = options().length
const max = Math.min(total, 9)
const digit = Number(evt.name)
if (!Number.isNaN(digit) && digit >= 1 && digit <= max) {
evt.preventDefault()
const index = digit - 1
setSelected(index)
choose(index)
return
}
if (evt.name === "up" || evt.name === "k") {
evt.preventDefault()
setSelected((selected() - 1 + total) % total)
return
}
if (evt.name === "down" || evt.name === "j") {
evt.preventDefault()
setSelected((selected() + 1) % total)
return
}
if (evt.name === "return") {
evt.preventDefault()
choose(selected())
return
}
if (evt.name === "escape" || keybind.match("app_exit", evt)) {
evt.preventDefault()
reject()
}
})
const note = createMemo(() => (busy() ? "Waiting..." : undefined))
return (
<box
backgroundColor={theme.backgroundPanel}
border={["left"]}
borderColor={theme.secondary}
customBorderChars={SplitBorder.customBorderChars}
>
<box gap={1} paddingLeft={1} paddingRight={3} paddingTop={1} paddingBottom={1}>
<box paddingLeft={1}>
<text fg={theme.text}>{props.request.text}</text>
</box>
<box>
<For each={options()}>
{(opt, i) => {
const active = () => i() === selected()
const muted = () => i() === props.request.actions.length
return (
<box
onMouseOver={() => setSelected(i())}
onMouseDown={() => setSelected(i())}
onMouseUp={() => choose(i())}
>
<box flexDirection="row">
<box backgroundColor={active() ? theme.backgroundElement : undefined} paddingRight={1}>
<text fg={active() ? tint(theme.textMuted, theme.secondary, 0.6) : theme.textMuted}>
{`${i() + 1}.`}
</text>
</box>
<box backgroundColor={active() ? theme.backgroundElement : undefined}>
<text fg={active() ? theme.secondary : muted() ? theme.textMuted : theme.text}>{opt.label}</text>
</box>
</box>
<box paddingLeft={3}>
<text fg={theme.textMuted}>{opt.description}</text>
</box>
</box>
)
}}
</For>
</box>
</box>
<box
flexDirection="row"
flexShrink={0}
gap={2}
paddingLeft={2}
paddingRight={3}
paddingBottom={1}
justifyContent="space-between"
>
<box flexDirection="row" gap={2}>
<text fg={theme.text}>
{"↑↓"} <span style={{ fg: theme.textMuted }}>select</span>
</text>
<text fg={theme.text}>
enter <span style={{ fg: theme.textMuted }}>choose</span>
</text>
<text fg={theme.text}>
esc <span style={{ fg: theme.textMuted }}>dismiss</span>
</text>
</box>
<text fg={theme.textMuted}>{note()}</text>
</box>
</box>
)
}
@@ -1,64 +0,0 @@
/** @jsxImportSource @opentui/solid */
import { createMemo, Show, type JSX } from "solid-js"
import { useTheme } from "../../../cli/cmd/tui/context/theme"
import type { ToolPart as MessageToolPart } from "@kilocode/sdk/v2"
type InlineProps = {
icon: string
complete: unknown
pending: string
part: MessageToolPart
children: JSX.Element
}
type BlockProps = {
title: string
part?: MessageToolPart
children: JSX.Element
}
export function Suggest(props: {
input: {
suggest?: string
}
metadata: {
accepted?: {
label: string
}
dismissed?: boolean
}
part: MessageToolPart
InlineTool: (props: InlineProps) => JSX.Element
BlockTool: (props: BlockProps) => JSX.Element
}) {
const { theme } = useTheme()
const accepted = createMemo(() => props.metadata.accepted)
const dismissed = createMemo(() => props.metadata.dismissed === true)
if (accepted() || dismissed()) {
return props.BlockTool({
title: "# Suggestion",
part: props.part,
children: (
<box gap={1}>
<text fg={theme.textMuted}>{props.input.suggest}</text>
<Show when={accepted()}>
<text fg={theme.text}>Accepted: {accepted()?.label}</text>
</Show>
<Show when={dismissed()}>
<text fg={theme.text}>Dismissed</text>
</Show>
</box>
),
})
}
return props.InlineTool({
icon: "→",
pending: "Suggesting next step...",
complete: props.part.state.status === "completed",
part: props.part,
children: props.input.suggest ?? "Suggested next step",
})
}
@@ -1,58 +0,0 @@
import { Binary } from "@opencode-ai/util/binary"
import type { SuggestionRequest } from "@kilocode/sdk/v2"
type RemovedEvent = {
type: "suggestion.accepted" | "suggestion.dismissed"
properties: {
sessionID: string
requestID: string
}
}
type ShownEvent = {
type: "suggestion.shown"
properties: SuggestionRequest
}
type Event = RemovedEvent | ShownEvent
type Store = {
suggestion: {
[sessionID: string]: SuggestionRequest[]
}
}
type SetStore = {
(key: "suggestion", sessionID: string, value: SuggestionRequest[]): void
}
export function handleSuggestionEvent(event: Event, store: Store, setStore: SetStore) {
if (event.type !== "suggestion.shown") {
const info = event.properties
const requests = store.suggestion[info.sessionID]
if (!requests) return
const match = Binary.search(requests, info.requestID, (r) => r.id)
if (!match.found) return
setStore("suggestion", info.sessionID, requests.toSpliced(match.index, 1))
return
}
const request = event.properties
const requests = store.suggestion[request.sessionID]
if (!requests) {
setStore("suggestion", request.sessionID, [request])
return
}
const match = Binary.search(requests, request.id, (r) => r.id)
if (match.found) {
const next = [...requests]
next[match.index] = request
setStore("suggestion", request.sessionID, next)
return
}
setStore("suggestion", request.sessionID, [
...requests.slice(0, match.index),
request,
...requests.slice(match.index),
])
}
@@ -26,11 +26,6 @@ export namespace KiloToolRegistry {
return true
}
/** Suggest tool is only registered for cli and vscode clients */
export function suggest(tool: Tool.Def): Tool.Def[] {
return ["cli", "vscode"].includes(Flag.KILO_CLIENT) ? [tool] : []
}
/** Kilo-specific tools to append to the builtin list */
export function extra(
tools: { codebase: Tool.Def; recall: Tool.Def },
-5
View File
@@ -37,7 +37,6 @@ export namespace Question {
id: QuestionID.zod,
sessionID: SessionID.zod,
questions: z.array(Info).describe("Questions to ask"),
blocking: z.boolean().optional().describe("Whether this question blocks prompt input (default: true)"), // kilocode_change
tool: z
.object({
messageID: MessageID.zod,
@@ -98,7 +97,6 @@ export namespace Question {
readonly ask: (input: {
sessionID: SessionID
questions: Info[]
blocking?: boolean // kilocode_change
tool?: { messageID: MessageID; callID: string }
}) => Effect.Effect<Answer[], RejectedError>
readonly reply: (input: { requestID: QuestionID; answers: Answer[] }) => Effect.Effect<void>
@@ -134,7 +132,6 @@ export namespace Question {
const ask = Effect.fn("Question.ask")(function* (input: {
sessionID: SessionID
questions: Info[]
blocking?: boolean // kilocode_change
tool?: { messageID: MessageID; callID: string }
}) {
const pending = (yield* InstanceState.get(state)).pending
@@ -146,7 +143,6 @@ export namespace Question {
id,
sessionID: input.sessionID,
questions: input.questions,
blocking: input.blocking, // kilocode_change
tool: input.tool,
}
pending.set(id, { info, deferred })
@@ -209,7 +205,6 @@ export namespace Question {
export async function ask(input: {
sessionID: SessionID
questions: Info[]
blocking?: boolean // kilocode_change
tool?: { messageID: MessageID; callID: string }
}): Promise<Answer[]> {
return runPromise((s) => s.ask(input))
@@ -1,2 +0,0 @@
// kilocode_change - new file
export { SuggestionRoutes } from "../../kilocode/suggestion/routes"
+1 -11
View File
@@ -19,7 +19,6 @@ import { SessionSummary } from "./summary"
import type { Provider } from "@/provider/provider"
import { Question } from "@/question"
import { KiloSessionProcessor } from "@/kilocode/session/processor" // kilocode_change
import { Suggestion } from "@/kilocode/suggestion" // kilocode_change
import { errorMessage } from "@/util/error"
import { isRecord } from "@/util/record"
@@ -209,11 +208,7 @@ export namespace SessionProcessor {
},
})
// kilocode_change start
if (
error instanceof Permission.RejectedError ||
error instanceof Question.RejectedError ||
error instanceof Suggestion.DismissedError
) {
if (error instanceof Permission.RejectedError || error instanceof Question.RejectedError) {
// kilocode_change end
ctx.blocked = ctx.shouldBreak
}
@@ -362,11 +357,6 @@ export namespace SessionProcessor {
case "tool-result": {
yield* completeToolCall(value.toolCallId, value.output)
// kilocode_change start
if (value.output.metadata?.dismissed === true) {
ctx.blocked = ctx.shouldBreak
}
// kilocode_change end
return
}
-6
View File
@@ -3,7 +3,6 @@ import os from "os"
import fs from "fs/promises"
import { KiloSessionPrompt } from "@/kilocode/session/prompt" // kilocode_change
import { KiloSession } from "@/kilocode/session" // kilocode_change
import { Suggestion } from "@/kilocode/suggestion" // kilocode_change
import z from "zod"
import { SessionID, MessageID, PartID } from "./schema"
import { MessageV2 } from "./message-v2"
@@ -1288,11 +1287,6 @@ NOTE: At any point in time through this workflow you should feel free to ask the
}
if (input.noReply === true) return message
// kilocode_change start — dismiss pending suggestions and cancel the session
// before starting a new loop to avoid the runner ignoring the new work
yield* Effect.promise(() => Suggestion.dismissAll(input.sessionID))
yield* state.cancel(input.sessionID)
// kilocode_change end
return yield* loop({ sessionID: input.sessionID })
},
)
@@ -1,2 +0,0 @@
// kilocode_change - new file
export { Suggestion } from "../kilocode/suggestion/index"
-3
View File
@@ -1,6 +1,5 @@
import { PlanExitTool } from "./plan"
import { QuestionTool } from "./question"
import { SuggestTool } from "../kilocode/suggestion/tool" // kilocode_change
import { BashTool } from "./bash"
import { EditTool } from "./edit"
import { GlobTool } from "./glob"
@@ -162,7 +161,6 @@ export namespace ToolRegistry {
question: Tool.init(question),
lsp: Tool.init(LspTool),
plan: Tool.init(PlanExitTool),
suggest: Tool.init(SuggestTool), // kilocode_change
})
const kilo = yield* KiloToolRegistry.build() // kilocode_change
@@ -187,7 +185,6 @@ export namespace ToolRegistry {
tool.patch,
...(Flag.KILO_EXPERIMENTAL_LSP_TOOL ? [tool.lsp] : []), // kilocode_change
...(KiloToolRegistry.plan() ? [tool.plan] : []), // kilocode_change
...KiloToolRegistry.suggest(tool.suggest), // kilocode_change
...KiloToolRegistry.extra(kilo, cfg), // kilocode_change
],
task: tool.task,
-2
View File
@@ -1,2 +0,0 @@
// kilocode_change - new file
export { SuggestTool } from "../kilocode/suggestion/tool"
@@ -6,7 +6,6 @@ import type { RemoteProtocol } from "../../../src/kilo-sessions/remote-protocol"
import { SessionPrompt } from "../../../src/session/prompt"
import { Question } from "../../../src/question"
import { Permission } from "../../../src/permission"
import { Suggestion } from "../../../src/kilocode/suggestion" // kilocode_change
function fakeConn() {
const sent: any[] = []
@@ -491,51 +490,6 @@ describe("RemoteSender", () => {
expect(sent[0].error).toContain("boom")
})
test("suggestion_accept sends response after work completes", async () => {
const { conn, sent } = fakeConn()
const accept = spyOn(Suggestion, "accept").mockResolvedValue(true)
const sender = RemoteSender.create({
conn,
directory: "/tmp/test",
log: nolog,
subscribe: fakeBus().subscribe,
provide: async <R>(input: { directory: string; init?: () => Promise<unknown>; fn: () => R }) => input.fn(),
})
sender.handle({
type: "command",
id: "req_suggestion_accept",
command: "suggestion_accept",
data: { requestID: "sug_1", index: 1 },
})
await new Promise((r) => setTimeout(r, 10))
expect(accept).toHaveBeenCalledWith({ requestID: "sug_1", index: 1 })
expect(sent).toContainEqual({ type: "response", id: "req_suggestion_accept", result: {} })
})
test("suggestion_dismiss with invalid data sends error response", () => {
const { conn, sent } = fakeConn()
const sender = RemoteSender.create({
conn,
directory: "/tmp/test",
log: nolog,
subscribe: fakeBus().subscribe,
provide: async () => ({}) as any,
})
sender.handle({
type: "command",
id: "req_suggestion_dismiss_bad",
command: "suggestion_dismiss",
data: { nope: true },
})
expect(sent).toHaveLength(1)
expect(sent[0].error).toContain("invalid suggestion_dismiss data")
})
test("question_reject sends response after work completes", async () => {
const { conn, sent } = fakeConn()
let provideCalled = false
@@ -859,7 +813,6 @@ describe("RemoteSender", () => {
const { conn, sent } = fakeConn()
const bus = fakeBus()
spyOn(Suggestion, "list").mockResolvedValue([])
spyOn(Question, "list").mockResolvedValue([
{ id: "question_1", sessionID: "ses_target", questions: [{ type: "text", text: "Continue?" }] } as any,
{ id: "question_2", sessionID: "ses_other", questions: [{ type: "text", text: "Unrelated?" }] } as any,
@@ -891,7 +844,6 @@ describe("RemoteSender", () => {
const { conn, sent } = fakeConn()
const bus = fakeBus()
spyOn(Suggestion, "list").mockResolvedValue([])
spyOn(Question, "list").mockResolvedValue([])
spyOn(Permission, "list").mockResolvedValue([
{
@@ -944,9 +896,6 @@ describe("RemoteSender", () => {
const { conn, sent } = fakeConn()
const bus = fakeBus()
spyOn(Suggestion, "list").mockResolvedValue([
{ id: "sug_1", sessionID: "ses_other", text: "Review?", actions: [] } as any,
])
spyOn(Question, "list").mockResolvedValue([{ id: "question_1", sessionID: "ses_other", questions: [] } as any])
spyOn(Permission, "list").mockResolvedValue([
{
@@ -974,53 +923,6 @@ describe("RemoteSender", () => {
expect(events).toHaveLength(0)
})
test("subscribe replays pending suggestion for the subscribed session", async () => {
const { conn, sent } = fakeConn()
const bus = fakeBus()
spyOn(Suggestion, "list").mockResolvedValue([
{
id: "sug_1",
sessionID: "ses_target",
text: "Review?",
actions: [{ label: "Start", prompt: "/local-review-uncommitted" }],
} as any,
{
id: "sug_2",
sessionID: "ses_other",
text: "Ignore",
actions: [{ label: "Skip", prompt: "skip" }],
} as any,
])
spyOn(Question, "list").mockResolvedValue([])
spyOn(Permission, "list").mockResolvedValue([])
const sender = RemoteSender.create({
conn,
directory: "/tmp/test",
log: nolog,
subscribe: bus.subscribe,
provide: async (input: any) => input.fn(),
})
sender.handle({ type: "subscribe", sessionId: "ses_target" })
await new Promise((r) => setTimeout(r, 10))
const suggestionEvents = sent.filter((m: any) => m.event === "suggestion.shown")
expect(suggestionEvents).toHaveLength(1)
expect(suggestionEvents[0]).toEqual({
type: "event",
sessionId: "ses_target",
event: "suggestion.shown",
data: {
id: "sug_1",
sessionID: "ses_target",
text: "Review?",
actions: [{ label: "Start", prompt: "/local-review-uncommitted" }],
},
})
})
test("system message is handled without error", () => {
const { conn, sent } = fakeConn()
const sender = RemoteSender.create({
@@ -1,76 +0,0 @@
import { describe, expect, test } from "bun:test"
import { Instance } from "../../../src/project/instance"
import { Suggestion } from "../../../src/kilocode/suggestion"
import { tmpdir } from "../../fixture/fixture"
describe("suggestion", () => {
test("show adds pending request with blocking flag", async () => {
await using tmp = await tmpdir({ git: true })
await Instance.provide({
directory: tmp.path,
fn: async () => {
const pending = Suggestion.show({
sessionID: "ses_test",
text: "Run review?",
blocking: false,
actions: [{ label: "Start", description: "Run it", prompt: "/local-review-uncommitted" }],
})
const list = await Suggestion.list()
expect(list).toHaveLength(1)
expect(list[0]?.blocking).toBe(false)
expect(list[0]?.text).toBe("Run review?")
await Suggestion.dismiss(list[0]!.id)
await expect(pending).rejects.toBeInstanceOf(Suggestion.DismissedError)
},
})
})
test("accept resolves selected action and removes pending request", async () => {
await using tmp = await tmpdir({ git: true })
await Instance.provide({
directory: tmp.path,
fn: async () => {
const ask = Suggestion.show({
sessionID: "ses_test",
text: "Next step?",
actions: [
{ label: "Review", description: "Start review", prompt: "/local-review-uncommitted" },
{ label: "Test", description: "Run tests", prompt: "Run the relevant tests now." },
],
})
const list = await Suggestion.list()
await Suggestion.accept({ requestID: list[0]!.id, index: 1 })
await expect(ask).resolves.toEqual({
label: "Test",
description: "Run tests",
prompt: "Run the relevant tests now.",
})
await expect(Suggestion.list()).resolves.toEqual([])
},
})
})
test("dismiss rejects pending request and removes it", async () => {
await using tmp = await tmpdir({ git: true })
await Instance.provide({
directory: tmp.path,
fn: async () => {
const ask = Suggestion.show({
sessionID: "ses_test",
text: "Review changes?",
actions: [{ label: "Start", prompt: "/local-review-uncommitted" }],
})
const list = await Suggestion.list()
await Suggestion.dismiss(list[0]!.id)
await expect(ask).rejects.toBeInstanceOf(Suggestion.DismissedError)
await expect(Suggestion.list()).resolves.toEqual([])
},
})
})
})
@@ -1,162 +0,0 @@
import { afterEach, beforeEach, describe, expect, test, spyOn } from "bun:test"
import { Command } from "../../../src/command"
import { Suggestion } from "../../../src/kilocode/suggestion"
import { SuggestTool } from "../../../src/kilocode/suggestion/tool"
const ctx = {
sessionID: "ses_test",
messageID: "msg_assistant",
callID: "call_suggest",
agent: "code",
abort: AbortSignal.any([]),
messages: [
{
info: {
id: "msg_user",
role: "user",
sessionID: "ses_test",
time: { created: 1 },
agent: "code",
model: { providerID: "openai", modelID: "gpt-4" },
},
parts: [],
},
],
metadata: () => {},
ask: async () => {},
}
describe("tool.suggest", () => {
let show: ReturnType<typeof spyOn>
let cmdGet: ReturnType<typeof spyOn>
beforeEach(() => {
show = spyOn(Suggestion, "show")
cmdGet = spyOn(Command, "get")
})
afterEach(() => {
show.mockRestore()
cmdGet.mockRestore()
})
test("returns dismissal result when suggestion is dismissed", async () => {
const tool = await SuggestTool.init()
show.mockRejectedValueOnce(new Suggestion.DismissedError())
const result = await tool.execute(
{
suggest: "Run review?",
actions: [{ label: "Start", prompt: "/local-review-uncommitted" }],
},
ctx as any,
)
expect(result.title).toBe("Suggestion dismissed")
expect(result.output).toBe("User dismissed the suggestion.")
expect(result.metadata.dismissed).toBe(true)
})
test("resolves command template for slash-command action prompt", async () => {
const tool = await SuggestTool.init()
show.mockResolvedValueOnce({
label: "Start review",
description: "Run a local review now",
prompt: "/local-review-uncommitted",
})
cmdGet.mockResolvedValueOnce({
name: "local-review-uncommitted",
description: "local review (uncommitted changes)",
template: Promise.resolve("Review these uncommitted changes:\n\n## Files Changed\n..."),
hints: [],
})
const result = await tool.execute(
{
suggest: "Run review?",
actions: [{ label: "Start review", prompt: "/local-review-uncommitted" }],
},
ctx as any,
)
expect(result.title).toBe("User accepted: Start review")
expect(result.output).toContain("Review these uncommitted changes:")
expect(result.output).toContain("Carry out the following request now")
expect(result.metadata.dismissed).toBe(false)
expect(result.metadata.accepted).toEqual({
label: "Start review",
description: "Run a local review now",
prompt: "/local-review-uncommitted",
})
expect(cmdGet).toHaveBeenCalledWith("local-review-uncommitted")
})
test("returns plain-text prompt directly for non-command actions", async () => {
const tool = await SuggestTool.init()
show.mockResolvedValueOnce({
label: "Run tests",
prompt: "Run the test suite and fix any failures",
})
const result = await tool.execute(
{
suggest: "Tests might need running",
actions: [{ label: "Run tests", prompt: "Run the test suite and fix any failures" }],
},
ctx as any,
)
expect(result.title).toBe("User accepted: Run tests")
expect(result.output).toContain("Run the test suite and fix any failures")
expect(result.output).toContain("Carry out the following request now")
expect(result.metadata.dismissed).toBe(false)
expect(cmdGet).not.toHaveBeenCalled()
})
test("falls back to raw prompt when command is not found", async () => {
const tool = await SuggestTool.init()
show.mockResolvedValueOnce({
label: "Unknown cmd",
prompt: "/nonexistent-command",
})
cmdGet.mockResolvedValueOnce(undefined)
const result = await tool.execute(
{
suggest: "Try this?",
actions: [{ label: "Unknown cmd", prompt: "/nonexistent-command" }],
},
ctx as any,
)
expect(result.title).toBe("User accepted: Unknown cmd")
expect(result.output).toContain("/nonexistent-command")
expect(result.metadata.dismissed).toBe(false)
})
test("falls back to raw prompt when template resolution fails", async () => {
const tool = await SuggestTool.init()
show.mockResolvedValueOnce({
label: "Start review",
prompt: "/local-review-uncommitted",
})
cmdGet.mockResolvedValueOnce({
name: "local-review-uncommitted",
description: "local review (uncommitted changes)",
template: Promise.reject(new Error("git not found")),
hints: [],
})
const result = await tool.execute(
{
suggest: "Run review?",
actions: [{ label: "Start review", prompt: "/local-review-uncommitted" }],
},
ctx as any,
)
expect(result.title).toBe("User accepted: Start review")
expect(result.output).toContain("/local-review-uncommitted")
expect(result.metadata.dismissed).toBe(false)
})
})
@@ -72,34 +72,6 @@ test("ask - adds to pending list", async () => {
})
})
// kilocode_change start - review follow-up uses non-blocking question prompts
test("ask - preserves blocking flag", async () => {
await using tmp = await tmpdir({ git: true })
await Instance.provide({
directory: tmp.path,
fn: async () => {
const askPromise = Question.ask({
sessionID: SessionID.make("ses_test"),
blocking: false,
questions: [
{
question: "Proceed with review suggestion?",
header: "Code review",
options: [{ label: "Start", description: "Run review" }],
},
],
})
const pending = await Question.list()
expect(pending[0]?.blocking).toBe(false)
await Question.reject(pending[0].id)
await expect(askPromise).rejects.toBeInstanceOf(Question.RejectedError)
},
})
})
// kilocode_change end
// reply tests
test("reply - resolves the pending ask with answers", async () => {
@@ -1,2 +0,0 @@
// kilocode_change - new file
// Moved to test/kilocode/suggestion/suggestion.test.ts.
@@ -32,37 +32,6 @@ describe("tool.registry", () => {
})
// kilocode_change end
// kilocode_change start
test("suggest is registered for cli and vscode only", async () => {
const original = process.env["KILO_CLIENT"]
const originalQuestion = process.env["KILO_ENABLE_QUESTION_TOOL"]
const originalConfig = process.env["KILO_CONFIG_DIR"]
try {
for (const client of ["cli", "vscode", "desktop", "app"]) {
process.env["KILO_CLIENT"] = client
process.env["KILO_ENABLE_QUESTION_TOOL"] = client === "vscode" ? "true" : "false"
await using tmp = await tmpdir({ git: true })
process.env["KILO_CONFIG_DIR"] = tmp.path
await Instance.provide({
directory: tmp.path,
fn: async () => {
const ids = await ToolRegistry.ids()
if (client === "cli" || client === "vscode") expect(ids).toContain("suggest")
else expect(ids).not.toContain("suggest")
},
})
}
} finally {
if (original === undefined) delete process.env["KILO_CLIENT"]
else process.env["KILO_CLIENT"] = original
if (originalQuestion === undefined) delete process.env["KILO_ENABLE_QUESTION_TOOL"]
else process.env["KILO_ENABLE_QUESTION_TOOL"] = originalQuestion
if (originalConfig === undefined) delete process.env["KILO_CONFIG_DIR"]
else process.env["KILO_CONFIG_DIR"] = originalConfig
}
})
// kilocode_change end
test("loads tools from .opencode/tool (singular)", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
@@ -1,2 +0,0 @@
// kilocode_change - new file
// Moved to test/kilocode/suggestion/tool.test.ts.