feat: replace mechanical review-followup with dedicated suggest tool

Replace the mechanical shouldAskReviewFollowup() detection with a
dedicated 'suggest' tool that the LLM calls when ~90% confident
implementation is complete. The suggest tool is distinct from the
question tool — it provides lightweight non-blocking nudges rather
than multi-step wizard prompts.

Backend: new Suggestion namespace, suggest tool, server routes, SDK
regeneration, agent permissions, processor dismiss handling, and
remote session replay/command support.

CLI TUI: suggestion state syncing in sync context, blocking/dismissable
SuggestPrompt picker, and suggest tool part rendering.

VS Code: SuggestBar component, session context suggestion state with
cleanup on session deletion, SSE event mapping, KiloProvider handlers,
and Storybook support.

Removes review-followup.ts and its detection/integration tests.
This commit is contained in:
Alex Alecu
2026-04-04 00:02:56 +03:00
parent b8b43f5cc1
commit 46eae0f083
39 changed files with 2664 additions and 2469 deletions
+25
View File
@@ -68,6 +68,12 @@ import {
type PermissionContext,
} from "./kilo-provider/handlers/permission-handler"
import { handleQuestionReply, handleQuestionReject } from "./kilo-provider/handlers/question"
import {
fetchAndSendPendingSuggestions,
handleSuggestionAccept,
handleSuggestionDismiss,
type SuggestionContext,
} from "./kilo-provider/handlers/suggestion"
import {
buildActionContext,
@@ -673,6 +679,12 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
case "questionReject":
await handleQuestionReject(this.questionCtx, message.requestID)
break
case "suggestionAccept":
await handleSuggestionAccept(this.suggestionCtx, message.requestID, message.index)
break
case "suggestionDismiss":
await handleSuggestionDismiss(this.suggestionCtx, message.requestID)
break
case "requestConfig":
this.fetchAndSendConfig().catch((e) => console.error("[Kilo New] fetchAndSendConfig failed:", e))
break
@@ -994,6 +1006,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
await this.syncWebviewState("sse-connected")
await this.flushPendingSessionRefresh("sse-connected")
await fetchAndSendPendingPermissions(this.permissionCtx)
await fetchAndSendPendingSuggestions(this.suggestionCtx)
} catch (error) {
console.error("[Kilo New] KiloProvider: ❌ Failed during connected state handling:", error)
this.postMessage({
@@ -1207,6 +1220,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
// Recover any permission.asked events that were missed while the webview
// was loading or during an SSE reconnection (fire-and-forget).
void fetchAndSendPendingPermissions(this.permissionCtx)
void fetchAndSendPendingSuggestions(this.suggestionCtx)
} catch (error) {
// Silently ignore aborted requests — the user switched to a different session
if (abort.signal.aborted) return
@@ -2280,6 +2294,17 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
}
}
private get suggestionCtx(): SuggestionContext {
return {
client: this.client,
currentSessionId: this.currentSession?.id,
trackedSessionIds: this.trackedSessionIds,
sessionDirectories: this.sessionDirectories,
postMessage: (msg: unknown) => this.postMessage(msg),
getWorkspaceDirectory: (sid?: string) => this.getWorkspaceDirectory(sid),
}
}
// Cloud session handlers extracted to kilo-provider/handlers/cloud-session.ts
private get cloudSessionCtx(): CloudSessionContext {
@@ -225,6 +225,19 @@ export type WebviewMessage =
question: { id: string; sessionID: string; questions: unknown[]; blocking?: boolean; tool?: unknown }
}
| { type: "questionResolved"; requestID: string }
| {
type: "suggestionRequest"
suggestion: {
id: string
sessionID: string
text: string
actions: unknown[]
blocking?: boolean
tool?: unknown
}
}
| { type: "suggestionResolved"; requestID: string }
| { type: "suggestionError"; requestID: string }
| { type: "permissionResolved"; permissionID: string }
| { type: "permissionError"; permissionID: string }
| { type: "sessionCreated"; session: ReturnType<typeof sessionToWebview> }
@@ -325,6 +338,24 @@ export function mapSSEEventToWebviewMessage(event: Event, sessionID: string | un
type: "questionResolved",
requestID: event.properties.requestID,
}
case "suggestion.shown":
return {
type: "suggestionRequest",
suggestion: {
id: event.properties.id,
sessionID: event.properties.sessionID,
text: event.properties.text,
actions: event.properties.actions,
blocking: event.properties.blocking,
tool: event.properties.tool,
},
}
case "suggestion.accepted":
case "suggestion.dismissed":
return {
type: "suggestionResolved",
requestID: event.properties.requestID,
}
case "session.error": {
return {
type: "sessionError",
@@ -0,0 +1,83 @@
/**
* Suggestion handlers — extracted from KiloProvider.
*
* Manages suggestion accept and dismiss flows plus recovery after SSE reconnects.
* No vscode dependency.
*/
import type { KiloClient, SuggestionRequest } from "@kilocode/sdk/v2/client"
import { recoveryDirs } from "./permission-handler"
export type RecoverableSuggestion = SuggestionRequest
export interface SuggestionContext {
readonly client: KiloClient | null
readonly currentSessionId: string | undefined
readonly trackedSessionIds: Set<string>
readonly sessionDirectories: ReadonlyMap<string, string>
postMessage(msg: unknown): void
getWorkspaceDirectory(sessionId?: string): string
}
export function recoverableSuggestions(items: RecoverableSuggestion[], tracked: Set<string>, seen: Set<string>) {
return items.filter((item) => {
if (seen.has(item.id)) return false
seen.add(item.id)
return tracked.has(item.sessionID)
})
}
export async function handleSuggestionAccept(ctx: SuggestionContext, requestID: string, index: number): Promise<void> {
if (!ctx.client) {
ctx.postMessage({ type: "suggestionError", requestID })
return
}
try {
await ctx.client.suggestion.accept(
{ requestID, index, directory: ctx.getWorkspaceDirectory(ctx.currentSessionId) },
{ throwOnError: true },
)
} catch (error) {
console.error("[Kilo New] KiloProvider: Failed to accept suggestion:", error)
ctx.postMessage({ type: "suggestionError", requestID })
}
}
export async function handleSuggestionDismiss(ctx: SuggestionContext, requestID: string): Promise<void> {
if (!ctx.client) {
ctx.postMessage({ type: "suggestionError", requestID })
return
}
try {
await ctx.client.suggestion.dismiss(
{ requestID, directory: ctx.getWorkspaceDirectory(ctx.currentSessionId) },
{ throwOnError: true },
)
} catch (error) {
console.error("[Kilo New] KiloProvider: Failed to dismiss suggestion:", error)
ctx.postMessage({ type: "suggestionError", requestID })
}
}
export async function fetchAndSendPendingSuggestions(ctx: SuggestionContext): Promise<void> {
if (!ctx.client) return
try {
const dirs = recoveryDirs(ctx.getWorkspaceDirectory(), ctx.sessionDirectories)
const seen = new Set<string>()
for (const dir of dirs) {
const { data } = await ctx.client.suggestion.list({ directory: dir })
if (!data) continue
for (const suggestion of recoverableSuggestions(data, ctx.trackedSessionIds, seen)) {
ctx.postMessage({
type: "suggestionRequest",
suggestion,
})
}
}
} catch (error) {
console.error("[Kilo New] KiloProvider: Failed to fetch pending suggestions:", error)
}
}
@@ -40,6 +40,9 @@ export function resolveEventSessionId(
case "question.asked":
case "question.replied":
case "question.rejected":
case "suggestion.shown":
case "suggestion.accepted":
case "suggestion.dismissed":
return event.properties.sessionID
default:
return undefined
@@ -149,6 +149,30 @@ describe("resolveEventSessionId", () => {
expect(resolveEventSessionId(e, noLookup)).toBe("s11")
})
it("returns sessionID from suggestion.shown", () => {
const e = event({
type: "suggestion.shown",
properties: { id: "sug_1", sessionID: "s12", text: "Review?", actions: [] },
})
expect(resolveEventSessionId(e, noLookup)).toBe("s12")
})
it("returns sessionID from suggestion.accepted", () => {
const e = event({
type: "suggestion.accepted",
properties: { sessionID: "s13", requestID: "sug_1", index: 0, action: { label: "Start", prompt: "x" } },
})
expect(resolveEventSessionId(e, noLookup)).toBe("s13")
})
it("returns sessionID from suggestion.dismissed", () => {
const e = event({
type: "suggestion.dismissed",
properties: { sessionID: "s14", requestID: "sug_2" },
})
expect(resolveEventSessionId(e, noLookup)).toBe("s14")
})
it("returns undefined for unknown event types (global events)", () => {
const e = event({ type: "server.connected", properties: {} })
expect(resolveEventSessionId(e, noLookup)).toBeUndefined()
@@ -24,6 +24,9 @@ import type {
EventQuestionAsked,
EventQuestionReplied,
EventQuestionRejected,
EventSuggestionShown,
EventSuggestionAccepted,
EventSuggestionDismissed,
EventSessionCreated,
EventSessionUpdated,
EventServerConnected,
@@ -394,6 +397,43 @@ describe("mapSSEEventToWebviewMessage", () => {
}
})
it("maps suggestion.shown to suggestionRequest", () => {
const event: EventSuggestionShown = {
type: "suggestion.shown",
properties: {
id: "sug-1",
sessionID: "sess-1",
text: "Review changes?",
actions: [{ label: "Start", prompt: "/local-review-uncommitted" }],
},
}
const msg = mapSSEEventToWebviewMessage(event, "sess-1")
expect(msg?.type).toBe("suggestionRequest")
})
it("maps suggestion.accepted to suggestionResolved", () => {
const event: EventSuggestionAccepted = {
type: "suggestion.accepted",
properties: {
sessionID: "sess-1",
requestID: "sug-1",
index: 0,
action: { label: "Start", prompt: "/local-review-uncommitted" },
},
}
const msg = mapSSEEventToWebviewMessage(event, "sess-1")
expect(msg?.type).toBe("suggestionResolved")
})
it("maps suggestion.dismissed to suggestionResolved", () => {
const event: EventSuggestionDismissed = {
type: "suggestion.dismissed",
properties: { sessionID: "sess-1", requestID: "sug-2" },
}
const msg = mapSSEEventToWebviewMessage(event, "sess-1")
expect(msg?.type).toBe("suggestionResolved")
})
it("maps session.created to sessionCreated with ISO dates", () => {
const event: EventSessionCreated = {
type: "session.created",
@@ -0,0 +1,89 @@
import { describe, expect, it } from "bun:test"
import {
fetchAndSendPendingSuggestions,
recoverableSuggestions,
type RecoverableSuggestion,
type SuggestionContext,
} from "../../src/kilo-provider/handlers/suggestion"
function pending(id: string, sessionID: string): RecoverableSuggestion {
return {
id,
sessionID,
text: "Review changes?",
actions: [{ label: "Start", prompt: "/local-review-uncommitted" }],
}
}
type Items = Record<string, RecoverableSuggestion[]>
function suggestionClient(itemsPerDir: Items, queries: string[]) {
return {
suggestion: {
list: async (args?: { directory?: string }) => {
const dir = args?.directory ?? ""
queries.push(dir)
return { data: itemsPerDir[dir] ?? [] }
},
accept: async () => ({ data: true }),
dismiss: async () => ({ data: true }),
},
}
}
function ctx(opts: { tracked: string[]; dirs?: Map<string, string>; itemsPerDir?: Items }) {
const messages: unknown[] = []
const queries: string[] = []
const sdk = suggestionClient(opts.itemsPerDir ?? {}, queries) as unknown as SuggestionContext["client"]
const fake: SuggestionContext = {
client: sdk,
currentSessionId: undefined,
trackedSessionIds: new Set(opts.tracked),
sessionDirectories: opts.dirs ?? new Map(),
postMessage: (msg) => messages.push(msg),
getWorkspaceDirectory: () => "/workspace",
}
return { fake, messages, queries }
}
describe("recoverableSuggestions", () => {
it("filters out untracked suggestions and deduplicates by id", () => {
const seen = new Set<string>()
const list = [pending("s1", "tracked"), pending("s1", "tracked"), pending("s2", "other")]
expect(recoverableSuggestions(list, new Set(["tracked"]), seen)).toEqual([pending("s1", "tracked")])
})
})
describe("fetchAndSendPendingSuggestions", () => {
it("forwards suggestions from tracked sessions", async () => {
const dirs = new Map([["s1", "/wt"]])
const { fake, messages, queries } = ctx({
tracked: ["s1"],
dirs,
itemsPerDir: { "/wt": [pending("sug-1", "s1")] },
})
await fetchAndSendPendingSuggestions(fake)
expect(queries).toContain("/workspace")
expect(queries).toContain("/wt")
expect(messages).toEqual([{ type: "suggestionRequest", suggestion: pending("sug-1", "s1") }])
})
it("does nothing when client is null", async () => {
const messages: unknown[] = []
const fake: SuggestionContext = {
client: null,
currentSessionId: undefined,
trackedSessionIds: new Set(["s1"]),
sessionDirectories: new Map(),
postMessage: (msg) => messages.push(msg),
getWorkspaceDirectory: () => "/workspace",
}
await fetchAndSendPendingSuggestions(fake)
expect(messages).toHaveLength(0)
})
})
@@ -14,6 +14,7 @@ import { MessageList } from "./MessageList"
import { PromptInput } from "./PromptInput"
import { QuestionDock } from "./QuestionDock"
import { PermissionDock } from "./PermissionDock"
import { SuggestBar } from "./SuggestBar"
import { StartupErrorBanner } from "./StartupErrorBanner"
import { useSession } from "../../context/session"
import { useVSCode } from "../../context/vscode"
@@ -54,8 +55,10 @@ export const ChatView: Component<ChatViewProps> = (props) => {
// not once per accessor call (questionRequest, permissionRequest, blocked all read these).
const familyPermissions = createMemo(() => session.scopedPermissions(id()))
const familyQuestions = createMemo(() => session.scopedQuestions(id()))
const familySuggestions = createMemo(() => session.scopedSuggestions(id()))
const blockingQuestions = () => familyQuestions().filter((q) => q.blocking !== false)
const nonBlockingQuestions = () => familyQuestions().filter((q) => q.blocking === false)
const suggestionRequest = () => familySuggestions()[0]
// Prefer blocking questions first, then fall back to non-blocking ones.
const questionRequest = () => blockingQuestions()[0] ?? nonBlockingQuestions()[0]
@@ -137,6 +140,9 @@ export const ChatView: Component<ChatViewProps> = (props) => {
<Show when={questionRequest()} keyed>
{(req) => <QuestionDock request={req} />}
</Show>
<Show when={suggestionRequest()} keyed>
{(req) => <SuggestBar request={req} />}
</Show>
<Show when={permissionRequest()} keyed>
{(perm) => (
<PermissionDock
@@ -0,0 +1,56 @@
import { Button } from "@kilocode/kilo-ui/button"
import { Icon } from "@kilocode/kilo-ui/icon"
import { IconButton } from "@kilocode/kilo-ui/icon-button"
import type { Component } from "solid-js"
import { For } from "solid-js"
import { useLanguage } from "../../context/language"
import { useSession } from "../../context/session"
import type { SuggestionRequest } from "../../types/messages"
export const SuggestBar: Component<{ request: SuggestionRequest }> = (props) => {
const session = useSession()
const language = useLanguage()
const accept = (index: number) => {
if (session.respondingSuggestions().has(props.request.id)) return
session.acceptSuggestion(props.request.id, index)
}
const dismiss = () => {
if (session.respondingSuggestions().has(props.request.id)) return
session.dismissSuggestion(props.request.id)
}
return (
<div data-component="suggest-bar">
<div data-slot="suggest-bar-copy">
<span data-slot="suggest-bar-icon">
<Icon name="brain" size="small" />
</span>
<span data-slot="suggest-bar-text">{props.request.text}</span>
</div>
<div data-slot="suggest-bar-actions">
<For each={props.request.actions}>
{(action, index) => (
<Button
variant={index() === 0 ? "secondary" : "ghost"}
size="small"
disabled={session.respondingSuggestions().has(props.request.id)}
onClick={() => accept(index())}
>
{action.label}
</Button>
)}
</For>
<IconButton
icon="close"
variant="ghost"
size="small"
disabled={session.respondingSuggestions().has(props.request.id)}
label={language.t("common.dismiss")}
onClick={dismiss}
/>
</div>
</div>
)
}
@@ -32,6 +32,7 @@ import type {
SessionStatusInfo,
PermissionRequest,
QuestionRequest,
SuggestionRequest,
TodoItem,
ModelSelection,
ContextUsage,
@@ -114,10 +115,14 @@ interface SessionContextValue {
// Pending question requests (unscoped — all tracked sessions)
questions: Accessor<QuestionRequest[]>
questionErrors: Accessor<Set<string>>
suggestions: Accessor<SuggestionRequest[]>
suggestionErrors: Accessor<Set<string>>
respondingSuggestions: Accessor<Set<string>>
// Scoped permissions/questions — filtered to a session's family (self + subagents)
scopedPermissions: (sessionID: string | undefined) => PermissionRequest[]
scopedQuestions: (sessionID: string | undefined) => QuestionRequest[]
scopedSuggestions: (sessionID: string | undefined) => SuggestionRequest[]
// Model selection (global, extension-lifetime)
selected: Accessor<ModelSelection | null>
@@ -180,6 +185,8 @@ interface SessionContextValue {
) => void
replyToQuestion: (requestID: string, answers: string[][]) => void
rejectQuestion: (requestID: string) => void
acceptSuggestion: (requestID: string, index: number) => void
dismissSuggestion: (requestID: string) => void
createSession: () => void
clearCurrentSession: () => void
loadSessions: () => void
@@ -236,6 +243,9 @@ export const SessionProvider: ParentComponent = (props) => {
// Tracks question IDs that failed so the UI can reset sending state
const [questionErrors, setQuestionErrors] = createSignal<Set<string>>(new Set())
const [suggestions, setSuggestions] = createSignal<SuggestionRequest[]>([])
const [suggestionErrors, setSuggestionErrors] = createSignal<Set<string>>(new Set())
const [respondingSuggestions, setRespondingSuggestions] = createSignal<Set<string>>(new Set())
// Tracks whether the user has explicitly set a model override per agent (to
// prevent the default-sync effect from overwriting it).
@@ -632,6 +642,18 @@ export const SessionProvider: ParentComponent = (props) => {
handleQuestionError(message.requestID)
break
case "suggestionRequest":
handleSuggestionRequest(message.suggestion)
break
case "suggestionResolved":
handleSuggestionResolved(message.requestID)
break
case "suggestionError":
handleSuggestionError(message.requestID)
break
case "sessionsLoaded":
handleSessionsLoaded(message.sessions)
break
@@ -926,6 +948,42 @@ export const SessionProvider: ParentComponent = (props) => {
setQuestionErrors((prev) => new Set(prev).add(requestID))
}
function handleSuggestionRequest(suggestion: SuggestionRequest) {
setSuggestions((prev) => {
const idx = prev.findIndex((item) => item.id === suggestion.id)
if (idx === -1) return [...prev, suggestion]
const next = prev.slice()
next[idx] = suggestion
return next
})
}
function handleSuggestionResolved(requestID: string) {
setSuggestions((prev) => prev.filter((item) => item.id !== requestID))
setRespondingSuggestions((prev) => {
if (!prev.has(requestID)) return prev
const next = new Set(prev)
next.delete(requestID)
return next
})
setSuggestionErrors((prev) => {
if (!prev.has(requestID)) return prev
const next = new Set(prev)
next.delete(requestID)
return next
})
}
function handleSuggestionError(requestID: string) {
setRespondingSuggestions((prev) => {
if (!prev.has(requestID)) return prev
const next = new Set(prev)
next.delete(requestID)
return next
})
setSuggestionErrors((prev) => new Set(prev).add(requestID))
}
/**
* Handle a failed send: remove the optimistic message from the store
* and show a toast. The PromptInput restores the draft text separately
@@ -1030,6 +1088,12 @@ export const SessionProvider: ParentComponent = (props) => {
return questions().filter((q) => family.has(q.sessionID))
}
function scopedSuggestions(sessionID: string | undefined): SuggestionRequest[] {
if (!sessionID) return []
const family = sessionFamily(sessionID)
return suggestions().filter((item) => family.has(item.sessionID))
}
function handleTodoUpdated(sessionID: string, items: TodoItem[]) {
setStore("todos", sessionID, items)
}
@@ -1106,6 +1170,24 @@ export const SessionProvider: ParentComponent = (props) => {
return next
})
}
const gone = suggestions()
.filter((item) => item.sessionID === sessionID)
.map((item) => item.id)
if (gone.length > 0) {
setSuggestions((prev) => prev.filter((item) => item.sessionID !== sessionID))
setSuggestionErrors((prev) => {
const next = new Set(prev)
for (const id of gone) next.delete(id)
if (next.size === prev.size) return prev
return next
})
setRespondingSuggestions((prev) => {
const next = new Set(prev)
for (const id of gone) next.delete(id)
if (next.size === prev.size) return prev
return next
})
}
setPermissions((prev) => removeSessionPermissions(prev, sessionID))
setStatusMap(
produce((map) => {
@@ -1311,6 +1393,8 @@ export const SessionProvider: ParentComponent = (props) => {
}
const sid = currentSessionID()
const suggestion = scopedSuggestions(sid)[0]
if (suggestion) dismissSuggestion(suggestion.id)
if (sid) addOptimistic(sid, messageID, text, files)
const agent = selectedAgentName() !== defaultAgent() ? selectedAgentName() : undefined
@@ -1356,6 +1440,8 @@ export const SessionProvider: ParentComponent = (props) => {
const messageID = Identifier.ascending("message")
const sid = currentSessionID()
const suggestion = scopedSuggestions(sid)[0]
if (suggestion) dismissSuggestion(suggestion.id)
if (sid) addOptimistic(sid, messageID, `/${command} ${args}`.trim(), files)
@@ -1442,6 +1528,15 @@ export const SessionProvider: ParentComponent = (props) => {
})
}
function clearSuggestionError(requestID: string) {
setSuggestionErrors((prev) => {
if (!prev.has(requestID)) return prev
const next = new Set(prev)
next.delete(requestID)
return next
})
}
function replyToQuestion(requestID: string, answers: string[][]) {
clearQuestionError(requestID)
vscode.postMessage({
@@ -1459,6 +1554,25 @@ export const SessionProvider: ParentComponent = (props) => {
})
}
function acceptSuggestion(requestID: string, index: number) {
clearSuggestionError(requestID)
setRespondingSuggestions((prev) => new Set(prev).add(requestID))
vscode.postMessage({
type: "suggestionAccept",
requestID,
index,
})
}
function dismissSuggestion(requestID: string) {
clearSuggestionError(requestID)
setRespondingSuggestions((prev) => new Set(prev).add(requestID))
vscode.postMessage({
type: "suggestionDismiss",
requestID,
})
}
function createSession() {
if (!server.isConnected()) {
console.warn("[Kilo New] Cannot create session: not connected")
@@ -1669,8 +1783,12 @@ export const SessionProvider: ParentComponent = (props) => {
respondingPermissions,
questions,
questionErrors,
suggestions,
suggestionErrors,
respondingSuggestions,
scopedPermissions,
scopedQuestions,
scopedSuggestions,
selected,
selectModel,
hasModelOverride,
@@ -1729,6 +1847,8 @@ export const SessionProvider: ParentComponent = (props) => {
respondToPermission,
replyToQuestion,
rejectQuestion,
acceptSuggestion,
dismissSuggestion,
createSession,
clearCurrentSession,
loadSessions,
@@ -33,7 +33,13 @@ import { dict as appEn } from "../i18n/en"
import { dict as amEn } from "../../agent-manager/i18n/en"
import { dict as kiloEn } from "@kilocode/kilo-i18n/en"
import { resolveTemplate } from "../context/language-utils"
import type { Config, KilocodeNotification, PermissionRequest, QuestionRequest } from "../types/messages"
import type {
Config,
KilocodeNotification,
PermissionRequest,
QuestionRequest,
SuggestionRequest,
} from "../types/messages"
// Merged English dictionary (same merge order as the real LanguageProvider)
const dict: Record<string, string> = { ...appEn, ...amEn, ...uiEn, ...kiloEn }
@@ -120,11 +126,13 @@ export function mockSessionValue(overrides?: {
id?: string
permissions?: PermissionRequest[]
questions?: QuestionRequest[]
suggestions?: SuggestionRequest[]
status?: string
}) {
const id = overrides?.id ?? "story-session-001"
const permissions = overrides?.permissions ?? []
const qs = overrides?.questions ?? []
const suggestions = overrides?.suggestions ?? []
const status = (overrides?.status ?? "idle") as "idle" | "busy"
return {
@@ -154,8 +162,12 @@ export function mockSessionValue(overrides?: {
respondingPermissions: () => new Set<string>(),
questions: () => qs,
questionErrors: () => new Set<string>(),
suggestions: () => suggestions,
suggestionErrors: () => new Set<string>(),
respondingSuggestions: () => new Set<string>(),
scopedPermissions: (sid?: string) => (sid ? permissions.filter((p) => p.sessionID === sid) : permissions),
scopedQuestions: (sid?: string) => (sid ? qs.filter((q) => q.sessionID === sid) : qs),
scopedSuggestions: (sid?: string) => (sid ? suggestions.filter((item) => item.sessionID === sid) : suggestions),
selected: () => ({ providerID: "kilo", modelID: "anthropic/claude-sonnet-4-6" }),
selectModel: noop,
hasModelOverride: () => false,
@@ -183,11 +195,14 @@ export function mockSessionValue(overrides?: {
currentVariant: () => undefined,
selectVariant: noop,
sendMessage: noop,
sendCommand: noop,
abort: noop,
compact: noop,
respondToPermission: noop,
replyToQuestion: noop,
rejectQuestion: noop,
acceptSuggestion: noop,
dismissSuggestion: noop,
createSession: noop,
clearCurrentSession: noop,
loadSessions: noop,
@@ -208,6 +223,7 @@ interface StoryProvidersProps {
data?: any
permissions?: PermissionRequest[]
questions?: QuestionRequest[]
suggestions?: SuggestionRequest[]
notifications?: KilocodeNotification[]
status?: string
sessionID?: string
@@ -239,6 +255,7 @@ export const StoryProviders: ParentComponent<StoryProvidersProps> = (props) => {
id: props.sessionID,
permissions: props.permissions,
questions: props.questions,
suggestions: props.suggestions,
status: props.status,
})
const notifications = mockNotificationsValue(props.notifications)
@@ -12,9 +12,10 @@ import { StoryProviders, mockSessionValue } from "./StoryProviders"
import { ChatView } from "../components/chat/ChatView"
import { TaskHeader } from "../components/chat/TaskHeader"
import { QuestionDock } from "../components/chat/QuestionDock"
import { SuggestBar } from "../components/chat/SuggestBar"
import { SessionContext } from "../context/session"
import { ServerContext } from "../context/server"
import type { QuestionRequest, TodoItem } from "../types/messages"
import type { QuestionRequest, SuggestionRequest, TodoItem } from "../types/messages"
const SESSION_ID = "story-session-chat-001"
@@ -66,6 +67,14 @@ const multiQuestion: QuestionRequest = {
tool: { messageID: "asst-msg-001", callID: "call-question-002" },
}
const reviewSuggestion: SuggestionRequest = {
id: "s-review-001",
sessionID: SESSION_ID,
text: "Start a code review of uncommitted changes?",
actions: [{ label: "Start review", description: "Run a local review now", prompt: "/local-review-uncommitted" }],
tool: { messageID: "asst-msg-002", callID: "call-suggest-001" },
}
// ---------------------------------------------------------------------------
// Meta
// ---------------------------------------------------------------------------
@@ -172,6 +181,17 @@ export const QuestionDockManyOptions: Story = {
),
}
export const SuggestBarReview: Story = {
name: "SuggestBar — review suggestion",
render: () => (
<StoryProviders sessionID={SESSION_ID} suggestions={[reviewSuggestion]}>
<div style={{ width: "100%" }}>
<SuggestBar request={reviewSuggestion} />
</div>
</StoryProviders>
),
}
// ---------------------------------------------------------------------------
// TaskHeader with todos
// ---------------------------------------------------------------------------
@@ -2955,6 +2955,51 @@ body.vscode-light
}
}
/* ============================================
Suggest Bar
============================================ */
[data-component="suggest-bar"] {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin: 8px 12px 0;
padding: 10px 12px;
border: 1px solid var(--border-weak-base);
border-radius: 10px;
background: color-mix(in srgb, var(--background-base) 88%, var(--vscode-textLink-foreground) 12%);
[data-slot="suggest-bar-copy"] {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
flex: 1;
}
[data-slot="suggest-bar-icon"] {
display: inline-flex;
align-items: center;
color: var(--text-info, var(--vscode-textLink-foreground));
flex-shrink: 0;
}
[data-slot="suggest-bar-text"] {
min-width: 0;
color: var(--text-base);
font-size: 12px;
line-height: 1.4;
}
[data-slot="suggest-bar-actions"] {
display: flex;
align-items: center;
gap: 6px;
flex-shrink: 0;
}
}
/* Collapsed: chevron points UP (rotate 180). Expanded: chevron points DOWN (default). */
[data-component="question-dock"][data-collapsed="true"] [data-slot="question-collapse-toggle"] {
transform: rotate(180deg);
@@ -197,6 +197,24 @@ export interface QuestionRequest {
}
}
export interface SuggestionAction {
label: string
description?: string
prompt: string
}
export interface SuggestionRequest {
id: string
sessionID: string
text: string
actions: SuggestionAction[]
blocking?: boolean
tool?: {
messageID: string
callID: string
}
}
// Skill info from CLI backend
export interface SkillInfo {
name: string
@@ -703,6 +721,21 @@ export interface QuestionErrorMessage {
requestID: string
}
export interface SuggestionRequestMessage {
type: "suggestionRequest"
suggestion: SuggestionRequest
}
export interface SuggestionResolvedMessage {
type: "suggestionResolved"
requestID: string
}
export interface SuggestionErrorMessage {
type: "suggestionError"
requestID: string
}
export interface BrowserSettings {
enabled: boolean
useSystemChrome: boolean
@@ -1276,6 +1309,9 @@ export type ExtensionMessage =
| QuestionRequestMessage
| QuestionResolvedMessage
| QuestionErrorMessage
| SuggestionRequestMessage
| SuggestionResolvedMessage
| SuggestionErrorMessage
| BrowserSettingsLoadedMessage
| ConfigLoadedMessage
| ConfigUpdatedMessage
@@ -1567,6 +1603,17 @@ export interface QuestionRejectRequest {
requestID: string
}
export interface SuggestionAcceptRequest {
type: "suggestionAccept"
requestID: string
index: number
}
export interface SuggestionDismissRequest {
type: "suggestionDismiss"
requestID: string
}
export interface DeleteSessionRequest {
type: "deleteSession"
sessionID: string
@@ -2046,6 +2093,8 @@ export type WebviewMessage =
| SetLanguageRequest
| QuestionReplyRequest
| QuestionRejectRequest
| SuggestionAcceptRequest
| SuggestionDismissRequest
| DeleteSessionRequest
| RenameSessionRequest
| RequestAutocompleteSettingsMessage
+5
View File
@@ -192,6 +192,7 @@ export namespace Agent {
...Object.fromEntries(whitelistedDirs.map((dir) => [dir, "allow"])),
},
question: "deny",
suggest: "deny",
plan_enter: "deny",
plan_exit: "deny",
// mirrors github.com/github/gitignore Node.gitignore pattern for .env files
@@ -215,6 +216,7 @@ export namespace Agent {
defaults,
PermissionNext.fromConfig({
question: "allow",
suggest: "allow",
plan_enter: "allow",
}),
user,
@@ -230,6 +232,7 @@ export namespace Agent {
defaults,
PermissionNext.fromConfig({
question: "allow",
suggest: "allow",
plan_exit: "allow",
external_directory: {
[path.join(Global.Path.data, "plans", "*")]: "allow",
@@ -256,6 +259,7 @@ export namespace Agent {
defaults,
PermissionNext.fromConfig({
question: "allow",
suggest: "allow",
plan_enter: "allow",
}),
user,
@@ -278,6 +282,7 @@ export namespace Agent {
list: "allow",
// bash: "allow", // kilocode_change - disabled to prevent orchestrator from writing files via shell commands instead of delegating to sub-agents
question: "allow",
suggest: "allow",
task: "allow",
todoread: "allow",
todowrite: "allow",
@@ -9,6 +9,7 @@ import type {
Command,
PermissionRequest,
QuestionRequest,
SuggestionRequest,
LspStatus,
McpStatus,
McpResource,
@@ -47,6 +48,9 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
question: {
[sessionID: string]: QuestionRequest[]
}
suggestion: {
[sessionID: string]: SuggestionRequest[]
}
config: Config
session: Session[]
session_status: {
@@ -87,6 +91,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
agent: [],
permission: {},
question: {},
suggestion: {},
command: [],
provider: [],
provider_default: {},
@@ -129,6 +134,9 @@ 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]
}),
)
fullSyncedSessions.delete(sessionID)
@@ -224,6 +232,44 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
break
}
case "suggestion.accepted":
case "suggestion.dismissed": {
const requests = store.suggestion[event.properties.sessionID]
if (!requests) break
const match = Binary.search(requests, event.properties.requestID, (r) => r.id)
if (!match.found) break
setStore(
"suggestion",
event.properties.sessionID,
produce((draft) => {
draft.splice(match.index, 1)
}),
)
break
}
case "suggestion.shown": {
const request = event.properties
const requests = store.suggestion[request.sessionID]
if (!requests) {
setStore("suggestion", request.sessionID, [request])
break
}
const match = Binary.search(requests, request.id, (r) => r.id)
if (match.found) {
setStore("suggestion", request.sessionID, match.index, reconcile(request))
break
}
setStore(
"suggestion",
request.sessionID,
produce((draft) => {
draft.splice(match.index, 0, request)
}),
)
break
}
case "todo.updated":
setStore("todo", event.properties.sessionID, event.properties.todos)
break
@@ -45,6 +45,7 @@ import type { ApplyPatchTool } from "@/tool/apply_patch"
import type { WebFetchTool } from "@/tool/webfetch"
import type { TaskTool } from "@/tool/task"
import type { QuestionTool } from "@/tool/question"
import type { SuggestTool } from "@/tool/suggest"
import type { SkillTool } from "@/tool/skill"
import { useKeyboard, useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid"
import { useSDK } from "@tui/context/sdk"
@@ -78,6 +79,7 @@ import { Filesystem } from "@/util/filesystem"
import { Global } from "@/global"
import { PermissionPrompt } from "./permission"
import { QuestionPrompt } from "./question"
import { SuggestPrompt } from "./suggest"
import { DialogExportOptions } from "../../ui/dialog-export-options"
import { formatTranscript } from "../../util/transcript"
import { UI } from "@/cli/ui.ts"
@@ -141,9 +143,16 @@ export function Session() {
if (session()?.parentID) return []
return children().flatMap((x) => sync.data.question[x.id] ?? [])
})
const suggestions = createMemo(() => {
if (session()?.parentID) return []
return children().flatMap((x) => sync.data.suggestion[x.id] ?? [])
})
const blockingQuestions = createMemo(() => questions().filter((q) => q.blocking !== false)) // kilocode_change
const nonBlockingQuestions = createMemo(() => questions().filter((q) => q.blocking === false)) // kilocode_change
const question = createMemo(() => blockingQuestions()[0] ?? nonBlockingQuestions()[0]) // kilocode_change
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 pending = createMemo(() => {
return messages().findLast((x) => x.role === "assistant" && !x.time.completed)?.id
@@ -184,6 +193,15 @@ export function Session() {
},
),
)
createEffect(
on(
() => [route.sessionID, suggestions().length] as const,
([id, len], prev) => {
if (!prev || prev[0] !== id) return
if (len > prev[1] && bellEnabled()) bell()
},
),
)
// kilocode_change end
const dimensions = useTerminalDimensions()
@@ -1212,8 +1230,24 @@ export function Session() {
/>
)}
</Show>
<Show when={permissions().length === 0 && !question()}>
<Show when={suggestion()} keyed>
{(request) => (
<SuggestPrompt
request={request}
nonBlocking={request.blocking === false}
inputFocused={() => prompt?.focused ?? false}
/>
)}
</Show>
</Show>
<Prompt
visible={!session()?.parentID && permissions().length === 0 && blockingQuestions().length === 0}
visible={
!session()?.parentID &&
permissions().length === 0 &&
blockingQuestions().length === 0 &&
blockingSuggestions().length === 0
}
ref={(r) => {
prompt = r
promptRef.set(r)
@@ -1222,7 +1256,9 @@ export function Session() {
r.set(route.initialPrompt)
}
}}
disabled={permissions().length > 0 || blockingQuestions().length > 0}
disabled={
permissions().length > 0 || blockingQuestions().length > 0 || blockingSuggestions().length > 0
}
onSubmit={() => {
toBottom()
}}
@@ -1617,6 +1653,9 @@ function ToolPart(props: { last: boolean; part: ToolPart; message: AssistantMess
<Match when={props.part.tool === "question"}>
<Question {...toolprops} />
</Match>
<Match when={props.part.tool === "suggest"}>
<Suggest {...toolprops} />
</Match>
<Match when={props.part.tool === "skill"}>
<Skill {...toolprops} />
</Match>
@@ -2270,6 +2309,40 @@ function Question(props: ToolProps<typeof QuestionTool>) {
)
}
function Suggest(props: ToolProps<typeof SuggestTool>) {
const { theme } = useTheme()
const accepted = createMemo(() => props.metadata.accepted)
const dismissed = createMemo(() => props.metadata.dismissed === true)
return (
<Switch>
<Match when={accepted() || dismissed()}>
<BlockTool title="# Suggestion" part={props.part}>
<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>
</BlockTool>
</Match>
<Match when={true}>
<InlineTool
icon="→"
pending="Suggesting next step..."
complete={props.part.state.status === "completed"}
part={props.part}
>
{props.input.suggest ?? "Suggested next step"}
</InlineTool>
</Match>
</Switch>
)
}
function Skill(props: ToolProps<typeof SkillTool>) {
return (
<InlineTool icon="→" pending="Loading skill..." complete={props.input.name} part={props.part}>
@@ -0,0 +1,171 @@
import { createMemo, createSignal, For } from "solid-js"
import { useKeyboard } from "@opentui/solid"
import type { SuggestionRequest } from "@kilocode/sdk/v2"
import { SplitBorder } from "../../component/border"
import { useKeybind } from "../../context/keybind"
import { useSDK } from "../../context/sdk"
import { tint, useTheme } from "../../context/theme"
import { useDialog } from "../../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
View File
@@ -7,6 +7,7 @@ export namespace Identifier {
message: "msg",
permission: "per",
question: "que",
suggestion: "sug",
user: "usr",
part: "prt",
pty: "pty",
@@ -5,6 +5,7 @@ import { Instance } from "@/project/instance"
import { Session } from "@/session"
import { SessionPrompt } from "@/session/prompt"
import { Question } from "@/question"
import { Suggestion } from "@/suggestion"
import { PermissionNext } from "@/permission/next"
import { Log } from "@/util/log"
import z from "zod"
@@ -20,11 +21,18 @@ const PermissionData = z.object({
message: z.string().optional(),
})
const RemotePromptInput = SessionPrompt.PromptInput.extend({
model: z.string().optional(),
const SuggestionData = z.object({
requestID: z.string(),
index: z.number().int().nonnegative(),
})
function normalizeModel(model: z.infer<typeof RemotePromptInput.shape.model>) {
const RemotePromptInput = z
.object({
model: z.string().optional(),
})
.passthrough()
function normalizeModel(model: string | undefined) {
if (!model) return undefined
return {
providerID: "kilo",
@@ -32,7 +40,7 @@ function normalizeModel(model: z.infer<typeof RemotePromptInput.shape.model>) {
}
}
function normalizePrompt(input: z.infer<typeof RemotePromptInput>): SessionPrompt.PromptInput {
function normalizePrompt(input: z.infer<typeof RemotePromptInput>) {
return {
...input,
model: normalizeModel(input.model),
@@ -90,11 +98,24 @@ export namespace RemoteSender {
}
}
// Replay pending questions/permissions so a newly-subscribed web client
// Replay pending suggestions/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 [questions, permissions] = await Promise.all([Question.list(), PermissionNext.list()])
const [suggestions, questions, permissions] = await Promise.all([
Suggestion.list(),
Question.list(),
PermissionNext.list(),
])
for (const suggestion of suggestions) {
if (suggestion.sessionID !== sessionId) continue
options.conn.send({
type: "event",
sessionId,
event: "suggestion.shown",
data: suggestion,
})
}
for (const q of questions) {
if (q.sessionID !== sessionId) continue
options.conn.send({
@@ -258,6 +279,32 @@ export namespace RemoteSender {
dispatchQuick(msg, () => Question.reject(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
}
dispatchQuick(msg, () => Suggestion.accept(parsed.data))
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
}
dispatchQuick(msg, () => Suggestion.dismiss(parsed.data.requestID))
return
}
if (msg.command === "permission_respond") {
const parsed = PermissionData.safeParse(msg.data)
if (!parsed.success) {
@@ -1,99 +0,0 @@
import { Flag } from "@/flag/flag"
import { Identifier } from "@/id/id"
import { Question } from "@/question"
import { Session } from "@/session"
import { MessageV2 } from "@/session/message-v2"
import { Review } from "@/kilocode/review/review"
export namespace ReviewFollowup {
export const ANSWER_START = "Start code review"
export const ANSWER_SKIP = "Continue without review"
async function inject(input: { sessionID: string; model: MessageV2.User["model"]; text: string }) {
const msg: MessageV2.User = {
id: Identifier.ascending("message"),
sessionID: input.sessionID,
role: "user",
time: {
created: Date.now(),
},
agent: "code",
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: true,
} satisfies MessageV2.TextPart)
}
function prompt(input: { sessionID: string; abort: AbortSignal }) {
const promise = Question.ask({
sessionID: input.sessionID,
blocking: Flag.KILO_CLIENT !== "vscode",
questions: [
{
question: "Start an immediate review of uncommitted changes?",
header: "Code review",
custom: false,
options: [
{
label: ANSWER_START,
description: "Run a local review for current uncommitted changes",
},
{
label: ANSWER_SKIP,
description: "Dismiss the review suggestion and continue",
},
],
},
],
})
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)
})
}
export async function ask(input: {
sessionID: string
messages: MessageV2.WithParts[]
abort: AbortSignal
}): Promise<"continue" | "break"> {
if (input.abort.aborted) return "break"
const user = input.messages
.slice()
.reverse()
.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 })
const answer = answers?.[0]?.[0]?.trim()
if (answer !== ANSWER_START) return "break"
const text = await Review.buildReviewPromptUncommitted()
await inject({
sessionID: input.sessionID,
model: user.model,
text,
})
return "continue"
}
}
+8
View File
@@ -12,3 +12,11 @@ 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 for lightweight next-step nudges that the user can accept or dismiss.
- 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.
- Keep suggestion text concise, use at most 1-2 actions, and make each accepted action prompt self-contained.
@@ -0,0 +1,98 @@
// kilocode_change - new file
import { Suggestion } from "@/suggestion"
import { errors } from "@/server/error"
import { lazy } from "@/util/lazy"
import { Hono } from "hono"
import { describeRoute, resolver, validator } from "hono-openapi"
import z from "zod"
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")
await Suggestion.accept({
requestID: params.requestID,
index: json.index,
})
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)
},
),
)
+2
View File
@@ -50,6 +50,7 @@ import { EnhancePromptRoutes } from "./routes/enhance-prompt" // kilocode_change
import { KilocodeRoutes } from "./routes/kilocode" // kilocode_change
import { Filesystem } from "@/util/filesystem"
import { QuestionRoutes } from "./routes/question"
import { SuggestionRoutes } from "./routes/suggestion"
import { PermissionRoutes } from "./routes/permission"
import { RemoteRoutes } from "./routes/remote" // kilocode_change
import { GlobalRoutes } from "./routes/global"
@@ -269,6 +270,7 @@ export namespace Server {
.route("/session", SessionRoutes())
.route("/permission", PermissionRoutes())
.route("/question", QuestionRoutes())
.route("/suggestion", SuggestionRoutes())
.route("/provider", ProviderRoutes())
.route("/telemetry", TelemetryRoutes()) // kilocode_change
.route("/remote", RemoteRoutes()) // kilocode_change
+7 -1
View File
@@ -15,6 +15,7 @@ import { Config } from "@/config/config"
import { SessionCompaction } from "./compaction"
import { PermissionNext } from "@/permission/next"
import { Question } from "@/question"
import { Suggestion } from "@/suggestion"
import { Telemetry } from "@kilocode/kilo-telemetry" // kilocode_change
import { Flag } from "@/flag/flag" // kilocode_change
@@ -199,6 +200,10 @@ export namespace SessionProcessor {
},
})
if (match.tool === "suggest" && value.output.metadata?.dismissed === true) {
blocked = shouldBreak
}
delete toolcalls[value.toolCallId]
}
break
@@ -222,7 +227,8 @@ export namespace SessionProcessor {
if (
value.error instanceof PermissionNext.RejectedError ||
value.error instanceof Question.RejectedError
value.error instanceof Question.RejectedError ||
value.error instanceof Suggestion.DismissedError
) {
blocked = shouldBreak
}
+1 -60
View File
@@ -47,7 +47,6 @@ import { iife } from "@/util/iife"
import { Shell } from "@/shell/shell"
import { Truncate } from "@/tool/truncation"
import { PlanFollowup } from "@/kilocode/plan-followup" // kilocode_change
import { ReviewFollowup } from "@/kilocode/review-followup" // kilocode_change
import { environmentDetails } from "@/kilocode/editor-context" // kilocode_change
// @ts-ignore
@@ -76,59 +75,6 @@ export namespace SessionPrompt {
)
}
const reviewTools = new Set(["edit", "write", "multiedit", "apply_patch"]) // kilocode_change
// kilocode_change start - ask review follow-up only after first implementation turn per session
function reviewTurns(messages: MessageV2.WithParts[]) {
const ordered = messages.toSorted((a, b) => (a.info.id < b.info.id ? -1 : a.info.id > b.info.id ? 1 : 0))
const users = ordered.flatMap((msg, index) =>
msg.info.role === "user"
? [
{
index,
user: msg.info as MessageV2.User,
},
]
: [],
)
return users.map((item, index) => ({
user: item.user,
turn: ordered.slice(item.index + 1, users[index + 1]?.index ?? ordered.length),
}))
}
function isImplementationTurn(input: { user: MessageV2.User; turn: MessageV2.WithParts[] }) {
if (!["code"].includes(input.user.agent)) return false
const hasPlanExit = input.turn.some((msg) =>
msg.parts.some((part) => part.type === "tool" && part.tool === "plan_exit" && part.state.status === "completed"),
)
if (hasPlanExit) return false
return input.turn.some((msg) =>
msg.parts.some((part) => part.type === "tool" && part.state.status === "completed" && reviewTools.has(part.tool)),
)
}
// kilocode_change start - share review follow-up trigger logic with tests
export function shouldAskReviewFollowup(input: { messages: MessageV2.WithParts[]; abort: AbortSignal }) {
if (input.abort.aborted) return false
if (!["cli", "vscode"].includes(Flag.KILO_CLIENT)) return false
const turns = reviewTurns(input.messages)
const latest = turns.at(-1)
if (!latest) return false
if (!isImplementationTurn(latest)) return false
const alreadyImplemented = turns.slice(0, -1).some(isImplementationTurn)
if (alreadyImplemented) return false
return true
}
// kilocode_change end
const log = Log.create({ service: "session.prompt" })
const state = Instance.state(
@@ -424,12 +370,6 @@ export namespace SessionPrompt {
const action = await PlanFollowup.ask({ sessionID, messages: msgs, abort })
if (action === "continue") continue
}
// kilocode_change start - ask review follow-up after implementation turns
if (shouldAskReviewFollowup({ messages: msgs, abort })) {
const action = await ReviewFollowup.ask({ sessionID, messages: msgs, abort })
if (action === "continue") continue
}
// kilocode_change end
// kilocode_change end
log.info("exiting loop", { sessionID })
break
@@ -842,6 +782,7 @@ export namespace SessionPrompt {
// kilocode_change start
if (result === "stop") {
if (!abort.aborted && !processor.message.error && (state()[sessionID]?.callbacks.length ?? 0) > 0) continue
if (abort.aborted || processor.message.error?.name === "MessageAbortedError") closeReason = "interrupted"
else if (processor.message.error) closeReason = "error"
break
+178
View File
@@ -0,0 +1,178 @@
// kilocode_change - new file
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<void> {
const s = await state()
const existing = s.pending[input.requestID]
if (!existing) {
log.warn("accept for unknown request", { requestID: input.requestID })
return
}
const action = existing.info.actions[input.index]
if (!action) {
log.warn("accept for invalid action index", { requestID: input.requestID, index: input.index })
return
}
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)
}
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 list() {
return state().then((x) => Object.values(x.pending).map((x) => x.info))
}
}
+5 -2
View File
@@ -1,5 +1,6 @@
import { PlanExitTool } from "./plan"
import { QuestionTool } from "./question"
import { SuggestTool } from "./suggest"
import { BashTool } from "./bash"
import { EditTool } from "./edit"
import { GlobTool } from "./glob"
@@ -99,11 +100,13 @@ export namespace ToolRegistry {
async function all(): Promise<Tool.Info[]> {
const custom = await state().then((x) => x.custom)
const config = await Config.get()
const question = ["app", "cli", "desktop"].includes(Flag.KILO_CLIENT) || Flag.KILO_ENABLE_QUESTION_TOOL
const interactive = ["app", "cli", "desktop", "vscode"].includes(Flag.KILO_CLIENT) || Flag.KILO_ENABLE_QUESTION_TOOL
const suggest = ["cli", "vscode"].includes(Flag.KILO_CLIENT) && interactive
return [
InvalidTool,
...(["app", "cli", "desktop", "vscode"].includes(Flag.KILO_CLIENT) && question ? [QuestionTool] : []), // kilocode_change
...(["app", "cli", "desktop", "vscode"].includes(Flag.KILO_CLIENT) && interactive ? [QuestionTool] : []), // kilocode_change
...(suggest ? [SuggestTool] : []),
BashTool,
ReadTool,
GlobTool,
+111
View File
@@ -0,0 +1,111 @@
// kilocode_change - new file
import { Flag } from "@/flag/flag"
import { Identifier } from "@/id/id"
import { Session } from "@/session"
import { MessageV2 } from "@/session/message-v2"
import { Suggestion } from "@/suggestion"
import z from "zod"
import DESCRIPTION from "./suggest.txt"
import { Tool } from "./tool"
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
}
async function inject(input: { sessionID: string; user: MessageV2.User; agent: string; text: string }) {
const msg: MessageV2.User = {
id: Identifier.ascending("message"),
sessionID: input.sessionID,
role: "user",
time: {
created: Date.now(),
},
agent: input.agent,
model: input.user.model,
variant: input.user.variant,
editorContext: input.user.editorContext,
}
await Session.updateMessage(msg)
await Session.updatePart({
id: Identifier.ascending("part"),
messageID: msg.id,
sessionID: input.sessionID,
type: "text",
text: input.text,
synthetic: true,
} satisfies MessageV2.TextPart)
}
export const SuggestTool = Tool.define<typeof Params, Meta>("suggest", {
description: DESCRIPTION,
parameters: Params,
async execute(params, ctx) {
const user = ctx.messages
.slice()
.reverse()
.find((msg) => msg.info.role === "user")?.info
if (!user || user.role !== "user") {
throw new Error("No user message found for suggestion context")
}
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.sessionID === ctx.sessionID)
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,
}
return {
title: "Suggestion dismissed",
output: "User dismissed the suggestion.",
metadata,
}
}
await inject({
sessionID: ctx.sessionID,
user,
agent: ctx.agent,
text: action.prompt,
})
const metadata: Meta = {
accepted: action,
dismissed: false,
}
return {
title: `User accepted: ${action.label}`,
output: `User accepted the suggestion "${action.label}". The accepted action prompt is: ${JSON.stringify(action.prompt)}. It has also been injected as a synthetic user message. Continue with that request now.`,
metadata,
}
},
})
+13
View File
@@ -0,0 +1,13 @@
Use this tool to suggest a lightweight next step to the user. Suggestions are nudges, not questions: the user can accept one action or dismiss the suggestion entirely.
Use cases:
- Suggesting a code review after implementation appears complete
- Suggesting running tests after making changes
- Suggesting a clear next step after finishing a task
Guidelines:
- Only suggest something when you are at least 90% confident it is appropriate
- 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
@@ -9,6 +9,7 @@ import type { RemoteProtocol } from "../../src/kilo-sessions/remote-protocol"
import { SessionPrompt } from "../../src/session/prompt"
import { Question } from "../../src/question"
import { PermissionNext } from "../../src/permission/next"
import { Suggestion } from "../../src/suggestion"
// kilocode_change end
function fakeConn() {
@@ -494,6 +495,51 @@ 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(undefined)
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
@@ -817,6 +863,7 @@ 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,
@@ -848,6 +895,7 @@ describe("RemoteSender", () => {
const { conn, sent } = fakeConn()
const bus = fakeBus()
spyOn(Suggestion, "list").mockResolvedValue([])
spyOn(Question, "list").mockResolvedValue([])
spyOn(PermissionNext, "list").mockResolvedValue([
{
@@ -900,6 +948,9 @@ 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(PermissionNext, "list").mockResolvedValue([
{
@@ -927,6 +978,53 @@ 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(PermissionNext, "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,617 +0,0 @@
import { describe, expect, test } from "bun:test"
import { Identifier } from "../../src/id/id"
import { PlanFollowup } from "../../src/kilocode/plan-followup"
import { Instance } from "../../src/project/instance"
import { Session } from "../../src/session"
import { MessageV2 } from "../../src/session/message-v2"
import { SessionPrompt } from "../../src/session/prompt"
import { Log } from "../../src/util/log"
import { tmpdir } from "../fixture/fixture"
Log.init({ print: false })
const model = {
providerID: "openai",
modelID: "gpt-4",
}
async function withInstance(fn: () => Promise<void>) {
await using tmp = await tmpdir({ git: true })
await Instance.provide({ directory: tmp.path, fn })
}
async function seed(input: {
agent: string
tools?: Array<{ tool: string; status?: MessageV2.ToolPart["state"]["status"] }>
}) {
const session = await Session.create({})
const user = await Session.updateMessage({
id: Identifier.ascending("message"),
role: "user",
sessionID: session.id,
time: { created: Date.now() },
agent: input.agent,
model,
})
await Session.updatePart({
id: Identifier.ascending("part"),
messageID: user.id,
sessionID: session.id,
type: "text",
text: "Do the work",
})
const assistant: MessageV2.Assistant = {
id: Identifier.ascending("message"),
role: "assistant",
sessionID: session.id,
time: { created: Date.now() },
parentID: user.id,
modelID: model.modelID,
providerID: model.providerID,
mode: input.agent,
agent: input.agent,
path: {
cwd: Instance.directory,
root: Instance.worktree,
},
cost: 0,
tokens: {
total: 0,
input: 0,
output: 0,
reasoning: 0,
cache: { read: 0, write: 0 },
},
finish: "end_turn",
}
await Session.updateMessage(assistant)
for (const tool of input.tools ?? []) {
await Session.updatePart({
id: Identifier.ascending("part"),
messageID: assistant.id,
sessionID: session.id,
type: "tool",
callID: Identifier.ascending("tool"),
tool: tool.tool,
state:
tool.status === "error"
? {
status: "error",
error: "boom",
input: {},
metadata: {},
time: { start: Date.now(), end: Date.now() },
}
: {
status: "completed",
input: {},
output: "ok",
title: tool.tool,
metadata: {},
time: { start: Date.now(), end: Date.now() },
},
} satisfies MessageV2.ToolPart)
}
return Session.messages({ sessionID: session.id })
}
async function seedTwoImplementationTurns() {
const session = await Session.create({})
const firstUser = await Session.updateMessage({
id: Identifier.ascending("message"),
role: "user",
sessionID: session.id,
time: { created: Date.now() },
agent: "code",
model,
})
await Session.updatePart({
id: Identifier.ascending("part"),
messageID: firstUser.id,
sessionID: session.id,
type: "text",
text: "Implement first step",
})
const firstAssistant: MessageV2.Assistant = {
id: Identifier.ascending("message"),
role: "assistant",
sessionID: session.id,
time: { created: Date.now() },
parentID: firstUser.id,
modelID: model.modelID,
providerID: model.providerID,
mode: "code",
agent: "code",
path: {
cwd: Instance.directory,
root: Instance.worktree,
},
cost: 0,
tokens: {
total: 0,
input: 0,
output: 0,
reasoning: 0,
cache: { read: 0, write: 0 },
},
finish: "end_turn",
}
await Session.updateMessage(firstAssistant)
await Session.updatePart({
id: Identifier.ascending("part"),
messageID: firstAssistant.id,
sessionID: session.id,
type: "tool",
callID: Identifier.ascending("tool"),
tool: "edit",
state: {
status: "completed",
input: {},
output: "ok",
title: "edit",
metadata: {},
time: { start: Date.now(), end: Date.now() },
},
} satisfies MessageV2.ToolPart)
const secondUser = await Session.updateMessage({
id: Identifier.ascending("message"),
role: "user",
sessionID: session.id,
time: { created: Date.now() },
agent: "code",
model,
})
await Session.updatePart({
id: Identifier.ascending("part"),
messageID: secondUser.id,
sessionID: session.id,
type: "text",
text: "Implement second step",
})
const secondAssistant: MessageV2.Assistant = {
id: Identifier.ascending("message"),
role: "assistant",
sessionID: session.id,
time: { created: Date.now() },
parentID: secondUser.id,
modelID: model.modelID,
providerID: model.providerID,
mode: "code",
agent: "code",
path: {
cwd: Instance.directory,
root: Instance.worktree,
},
cost: 0,
tokens: {
total: 0,
input: 0,
output: 0,
reasoning: 0,
cache: { read: 0, write: 0 },
},
finish: "end_turn",
}
await Session.updateMessage(secondAssistant)
await Session.updatePart({
id: Identifier.ascending("part"),
messageID: secondAssistant.id,
sessionID: session.id,
type: "tool",
callID: Identifier.ascending("tool"),
tool: "write",
state: {
status: "completed",
input: {},
output: "ok",
title: "write",
metadata: {},
time: { start: Date.now(), end: Date.now() },
},
} satisfies MessageV2.ToolPart)
return Session.messages({ sessionID: session.id })
}
async function seedPlanThenImplementation() {
const session = await Session.create({})
// Turn 1: plan turn that ends with plan_exit
const planUser = await Session.updateMessage({
id: Identifier.ascending("message"),
role: "user",
sessionID: session.id,
time: { created: Date.now() },
agent: "code",
model,
})
await Session.updatePart({
id: Identifier.ascending("part"),
messageID: planUser.id,
sessionID: session.id,
type: "text",
text: "Plan the feature",
})
const planAssistant: MessageV2.Assistant = {
id: Identifier.ascending("message"),
role: "assistant",
sessionID: session.id,
time: { created: Date.now() },
parentID: planUser.id,
modelID: model.modelID,
providerID: model.providerID,
mode: "code",
agent: "code",
path: {
cwd: Instance.directory,
root: Instance.worktree,
},
cost: 0,
tokens: {
total: 0,
input: 0,
output: 0,
reasoning: 0,
cache: { read: 0, write: 0 },
},
finish: "end_turn",
}
await Session.updateMessage(planAssistant)
await Session.updatePart({
id: Identifier.ascending("part"),
messageID: planAssistant.id,
sessionID: session.id,
type: "tool",
callID: Identifier.ascending("tool"),
tool: "plan_exit",
state: {
status: "completed",
input: {},
output: "ok",
title: "plan_exit",
metadata: {},
time: { start: Date.now(), end: Date.now() },
},
} satisfies MessageV2.ToolPart)
// Turn 2: implementation turn with edit tool
const implUser = await Session.updateMessage({
id: Identifier.ascending("message"),
role: "user",
sessionID: session.id,
time: { created: Date.now() },
agent: "code",
model,
})
await Session.updatePart({
id: Identifier.ascending("part"),
messageID: implUser.id,
sessionID: session.id,
type: "text",
text: "Implement it",
})
const implAssistant: MessageV2.Assistant = {
id: Identifier.ascending("message"),
role: "assistant",
sessionID: session.id,
time: { created: Date.now() },
parentID: implUser.id,
modelID: model.modelID,
providerID: model.providerID,
mode: "code",
agent: "code",
path: {
cwd: Instance.directory,
root: Instance.worktree,
},
cost: 0,
tokens: {
total: 0,
input: 0,
output: 0,
reasoning: 0,
cache: { read: 0, write: 0 },
},
finish: "end_turn",
}
await Session.updateMessage(implAssistant)
await Session.updatePart({
id: Identifier.ascending("part"),
messageID: implAssistant.id,
sessionID: session.id,
type: "tool",
callID: Identifier.ascending("tool"),
tool: "edit",
state: {
status: "completed",
input: {},
output: "ok",
title: "edit",
metadata: {},
time: { start: Date.now(), end: Date.now() },
},
} satisfies MessageV2.ToolPart)
return Session.messages({ sessionID: session.id })
}
async function seedHandoverSession() {
const session = await Session.create({})
const user = await Session.updateMessage({
id: Identifier.ascending("message"),
role: "user",
sessionID: session.id,
time: { created: Date.now() },
agent: "code",
model,
})
await Session.updatePart({
id: Identifier.ascending("part"),
messageID: user.id,
sessionID: session.id,
type: "text",
text: `${PlanFollowup.PLAN_PREFIX}\n\nStep 1: do something\nStep 2: do something else`,
})
const assistant: MessageV2.Assistant = {
id: Identifier.ascending("message"),
role: "assistant",
sessionID: session.id,
time: { created: Date.now() },
parentID: user.id,
modelID: model.modelID,
providerID: model.providerID,
mode: "code",
agent: "code",
path: {
cwd: Instance.directory,
root: Instance.worktree,
},
cost: 0,
tokens: {
total: 0,
input: 0,
output: 0,
reasoning: 0,
cache: { read: 0, write: 0 },
},
finish: "end_turn",
}
await Session.updateMessage(assistant)
await Session.updatePart({
id: Identifier.ascending("part"),
messageID: assistant.id,
sessionID: session.id,
type: "tool",
callID: Identifier.ascending("tool"),
tool: "edit",
state: {
status: "completed",
input: {},
output: "ok",
title: "edit",
metadata: {},
time: { start: Date.now(), end: Date.now() },
},
} satisfies MessageV2.ToolPart)
return Session.messages({ sessionID: session.id })
}
describe("review follow-up detection", () => {
test("triggers for code agent with implementation tool", () =>
withInstance(async () => {
const messages = await seed({
agent: "code",
tools: [{ tool: "edit" }],
})
expect(SessionPrompt.shouldAskReviewFollowup({ messages, abort: AbortSignal.any([]) })).toBe(true)
}))
test("does not trigger for orchestrator turns without plan context", () =>
withInstance(async () => {
const messages = await seed({
agent: "orchestrator",
tools: [{ tool: "task" }],
})
expect(SessionPrompt.shouldAskReviewFollowup({ messages, abort: AbortSignal.any([]) })).toBe(false)
}))
test("does not trigger for orchestrator turns without implementation tools", () =>
withInstance(async () => {
const messages = await seed({
agent: "orchestrator",
})
expect(SessionPrompt.shouldAskReviewFollowup({ messages, abort: AbortSignal.any([]) })).toBe(false)
}))
test("does not trigger for orchestrator even with plan context", () =>
withInstance(async () => {
const session = await Session.create({})
// Turn 1: plan turn that ends with plan_exit
const planUser = await Session.updateMessage({
id: Identifier.ascending("message"),
role: "user",
sessionID: session.id,
time: { created: Date.now() },
agent: "code",
model,
})
await Session.updatePart({
id: Identifier.ascending("part"),
messageID: planUser.id,
sessionID: session.id,
type: "text",
text: "Plan the feature",
})
const planAssistant: MessageV2.Assistant = {
id: Identifier.ascending("message"),
role: "assistant",
sessionID: session.id,
time: { created: Date.now() },
parentID: planUser.id,
modelID: model.modelID,
providerID: model.providerID,
mode: "code",
agent: "code",
path: {
cwd: Instance.directory,
root: Instance.worktree,
},
cost: 0,
tokens: {
total: 0,
input: 0,
output: 0,
reasoning: 0,
cache: { read: 0, write: 0 },
},
finish: "end_turn",
}
await Session.updateMessage(planAssistant)
await Session.updatePart({
id: Identifier.ascending("part"),
messageID: planAssistant.id,
sessionID: session.id,
type: "tool",
callID: Identifier.ascending("tool"),
tool: "plan_exit",
state: {
status: "completed",
input: {},
output: "ok",
title: "plan_exit",
metadata: {},
time: { start: Date.now(), end: Date.now() },
},
} satisfies MessageV2.ToolPart)
// Turn 2: orchestrator turn with task tool
const orchUser = await Session.updateMessage({
id: Identifier.ascending("message"),
role: "user",
sessionID: session.id,
time: { created: Date.now() },
agent: "orchestrator",
model,
})
await Session.updatePart({
id: Identifier.ascending("part"),
messageID: orchUser.id,
sessionID: session.id,
type: "text",
text: "Implement it",
})
const orchAssistant: MessageV2.Assistant = {
id: Identifier.ascending("message"),
role: "assistant",
sessionID: session.id,
time: { created: Date.now() },
parentID: orchUser.id,
modelID: model.modelID,
providerID: model.providerID,
mode: "orchestrator",
agent: "orchestrator",
path: {
cwd: Instance.directory,
root: Instance.worktree,
},
cost: 0,
tokens: {
total: 0,
input: 0,
output: 0,
reasoning: 0,
cache: { read: 0, write: 0 },
},
finish: "end_turn",
}
await Session.updateMessage(orchAssistant)
await Session.updatePart({
id: Identifier.ascending("part"),
messageID: orchAssistant.id,
sessionID: session.id,
type: "tool",
callID: Identifier.ascending("tool"),
tool: "task",
state: {
status: "completed",
input: {},
output: "ok",
title: "task",
metadata: {},
time: { start: Date.now(), end: Date.now() },
},
} satisfies MessageV2.ToolPart)
const messages = await Session.messages({ sessionID: session.id })
expect(SessionPrompt.shouldAskReviewFollowup({ messages, abort: AbortSignal.any([]) })).toBe(false)
}))
test("does not trigger for read-only turns", () =>
withInstance(async () => {
const messages = await seed({
agent: "code",
tools: [{ tool: "read" }],
})
expect(SessionPrompt.shouldAskReviewFollowup({ messages, abort: AbortSignal.any([]) })).toBe(false)
}))
test("does not trigger for non-implementation agents", () =>
withInstance(async () => {
const messages = await seed({
agent: "ask",
tools: [{ tool: "edit" }],
})
expect(SessionPrompt.shouldAskReviewFollowup({ messages, abort: AbortSignal.any([]) })).toBe(false)
}))
test("does not trigger when plan_exit exists in same turn", () =>
withInstance(async () => {
const messages = await seed({
agent: "code",
tools: [{ tool: "edit" }, { tool: "plan_exit" }],
})
expect(SessionPrompt.shouldAskReviewFollowup({ messages, abort: AbortSignal.any([]) })).toBe(false)
}))
test("does not trigger when implementation tool fails", () =>
withInstance(async () => {
const messages = await seed({
agent: "code",
tools: [{ tool: "edit", status: "error" }],
})
expect(SessionPrompt.shouldAskReviewFollowup({ messages, abort: AbortSignal.any([]) })).toBe(false)
}))
test("does not trigger on later implementation turns in same session", () =>
withInstance(async () => {
const messages = await seedTwoImplementationTurns()
expect(SessionPrompt.shouldAskReviewFollowup({ messages, abort: AbortSignal.any([]) })).toBe(false)
}))
test("triggers after same-session plan_exit followed by implementation turn", () =>
withInstance(async () => {
const messages = await seedPlanThenImplementation()
expect(SessionPrompt.shouldAskReviewFollowup({ messages, abort: AbortSignal.any([]) })).toBe(true)
}))
test("triggers when first user message starts with plan handover prefix", () =>
withInstance(async () => {
const messages = await seedHandoverSession()
expect(SessionPrompt.shouldAskReviewFollowup({ messages, abort: AbortSignal.any([]) })).toBe(true)
}))
})
@@ -1,170 +0,0 @@
import { afterAll, beforeAll, describe, expect, spyOn, test } from "bun:test"
import { Identifier } from "../../src/id/id"
import { ReviewFollowup } from "../../src/kilocode/review-followup"
import { Review } from "../../src/kilocode/review/review"
import { Instance } from "../../src/project/instance"
import { Question } from "../../src/question"
import { Session } from "../../src/session"
import { MessageV2 } from "../../src/session/message-v2"
import { Log } from "../../src/util/log"
import { tmpdir } from "../fixture/fixture"
Log.init({ print: false })
// Flag.KILO_CLIENT reads process.env at access time — set it so blocking is false
const prev = process.env["KILO_CLIENT"]
beforeAll(() => {
process.env["KILO_CLIENT"] = "vscode"
})
afterAll(() => {
if (prev === undefined) delete process.env["KILO_CLIENT"]
else process.env["KILO_CLIENT"] = prev
})
const model = {
providerID: "openai",
modelID: "gpt-4",
}
async function withInstance(fn: () => Promise<void>) {
await using tmp = await tmpdir({ git: true })
await Instance.provide({ directory: tmp.path, fn })
}
async function seed() {
const session = await Session.create({})
const user = await Session.updateMessage({
id: Identifier.ascending("message"),
role: "user",
sessionID: session.id,
time: {
created: Date.now(),
},
agent: "code",
model,
})
await Session.updatePart({
id: Identifier.ascending("part"),
messageID: user.id,
sessionID: session.id,
type: "text",
text: "Implement feature",
})
const assistant: MessageV2.Assistant = {
id: Identifier.ascending("message"),
role: "assistant",
sessionID: session.id,
time: {
created: Date.now(),
},
parentID: user.id,
modelID: model.modelID,
providerID: model.providerID,
mode: "code",
agent: "code",
path: {
cwd: Instance.directory,
root: Instance.worktree,
},
cost: 0,
tokens: {
total: 0,
input: 0,
output: 0,
reasoning: 0,
cache: {
read: 0,
write: 0,
},
},
finish: "end_turn",
}
await Session.updateMessage(assistant)
await Session.updatePart({
id: Identifier.ascending("part"),
messageID: assistant.id,
sessionID: session.id,
type: "tool",
callID: Identifier.ascending("tool"),
tool: "edit",
state: {
status: "completed",
input: {},
output: "done",
title: "edit",
metadata: {},
time: { start: Date.now(), end: Date.now() },
},
} satisfies MessageV2.ToolPart)
return {
sessionID: session.id,
messages: await Session.messages({ sessionID: session.id }),
}
}
async function latestUser(sessionID: string) {
const messages = await Session.messages({ sessionID })
return messages
.slice()
.reverse()
.find((item) => item.info.role === "user")
}
describe("review follow-up", () => {
test("ask returns break when dismissed", () =>
withInstance(async () => {
const seeded = await seed()
const pending = ReviewFollowup.ask({
sessionID: seeded.sessionID,
messages: seeded.messages,
abort: AbortSignal.any([]),
})
const list = await Question.list()
expect(list).toHaveLength(1)
expect(list[0]?.blocking).toBe(false)
await Question.reject(list[0].id)
await expect(pending).resolves.toBe("break")
}))
test("ask injects review kickoff prompt when accepted", () =>
withInstance(async () => {
const seeded = await seed()
const review = spyOn(Review, "buildReviewPromptUncommitted").mockResolvedValue("Run local review now")
await using _spy = {
[Symbol.dispose]() {
review.mockRestore()
},
}
const pending = ReviewFollowup.ask({
sessionID: seeded.sessionID,
messages: seeded.messages,
abort: AbortSignal.any([]),
})
const list = await Question.list()
expect(list[0]?.blocking).toBe(false)
await Question.reply({
requestID: list[0].id,
answers: [[ReviewFollowup.ANSWER_START]],
})
await expect(pending).resolves.toBe("continue")
expect(review).toHaveBeenCalledTimes(1)
const user = await latestUser(seeded.sessionID)
expect(user?.info.role).toBe("user")
if (!user || user.info.role !== "user") return
expect(user.info.agent).toBe("code")
const part = user.parts.find((item) => item.type === "text")
expect(part?.type).toBe("text")
if (!part || part.type !== "text") return
expect(part.text).toBe("Run local review now")
expect(part.synthetic).toBe(true)
}))
})
@@ -0,0 +1,76 @@
import { describe, expect, test } from "bun:test"
import { Instance } from "../../src/project/instance"
import { Suggestion } from "../../src/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([])
},
})
})
})
@@ -28,6 +28,35 @@ describe("tool.registry", () => {
})
// kilocode_change end
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
}
})
test("loads tools from .opencode/tool (singular)", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
@@ -0,0 +1,90 @@
import { afterEach, beforeEach, describe, expect, test, spyOn } from "bun:test"
import { Session } from "../../src/session"
import { Suggestion } from "../../src/suggestion"
import { SuggestTool } from "../../src/tool/suggest"
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 updateMessage: ReturnType<typeof spyOn>
let updatePart: ReturnType<typeof spyOn>
beforeEach(() => {
show = spyOn(Suggestion, "show")
updateMessage = spyOn(Session, "updateMessage").mockResolvedValue({} as never)
updatePart = spyOn(Session, "updatePart").mockResolvedValue({} as never)
})
afterEach(() => {
show.mockRestore()
updateMessage.mockRestore()
updatePart.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("returns accepted action metadata when suggestion is accepted", async () => {
const tool = await SuggestTool.init()
show.mockResolvedValueOnce({
label: "Start review",
description: "Run a local review now",
prompt: "/local-review-uncommitted",
})
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("Continue with that request now")
expect(result.metadata.dismissed).toBe(false)
expect(updateMessage).toHaveBeenCalledTimes(1)
expect(updatePart).toHaveBeenCalledTimes(1)
expect(result.metadata.accepted).toEqual({
label: "Start review",
description: "Run a local review now",
prompt: "/local-review-uncommitted",
})
})
})
+113
View File
@@ -179,6 +179,11 @@ import type {
SessionUpdateResponses,
SessionViewedResponses,
SubtaskPartInput,
SuggestionAcceptErrors,
SuggestionAcceptResponses,
SuggestionDismissErrors,
SuggestionDismissResponses,
SuggestionListResponses,
TelemetryCaptureErrors,
TelemetryCaptureResponses,
TextPartInput,
@@ -2713,6 +2718,109 @@ export class Question extends HeyApiClient {
}
}
export class Suggestion extends HeyApiClient {
/**
* List pending suggestions
*
* Get all pending suggestion requests across all sessions.
*/
public list<ThrowOnError extends boolean = false>(
parameters?: {
directory?: string
workspace?: string
},
options?: Options<never, ThrowOnError>,
) {
const params = buildClientParams(
[parameters],
[
{
args: [
{ in: "query", key: "directory" },
{ in: "query", key: "workspace" },
],
},
],
)
return (options?.client ?? this.client).get<SuggestionListResponses, unknown, ThrowOnError>({
url: "/suggestion",
...options,
...params,
})
}
/**
* Accept suggestion request
*
* Accept a suggestion request from the AI assistant.
*/
public accept<ThrowOnError extends boolean = false>(
parameters: {
requestID: string
directory?: string
workspace?: string
index?: number
},
options?: Options<never, ThrowOnError>,
) {
const params = buildClientParams(
[parameters],
[
{
args: [
{ in: "path", key: "requestID" },
{ in: "query", key: "directory" },
{ in: "query", key: "workspace" },
{ in: "body", key: "index" },
],
},
],
)
return (options?.client ?? this.client).post<SuggestionAcceptResponses, SuggestionAcceptErrors, ThrowOnError>({
url: "/suggestion/{requestID}/accept",
...options,
...params,
headers: {
"Content-Type": "application/json",
...options?.headers,
...params.headers,
},
})
}
/**
* Dismiss suggestion request
*
* Dismiss a suggestion request from the AI assistant.
*/
public dismiss<ThrowOnError extends boolean = false>(
parameters: {
requestID: string
directory?: string
workspace?: string
},
options?: Options<never, ThrowOnError>,
) {
const params = buildClientParams(
[parameters],
[
{
args: [
{ in: "path", key: "requestID" },
{ in: "query", key: "directory" },
{ in: "query", key: "workspace" },
],
},
],
)
return (options?.client ?? this.client).post<SuggestionDismissResponses, SuggestionDismissErrors, ThrowOnError>({
url: "/suggestion/{requestID}/dismiss",
...options,
...params,
})
}
}
export class Oauth extends HeyApiClient {
/**
* OAuth authorize
@@ -4802,6 +4910,11 @@ export class KiloClient extends HeyApiClient {
return (this._question ??= new Question({ client: this.client }))
}
private _suggestion?: Suggestion
get suggestion(): Suggestion {
return (this._suggestion ??= new Suggestion({ client: this.client }))
}
private _provider?: Provider
get provider(): Provider {
return (this._provider ??= new Provider({ client: this.client }))
+154
View File
@@ -699,6 +699,65 @@ export type EventQuestionRejected = {
}
}
export type SuggestionAction = {
/**
* Button or option label (1-5 words)
*/
label: string
/**
* Brief explanation of what this action does
*/
description?: string
/**
* Synthetic user prompt to inject when this action is accepted
*/
prompt: string
}
export type SuggestionRequest = {
id: string
sessionID: string
/**
* Suggestion text shown to the user
*/
text: string
/**
* Available actions the user can take
*/
actions: Array<SuggestionAction>
/**
* Whether this suggestion blocks prompt input (default: true)
*/
blocking?: boolean
tool?: {
messageID: string
callID: string
}
}
export type EventSuggestionShown = {
type: "suggestion.shown"
properties: SuggestionRequest
}
export type EventSuggestionAccepted = {
type: "suggestion.accepted"
properties: {
sessionID: string
requestID: string
index: number
action: SuggestionAction
}
}
export type EventSuggestionDismissed = {
type: "suggestion.dismissed"
properties: {
sessionID: string
requestID: string
}
}
export type EventSessionCompacted = {
type: "session.compacted"
properties: {
@@ -1016,6 +1075,9 @@ export type Event =
| EventQuestionAsked
| EventQuestionReplied
| EventQuestionRejected
| EventSuggestionShown
| EventSuggestionAccepted
| EventSuggestionDismissed
| EventSessionCompacted
| EventFileWatcherUpdated
| EventTodoUpdated
@@ -4159,6 +4221,98 @@ export type QuestionRejectResponses = {
export type QuestionRejectResponse = QuestionRejectResponses[keyof QuestionRejectResponses]
export type SuggestionListData = {
body?: never
path?: never
query?: {
directory?: string
workspace?: string
}
url: "/suggestion"
}
export type SuggestionListResponses = {
/**
* List of pending suggestions
*/
200: Array<SuggestionRequest>
}
export type SuggestionListResponse = SuggestionListResponses[keyof SuggestionListResponses]
export type SuggestionAcceptData = {
body?: {
/**
* Zero-based action index to accept
*/
index: number
}
path: {
requestID: string
}
query?: {
directory?: string
workspace?: string
}
url: "/suggestion/{requestID}/accept"
}
export type SuggestionAcceptErrors = {
/**
* Bad request
*/
400: BadRequestError
/**
* Not found
*/
404: NotFoundError
}
export type SuggestionAcceptError = SuggestionAcceptErrors[keyof SuggestionAcceptErrors]
export type SuggestionAcceptResponses = {
/**
* Suggestion accepted successfully
*/
200: boolean
}
export type SuggestionAcceptResponse = SuggestionAcceptResponses[keyof SuggestionAcceptResponses]
export type SuggestionDismissData = {
body?: never
path: {
requestID: string
}
query?: {
directory?: string
workspace?: string
}
url: "/suggestion/{requestID}/dismiss"
}
export type SuggestionDismissErrors = {
/**
* Bad request
*/
400: BadRequestError
/**
* Not found
*/
404: NotFoundError
}
export type SuggestionDismissError = SuggestionDismissErrors[keyof SuggestionDismissErrors]
export type SuggestionDismissResponses = {
/**
* Suggestion dismissed successfully
*/
200: boolean
}
export type SuggestionDismissResponse = SuggestionDismissResponses[keyof SuggestionDismissResponses]
export type ProviderListData = {
body?: never
path?: never
+720 -1510
View File
File diff suppressed because it is too large Load Diff