mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-09-24 16:02:55 +08:00
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:
@@ -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
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
},
|
||||
),
|
||||
)
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
},
|
||||
})
|
||||
@@ -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",
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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 }))
|
||||
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user