diff --git a/packages/kilo-vscode/src/KiloProvider.ts b/packages/kilo-vscode/src/KiloProvider.ts index 217cd12c020..bf6715a0b11 100644 --- a/packages/kilo-vscode/src/KiloProvider.ts +++ b/packages/kilo-vscode/src/KiloProvider.ts @@ -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 { diff --git a/packages/kilo-vscode/src/kilo-provider-utils.ts b/packages/kilo-vscode/src/kilo-provider-utils.ts index c63245f5d10..201cb433b76 100644 --- a/packages/kilo-vscode/src/kilo-provider-utils.ts +++ b/packages/kilo-vscode/src/kilo-provider-utils.ts @@ -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 } @@ -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", diff --git a/packages/kilo-vscode/src/kilo-provider/handlers/suggestion.ts b/packages/kilo-vscode/src/kilo-provider/handlers/suggestion.ts new file mode 100644 index 00000000000..3b2c0fa0195 --- /dev/null +++ b/packages/kilo-vscode/src/kilo-provider/handlers/suggestion.ts @@ -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 + readonly sessionDirectories: ReadonlyMap + postMessage(msg: unknown): void + getWorkspaceDirectory(sessionId?: string): string +} + +export function recoverableSuggestions(items: RecoverableSuggestion[], tracked: Set, seen: Set) { + 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 { + 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 { + 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 { + if (!ctx.client) return + try { + const dirs = recoveryDirs(ctx.getWorkspaceDirectory(), ctx.sessionDirectories) + + const seen = new Set() + 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) + } +} diff --git a/packages/kilo-vscode/src/services/cli-backend/connection-utils.ts b/packages/kilo-vscode/src/services/cli-backend/connection-utils.ts index a7f70859230..91e28a8523e 100644 --- a/packages/kilo-vscode/src/services/cli-backend/connection-utils.ts +++ b/packages/kilo-vscode/src/services/cli-backend/connection-utils.ts @@ -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 diff --git a/packages/kilo-vscode/tests/unit/connection-utils.test.ts b/packages/kilo-vscode/tests/unit/connection-utils.test.ts index 0715975f3d8..c916af225a9 100644 --- a/packages/kilo-vscode/tests/unit/connection-utils.test.ts +++ b/packages/kilo-vscode/tests/unit/connection-utils.test.ts @@ -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() diff --git a/packages/kilo-vscode/tests/unit/kilo-provider-utils.test.ts b/packages/kilo-vscode/tests/unit/kilo-provider-utils.test.ts index 4e80af488da..22e6411dc4f 100644 --- a/packages/kilo-vscode/tests/unit/kilo-provider-utils.test.ts +++ b/packages/kilo-vscode/tests/unit/kilo-provider-utils.test.ts @@ -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", diff --git a/packages/kilo-vscode/tests/unit/suggestion-recovery.test.ts b/packages/kilo-vscode/tests/unit/suggestion-recovery.test.ts new file mode 100644 index 00000000000..81f535afa1d --- /dev/null +++ b/packages/kilo-vscode/tests/unit/suggestion-recovery.test.ts @@ -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 + +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; 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() + 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) + }) +}) diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/ChatView.tsx b/packages/kilo-vscode/webview-ui/src/components/chat/ChatView.tsx index b056dc6e30d..c60a43a11a5 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/ChatView.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/chat/ChatView.tsx @@ -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 = (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 = (props) => { {(req) => } + + {(req) => } + {(perm) => ( = (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 ( +
+
+ + + + {props.request.text} +
+
+ + {(action, index) => ( + + )} + + +
+
+ ) +} diff --git a/packages/kilo-vscode/webview-ui/src/context/session.tsx b/packages/kilo-vscode/webview-ui/src/context/session.tsx index 6bd771ab7ec..12869731e9e 100644 --- a/packages/kilo-vscode/webview-ui/src/context/session.tsx +++ b/packages/kilo-vscode/webview-ui/src/context/session.tsx @@ -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 questionErrors: Accessor> + suggestions: Accessor + suggestionErrors: Accessor> + respondingSuggestions: Accessor> // 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 @@ -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>(new Set()) + const [suggestions, setSuggestions] = createSignal([]) + const [suggestionErrors, setSuggestionErrors] = createSignal>(new Set()) + const [respondingSuggestions, setRespondingSuggestions] = createSignal>(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, diff --git a/packages/kilo-vscode/webview-ui/src/stories/StoryProviders.tsx b/packages/kilo-vscode/webview-ui/src/stories/StoryProviders.tsx index 4c0f733837c..c6160c7956e 100644 --- a/packages/kilo-vscode/webview-ui/src/stories/StoryProviders.tsx +++ b/packages/kilo-vscode/webview-ui/src/stories/StoryProviders.tsx @@ -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 = { ...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(), questions: () => qs, questionErrors: () => new Set(), + suggestions: () => suggestions, + suggestionErrors: () => new Set(), + respondingSuggestions: () => new Set(), 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 = (props) => { id: props.sessionID, permissions: props.permissions, questions: props.questions, + suggestions: props.suggestions, status: props.status, }) const notifications = mockNotificationsValue(props.notifications) diff --git a/packages/kilo-vscode/webview-ui/src/stories/chat.stories.tsx b/packages/kilo-vscode/webview-ui/src/stories/chat.stories.tsx index 2372b719e4a..12ac866d2b7 100644 --- a/packages/kilo-vscode/webview-ui/src/stories/chat.stories.tsx +++ b/packages/kilo-vscode/webview-ui/src/stories/chat.stories.tsx @@ -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: () => ( + +
+ +
+
+ ), +} + // --------------------------------------------------------------------------- // TaskHeader with todos // --------------------------------------------------------------------------- diff --git a/packages/kilo-vscode/webview-ui/src/styles/chat.css b/packages/kilo-vscode/webview-ui/src/styles/chat.css index c27b21154f9..2c1aadc6dfe 100644 --- a/packages/kilo-vscode/webview-ui/src/styles/chat.css +++ b/packages/kilo-vscode/webview-ui/src/styles/chat.css @@ -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); diff --git a/packages/kilo-vscode/webview-ui/src/types/messages.ts b/packages/kilo-vscode/webview-ui/src/types/messages.ts index 7d24b879f5b..662b0ab0e6e 100644 --- a/packages/kilo-vscode/webview-ui/src/types/messages.ts +++ b/packages/kilo-vscode/webview-ui/src/types/messages.ts @@ -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 diff --git a/packages/opencode/src/agent/agent.ts b/packages/opencode/src/agent/agent.ts index 8bfbe786a21..e784c526250 100644 --- a/packages/opencode/src/agent/agent.ts +++ b/packages/opencode/src/agent/agent.ts @@ -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", diff --git a/packages/opencode/src/cli/cmd/tui/context/sync.tsx b/packages/opencode/src/cli/cmd/tui/context/sync.tsx index e7049fcbe4e..146427bbabe 100644 --- a/packages/opencode/src/cli/cmd/tui/context/sync.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/sync.tsx @@ -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 diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx index 6986e2d57f2..46ab4394252 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -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() { /> )}
+ + + {(request) => ( + prompt?.focused ?? false} + /> + )} + + { 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 + + + @@ -2270,6 +2309,40 @@ function Question(props: ToolProps) { ) } +function Suggest(props: ToolProps) { + const { theme } = useTheme() + const accepted = createMemo(() => props.metadata.accepted) + const dismissed = createMemo(() => props.metadata.dismissed === true) + + return ( + + + + + {props.input.suggest} + + Accepted: {accepted()?.label} + + + Dismissed + + + + + + + {props.input.suggest ?? "Suggested next step"} + + + + ) +} + function Skill(props: ToolProps) { return ( diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/suggest.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/suggest.tsx new file mode 100644 index 00000000000..9a5d7b43087 --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/routes/session/suggest.tsx @@ -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 ( + + + + {props.request.text} + + + + + {(opt, i) => { + const active = () => i() === selected() + const muted = () => i() === props.request.actions.length + return ( + setSelected(i())} + onMouseDown={() => setSelected(i())} + onMouseUp={() => choose(i())} + > + + + + {`${i() + 1}.`} + + + + {opt.label} + + + + + {opt.description} + + + ) + }} + + + + + + + {"↑↓"} select + + + enter choose + + + esc dismiss + + + {note()} + + + ) +} diff --git a/packages/opencode/src/id/id.ts b/packages/opencode/src/id/id.ts index 6673297cbfa..5a1252e3327 100644 --- a/packages/opencode/src/id/id.ts +++ b/packages/opencode/src/id/id.ts @@ -7,6 +7,7 @@ export namespace Identifier { message: "msg", permission: "per", question: "que", + suggestion: "sug", user: "usr", part: "prt", pty: "pty", diff --git a/packages/opencode/src/kilo-sessions/remote-sender.ts b/packages/opencode/src/kilo-sessions/remote-sender.ts index 02f74d064a0..53837294dfe 100644 --- a/packages/opencode/src/kilo-sessions/remote-sender.ts +++ b/packages/opencode/src/kilo-sessions/remote-sender.ts @@ -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) { +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) { } } -function normalizePrompt(input: z.infer): SessionPrompt.PromptInput { +function normalizePrompt(input: z.infer) { 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) { diff --git a/packages/opencode/src/kilocode/review-followup.ts b/packages/opencode/src/kilocode/review-followup.ts deleted file mode 100644 index cc3208e1476..00000000000 --- a/packages/opencode/src/kilocode/review-followup.ts +++ /dev/null @@ -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" - } -} diff --git a/packages/opencode/src/kilocode/soul.txt b/packages/opencode/src/kilocode/soul.txt index 70d52164ec1..3256446437a 100644 --- a/packages/opencode/src/kilocode/soul.txt +++ b/packages/opencode/src/kilocode/soul.txt @@ -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. diff --git a/packages/opencode/src/server/routes/suggestion.ts b/packages/opencode/src/server/routes/suggestion.ts new file mode 100644 index 00000000000..630d8714ac1 --- /dev/null +++ b/packages/opencode/src/server/routes/suggestion.ts @@ -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) + }, + ), +) diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts index c5cb6490d8f..e0812e6d715 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -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 diff --git a/packages/opencode/src/session/processor.ts b/packages/opencode/src/session/processor.ts index abf1707f4b6..db017613def 100644 --- a/packages/opencode/src/session/processor.ts +++ b/packages/opencode/src/session/processor.ts @@ -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 } diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 2d4107b25f4..fb229fbd1cb 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -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 diff --git a/packages/opencode/src/suggestion/index.ts b/packages/opencode/src/suggestion/index.ts new file mode 100644 index 00000000000..aa4c6b58cfb --- /dev/null +++ b/packages/opencode/src/suggestion/index.ts @@ -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 + + 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 + + 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 + + export const Accept = z.object({ + index: z.number().int().nonnegative().describe("Zero-based action index to accept"), + }) + export type Accept = z.infer + + 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 { + const s = await state() + const id = Identifier.ascending("suggestion") + + log.info("shown", { id, actions: input.actions.length }) + + return new Promise((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 { + 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 { + 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)) + } +} diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts index 3c05ef92a00..ce842048e22 100644 --- a/packages/opencode/src/tool/registry.ts +++ b/packages/opencode/src/tool/registry.ts @@ -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 { 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, diff --git a/packages/opencode/src/tool/suggest.ts b/packages/opencode/src/tool/suggest.ts new file mode 100644 index 00000000000..556cdf84146 --- /dev/null +++ b/packages/opencode/src/tool/suggest.ts @@ -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("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, + } + }, +}) diff --git a/packages/opencode/src/tool/suggest.txt b/packages/opencode/src/tool/suggest.txt new file mode 100644 index 00000000000..65079259685 --- /dev/null +++ b/packages/opencode/src/tool/suggest.txt @@ -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 diff --git a/packages/opencode/test/kilo-sessions/remote-sender.test.ts b/packages/opencode/test/kilo-sessions/remote-sender.test.ts index 987328860ae..195bc2efcd9 100644 --- a/packages/opencode/test/kilo-sessions/remote-sender.test.ts +++ b/packages/opencode/test/kilo-sessions/remote-sender.test.ts @@ -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 (input: { directory: string; init?: () => Promise; 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({ diff --git a/packages/opencode/test/kilocode/review-followup-detection.test.ts b/packages/opencode/test/kilocode/review-followup-detection.test.ts deleted file mode 100644 index 074aced14a4..00000000000 --- a/packages/opencode/test/kilocode/review-followup-detection.test.ts +++ /dev/null @@ -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) { - 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) - })) -}) diff --git a/packages/opencode/test/kilocode/review-followup.test.ts b/packages/opencode/test/kilocode/review-followup.test.ts deleted file mode 100644 index 8cf351192e3..00000000000 --- a/packages/opencode/test/kilocode/review-followup.test.ts +++ /dev/null @@ -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) { - 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) - })) -}) diff --git a/packages/opencode/test/suggestion/suggestion.test.ts b/packages/opencode/test/suggestion/suggestion.test.ts new file mode 100644 index 00000000000..cbef9b12e03 --- /dev/null +++ b/packages/opencode/test/suggestion/suggestion.test.ts @@ -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([]) + }, + }) + }) +}) diff --git a/packages/opencode/test/tool/registry.test.ts b/packages/opencode/test/tool/registry.test.ts index 7ba80294300..6baac8efdc7 100644 --- a/packages/opencode/test/tool/registry.test.ts +++ b/packages/opencode/test/tool/registry.test.ts @@ -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) => { diff --git a/packages/opencode/test/tool/suggest.test.ts b/packages/opencode/test/tool/suggest.test.ts new file mode 100644 index 00000000000..cdd574518e5 --- /dev/null +++ b/packages/opencode/test/tool/suggest.test.ts @@ -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 + let updateMessage: ReturnType + let updatePart: ReturnType + + 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", + }) + }) +}) diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index 640cc66fb0d..7a31e74c90d 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -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( + parameters?: { + directory?: string + workspace?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + ], + }, + ], + ) + return (options?.client ?? this.client).get({ + url: "/suggestion", + ...options, + ...params, + }) + } + + /** + * Accept suggestion request + * + * Accept a suggestion request from the AI assistant. + */ + public accept( + parameters: { + requestID: string + directory?: string + workspace?: string + index?: number + }, + options?: Options, + ) { + 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({ + 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( + parameters: { + requestID: string + directory?: string + workspace?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "requestID" }, + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post({ + 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 })) diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 798a200afe8..1852f6c0302 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -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 + /** + * 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 +} + +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 diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index 3f4f142364e..8776ae08c39 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -27,10 +27,7 @@ "type": "string" } }, - "required": [ - "healthy", - "version" - ] + "required": ["healthy", "version"] } } } @@ -745,10 +742,7 @@ "type": "number" } }, - "required": [ - "rows", - "cols" - ] + "required": ["rows", "cols"] } } } @@ -1023,10 +1017,7 @@ } } }, - "required": [ - "providers", - "default" - ] + "required": ["providers", "default"] } } } @@ -1233,11 +1224,7 @@ ] } }, - "required": [ - "type", - "branch", - "extra" - ] + "required": ["type", "branch", "extra"] } } } @@ -2148,9 +2135,7 @@ ], "summary": "Get session", "description": "Retrieve detailed information about a specific Kilo session.", - "tags": [ - "Session" - ], + "tags": ["Session"], "responses": { "200": { "description": "Get session", @@ -2377,9 +2362,7 @@ } ], "summary": "Get session children", - "tags": [ - "Session" - ], + "tags": ["Session"], "description": "Retrieve all child sessions that were forked from the specified parent session.", "responses": { "200": { @@ -2576,11 +2559,7 @@ "pattern": "^msg.*" } }, - "required": [ - "modelID", - "providerID", - "messageID" - ] + "required": ["modelID", "providerID", "messageID"] } } } @@ -3004,10 +2983,7 @@ "type": "boolean" } }, - "required": [ - "providerID", - "modelID" - ] + "required": ["providerID", "modelID"] } } } @@ -3077,10 +3053,7 @@ } } }, - "required": [ - "info", - "parts" - ] + "required": ["info", "parts"] } } } @@ -3161,10 +3134,7 @@ } } }, - "required": [ - "info", - "parts" - ] + "required": ["info", "parts"] } } } @@ -3210,10 +3180,7 @@ "type": "string" } }, - "required": [ - "providerID", - "modelID" - ] + "required": ["providerID", "modelID"] }, "agent": { "type": "string" @@ -3283,9 +3250,7 @@ } } }, - "required": [ - "parts" - ] + "required": ["parts"] } } } @@ -3355,10 +3320,7 @@ } } }, - "required": [ - "info", - "parts" - ] + "required": ["info", "parts"] } } } @@ -3725,10 +3687,7 @@ "type": "string" } }, - "required": [ - "providerID", - "modelID" - ] + "required": ["providerID", "modelID"] }, "agent": { "type": "string" @@ -3798,9 +3757,7 @@ } } }, - "required": [ - "parts" - ] + "required": ["parts"] } } } @@ -3861,10 +3818,7 @@ } } }, - "required": [ - "info", - "parts" - ] + "required": ["info", "parts"] } } } @@ -3942,20 +3896,13 @@ "$ref": "#/components/schemas/FilePartSource" } }, - "required": [ - "type", - "mime", - "url" - ] + "required": ["type", "mime", "url"] } ] } } }, - "required": [ - "arguments", - "command" - ] + "required": ["arguments", "command"] } } } @@ -4049,19 +3996,13 @@ "type": "string" } }, - "required": [ - "providerID", - "modelID" - ] + "required": ["providerID", "modelID"] }, "command": { "type": "string" } }, - "required": [ - "agent", - "command" - ] + "required": ["agent", "command"] } } } @@ -4150,9 +4091,7 @@ "pattern": "^prt.*" } }, - "required": [ - "messageID" - ] + "required": ["messageID"] } } } @@ -4312,16 +4251,10 @@ "properties": { "response": { "type": "string", - "enum": [ - "once", - "always", - "reject" - ] + "enum": ["once", "always", "reject"] } }, - "required": [ - "response" - ] + "required": ["response"] } } } @@ -4458,19 +4391,13 @@ "properties": { "reply": { "type": "string", - "enum": [ - "once", - "always", - "reject" - ] + "enum": ["once", "always", "reject"] }, "message": { "type": "string" } }, - "required": [ - "reply" - ] + "required": ["reply"] } } } @@ -4738,9 +4665,7 @@ } } }, - "required": [ - "answers" - ] + "required": ["answers"] } } } @@ -4822,6 +4747,206 @@ ] } }, + "/suggestion": { + "get": { + "operationId": "suggestion.list", + "parameters": [ + { + "in": "query", + "name": "directory", + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "workspace", + "schema": { + "type": "string" + } + } + ], + "summary": "List pending suggestions", + "description": "Get all pending suggestion requests across all sessions.", + "responses": { + "200": { + "description": "List of pending suggestions", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SuggestionRequest" + } + } + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createKiloClient } from \"@kilocode/sdk\n\nconst client = createKiloClient()\nawait client.suggestion.list({\n ...\n})" + } + ] + } + }, + "/suggestion/{requestID}/accept": { + "post": { + "operationId": "suggestion.accept", + "parameters": [ + { + "in": "query", + "name": "directory", + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "workspace", + "schema": { + "type": "string" + } + }, + { + "in": "path", + "name": "requestID", + "schema": { + "type": "string" + }, + "required": true + } + ], + "summary": "Accept suggestion request", + "description": "Accept a suggestion request from the AI assistant.", + "responses": { + "200": { + "description": "Suggestion accepted successfully", + "content": { + "application/json": { + "schema": { + "type": "boolean" + } + } + } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotFoundError" + } + } + } + } + }, + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "index": { + "description": "Zero-based action index to accept", + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + } + }, + "required": ["index"] + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createKiloClient } from \"@kilocode/sdk\n\nconst client = createKiloClient()\nawait client.suggestion.accept({\n ...\n})" + } + ] + } + }, + "/suggestion/{requestID}/dismiss": { + "post": { + "operationId": "suggestion.dismiss", + "parameters": [ + { + "in": "query", + "name": "directory", + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "workspace", + "schema": { + "type": "string" + } + }, + { + "in": "path", + "name": "requestID", + "schema": { + "type": "string" + }, + "required": true + } + ], + "summary": "Dismiss suggestion request", + "description": "Dismiss a suggestion request from the AI assistant.", + "responses": { + "200": { + "description": "Suggestion dismissed successfully", + "content": { + "application/json": { + "schema": { + "type": "boolean" + } + } + } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotFoundError" + } + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createKiloClient } from \"@kilocode/sdk\n\nconst client = createKiloClient()\nawait client.suggestion.dismiss({\n ...\n})" + } + ] + } + }, "/provider": { "get": { "operationId": "provider.list", @@ -4917,15 +5042,10 @@ "properties": { "field": { "type": "string", - "enum": [ - "reasoning_content", - "reasoning_details" - ] + "enum": ["reasoning_content", "reasoning_details"] } }, - "required": [ - "field" - ], + "required": ["field"], "additionalProperties": false } ] @@ -4961,16 +5081,10 @@ "type": "number" } }, - "required": [ - "input", - "output" - ] + "required": ["input", "output"] } }, - "required": [ - "input", - "output" - ] + "required": ["input", "output"] }, "limit": { "type": "object", @@ -4985,10 +5099,7 @@ "type": "number" } }, - "required": [ - "context", - "output" - ] + "required": ["context", "output"] }, "modalities": { "type": "object", @@ -4997,61 +5108,39 @@ "type": "array", "items": { "type": "string", - "enum": [ - "text", - "audio", - "image", - "video", - "pdf" - ] + "enum": ["text", "audio", "image", "video", "pdf"] } }, "output": { "type": "array", "items": { "type": "string", - "enum": [ - "text", - "audio", - "image", - "video", - "pdf" - ] + "enum": ["text", "audio", "image", "video", "pdf"] } } }, - "required": [ - "input", - "output" - ] + "required": ["input", "output"] }, "recommendedIndex": { "type": "number" }, "prompt": { "type": "string", - "enum": [ - "codex", - "gemini", - "beast", - "anthropic", - "trinity", - "anthropic_without_todo" - ] + "enum": ["codex", "gemini", "beast", "anthropic", "trinity", "anthropic_without_todo"] }, "isFree": { "type": "boolean" }, + "ai_sdk_provider": { + "type": "string", + "enum": ["anthropic", "openai", "openai-compatible", "openrouter"] + }, "experimental": { "type": "boolean" }, "status": { "type": "string", - "enum": [ - "alpha", - "beta", - "deprecated" - ] + "enum": ["alpha", "beta", "deprecated"] }, "options": { "type": "object", @@ -5108,12 +5197,7 @@ } } }, - "required": [ - "name", - "env", - "id", - "models" - ] + "required": ["name", "env", "id", "models"] } }, "default": { @@ -5132,11 +5216,7 @@ } } }, - "required": [ - "all", - "default", - "connected" - ] + "required": ["all", "default", "connected"] } } } @@ -5263,9 +5343,7 @@ "type": "number" } }, - "required": [ - "method" - ] + "required": ["method"] } } } @@ -5345,9 +5423,7 @@ "type": "string" } }, - "required": [ - "method" - ] + "required": ["method"] } } } @@ -5422,9 +5498,7 @@ "additionalProperties": {} } }, - "required": [ - "event" - ] + "required": ["event"] } } } @@ -5620,9 +5694,7 @@ "type": "string" } }, - "required": [ - "message" - ] + "required": ["message"] } } } @@ -5660,9 +5732,7 @@ "type": "string" } }, - "required": [ - "path" - ] + "required": ["path"] } } } @@ -5708,9 +5778,7 @@ "type": "string" } }, - "required": [ - "text" - ] + "required": ["text"] } } } @@ -5738,9 +5806,7 @@ "minLength": 1 } }, - "required": [ - "text" - ] + "required": ["text"] } } } @@ -5806,9 +5872,7 @@ "type": "string" } }, - "required": [ - "location" - ] + "required": ["location"] } } } @@ -5874,9 +5938,7 @@ "type": "string" } }, - "required": [ - "name" - ] + "required": ["name"] } } } @@ -5942,17 +6004,11 @@ "type": "string" } }, - "required": [ - "id", - "name", - "role" - ] + "required": ["id", "name", "role"] } } }, - "required": [ - "email" - ] + "required": ["email"] }, "balance": { "anyOf": [ @@ -5963,9 +6019,7 @@ "type": "number" } }, - "required": [ - "balance" - ] + "required": ["balance"] }, { "type": "null" @@ -5983,11 +6037,7 @@ ] } }, - "required": [ - "profile", - "balance", - "currentOrgId" - ] + "required": ["profile", "balance", "currentOrgId"] } } } @@ -6071,9 +6121,7 @@ ] } }, - "required": [ - "organizationId" - ] + "required": ["organizationId"] } } } @@ -6158,7 +6206,32 @@ }, "groups": { "type": "array", - "items": {} + "items": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "prefixItems": [ + { + "type": "string" + }, + { + "type": "object", + "properties": { + "fileRegex": { + "type": "string" + }, + "description": { + "type": "string" + } + } + } + ] + } + ] + } } } } @@ -6176,9 +6249,7 @@ } } }, - "required": [ - "modes" - ] + "required": ["modes"] } } } @@ -6289,10 +6360,7 @@ "type": "number" } }, - "required": [ - "prefix", - "suffix" - ] + "required": ["prefix", "suffix"] } } } @@ -6355,10 +6423,7 @@ "type": "string" } }, - "required": [ - "actionText", - "actionURL" - ] + "required": ["actionText", "actionURL"] }, "showIn": { "type": "array", @@ -6370,11 +6435,7 @@ "type": "string" } }, - "required": [ - "id", - "title", - "message" - ] + "required": ["id", "title", "message"] } } } @@ -6517,9 +6578,7 @@ "type": "string" } }, - "required": [ - "sessionId" - ] + "required": ["sessionId"] } } } @@ -6610,13 +6669,7 @@ "type": "number" } }, - "required": [ - "session_id", - "title", - "created_at", - "updated_at", - "version" - ] + "required": ["session_id", "title", "created_at", "updated_at", "version"] } }, "nextCursor": { @@ -6630,10 +6683,7 @@ ] } }, - "required": [ - "cliSessions", - "nextCursor" - ] + "required": ["cliSessions", "nextCursor"] } } } @@ -6703,9 +6753,7 @@ "type": "string" } }, - "required": [ - "text" - ] + "required": ["text"] }, "lines": { "type": "object", @@ -6714,9 +6762,7 @@ "type": "string" } }, - "required": [ - "text" - ] + "required": ["text"] }, "line_number": { "type": "number" @@ -6736,9 +6782,7 @@ "type": "string" } }, - "required": [ - "text" - ] + "required": ["text"] }, "start": { "type": "number" @@ -6747,21 +6791,11 @@ "type": "number" } }, - "required": [ - "match", - "start", - "end" - ] + "required": ["match", "start", "end"] } } }, - "required": [ - "path", - "lines", - "line_number", - "absolute_offset", - "submatches" - ] + "required": ["path", "lines", "line_number", "absolute_offset", "submatches"] } } } @@ -6807,10 +6841,7 @@ "name": "dirs", "schema": { "type": "string", - "enum": [ - "true", - "false" - ] + "enum": ["true", "false"] } }, { @@ -6818,10 +6849,7 @@ "name": "type", "schema": { "type": "string", - "enum": [ - "file", - "directory" - ] + "enum": ["file", "directory"] } }, { @@ -7170,10 +7198,7 @@ ] } }, - "required": [ - "name", - "config" - ] + "required": ["name", "config"] } } } @@ -7228,9 +7253,7 @@ "type": "string" } }, - "required": [ - "authorizationUrl" - ] + "required": ["authorizationUrl"] } } } @@ -7304,9 +7327,7 @@ "const": true } }, - "required": [ - "success" - ] + "required": ["success"] } } } @@ -7402,9 +7423,7 @@ "type": "string" } }, - "required": [ - "code" - ] + "required": ["code"] } } } @@ -7635,9 +7654,7 @@ "type": "string" } }, - "required": [ - "text" - ] + "required": ["text"] } } } @@ -7949,9 +7966,7 @@ "type": "string" } }, - "required": [ - "command" - ] + "required": ["command"] } } } @@ -8011,12 +8026,7 @@ }, "variant": { "type": "string", - "enum": [ - "info", - "success", - "warning", - "error" - ] + "enum": ["info", "success", "warning", "error"] }, "duration": { "description": "Duration in milliseconds", @@ -8024,10 +8034,7 @@ "type": "number" } }, - "required": [ - "message", - "variant" - ] + "required": ["message", "variant"] } } } @@ -8178,9 +8185,7 @@ "pattern": "^ses" } }, - "required": [ - "sessionID" - ] + "required": ["sessionID"] } } } @@ -8227,10 +8232,7 @@ }, "body": {} }, - "required": [ - "path", - "body" - ] + "required": ["path", "body"] } } } @@ -8515,12 +8517,7 @@ "level": { "description": "Log level", "type": "string", - "enum": [ - "debug", - "info", - "error", - "warn" - ] + "enum": ["debug", "info", "error", "warn"] }, "message": { "description": "Log message", @@ -8535,11 +8532,7 @@ "additionalProperties": {} } }, - "required": [ - "service", - "level", - "message" - ] + "required": ["service", "level", "message"] } } } @@ -8640,12 +8633,7 @@ "type": "string" } }, - "required": [ - "name", - "description", - "location", - "content" - ] + "required": ["name", "description", "location", "content"] } } } @@ -8806,15 +8794,10 @@ "type": "string" } }, - "required": [ - "version" - ] + "required": ["version"] } }, - "required": [ - "type", - "properties" - ] + "required": ["type", "properties"] }, "Event.installation.update-available": { "type": "object", @@ -8830,15 +8813,10 @@ "type": "string" } }, - "required": [ - "version" - ] + "required": ["version"] } }, - "required": [ - "type", - "properties" - ] + "required": ["type", "properties"] }, "Project": { "type": "object", @@ -8892,10 +8870,7 @@ "type": "number" } }, - "required": [ - "created", - "updated" - ] + "required": ["created", "updated"] }, "sandboxes": { "type": "array", @@ -8904,12 +8879,7 @@ } } }, - "required": [ - "id", - "worktree", - "time", - "sandboxes" - ] + "required": ["id", "worktree", "time", "sandboxes"] }, "Event.project.updated": { "type": "object", @@ -8922,10 +8892,7 @@ "$ref": "#/components/schemas/Project" } }, - "required": [ - "type", - "properties" - ] + "required": ["type", "properties"] }, "Event.server.instance.disposed": { "type": "object", @@ -8941,15 +8908,10 @@ "type": "string" } }, - "required": [ - "directory" - ] + "required": ["directory"] } }, - "required": [ - "type", - "properties" - ] + "required": ["type", "properties"] }, "Event.server.connected": { "type": "object", @@ -8963,10 +8925,7 @@ "properties": {} } }, - "required": [ - "type", - "properties" - ] + "required": ["type", "properties"] }, "Event.global.disposed": { "type": "object", @@ -8980,10 +8939,7 @@ "properties": {} } }, - "required": [ - "type", - "properties" - ] + "required": ["type", "properties"] }, "Event.global.config.updated": { "type": "object", @@ -8997,10 +8953,7 @@ "properties": {} } }, - "required": [ - "type", - "properties" - ] + "required": ["type", "properties"] }, "Event.lsp.client.diagnostics": { "type": "object", @@ -9019,16 +8972,10 @@ "type": "string" } }, - "required": [ - "serverID", - "path" - ] + "required": ["serverID", "path"] } }, - "required": [ - "type", - "properties" - ] + "required": ["type", "properties"] }, "Event.lsp.updated": { "type": "object", @@ -9042,10 +8989,7 @@ "properties": {} } }, - "required": [ - "type", - "properties" - ] + "required": ["type", "properties"] }, "Event.file.edited": { "type": "object", @@ -9061,15 +9005,10 @@ "type": "string" } }, - "required": [ - "file" - ] + "required": ["file"] } }, - "required": [ - "type", - "properties" - ] + "required": ["type", "properties"] }, "OutputFormatText": { "type": "object", @@ -9079,9 +9018,7 @@ "const": "text" } }, - "required": [ - "type" - ] + "required": ["type"] }, "JSONSchema": { "type": "object", @@ -9107,10 +9044,7 @@ "maximum": 9007199254740991 } }, - "required": [ - "type", - "schema" - ] + "required": ["type", "schema"] }, "OutputFormat": { "anyOf": [ @@ -9142,20 +9076,10 @@ }, "status": { "type": "string", - "enum": [ - "added", - "deleted", - "modified" - ] + "enum": ["added", "deleted", "modified"] } }, - "required": [ - "file", - "before", - "after", - "additions", - "deletions" - ] + "required": ["file", "before", "after", "additions", "deletions"] }, "UserMessage": { "type": "object", @@ -9177,9 +9101,7 @@ "type": "number" } }, - "required": [ - "created" - ] + "required": ["created"] }, "format": { "$ref": "#/components/schemas/OutputFormat" @@ -9200,9 +9122,7 @@ } } }, - "required": [ - "diffs" - ] + "required": ["diffs"] }, "agent": { "type": "string" @@ -9217,10 +9137,7 @@ "type": "string" } }, - "required": [ - "providerID", - "modelID" - ] + "required": ["providerID", "modelID"] }, "system": { "type": "string" @@ -9261,14 +9178,7 @@ } } }, - "required": [ - "id", - "sessionID", - "role", - "time", - "agent", - "model" - ] + "required": ["id", "sessionID", "role", "time", "agent", "model"] }, "ProviderAuthError": { "type": "object", @@ -9287,16 +9197,10 @@ "type": "string" } }, - "required": [ - "providerID", - "message" - ] + "required": ["providerID", "message"] } }, - "required": [ - "name", - "data" - ] + "required": ["name", "data"] }, "UnknownError": { "type": "object", @@ -9312,15 +9216,10 @@ "type": "string" } }, - "required": [ - "message" - ] + "required": ["message"] } }, - "required": [ - "name", - "data" - ] + "required": ["name", "data"] }, "MessageOutputLengthError": { "type": "object", @@ -9334,10 +9233,7 @@ "properties": {} } }, - "required": [ - "name", - "data" - ] + "required": ["name", "data"] }, "MessageAbortedError": { "type": "object", @@ -9353,15 +9249,10 @@ "type": "string" } }, - "required": [ - "message" - ] + "required": ["message"] } }, - "required": [ - "name", - "data" - ] + "required": ["name", "data"] }, "StructuredOutputError": { "type": "object", @@ -9380,16 +9271,10 @@ "type": "number" } }, - "required": [ - "message", - "retries" - ] + "required": ["message", "retries"] } }, - "required": [ - "name", - "data" - ] + "required": ["name", "data"] }, "ContextOverflowError": { "type": "object", @@ -9408,15 +9293,10 @@ "type": "string" } }, - "required": [ - "message" - ] + "required": ["message"] } }, - "required": [ - "name", - "data" - ] + "required": ["name", "data"] }, "APIError": { "type": "object", @@ -9459,16 +9339,10 @@ } } }, - "required": [ - "message", - "isRetryable" - ] + "required": ["message", "isRetryable"] } }, - "required": [ - "name", - "data" - ] + "required": ["name", "data"] }, "AssistantMessage": { "type": "object", @@ -9493,9 +9367,7 @@ "type": "number" } }, - "required": [ - "created" - ] + "required": ["created"] }, "error": { "anyOf": [ @@ -9547,10 +9419,7 @@ "type": "string" } }, - "required": [ - "cwd", - "root" - ] + "required": ["cwd", "root"] }, "summary": { "type": "boolean" @@ -9583,18 +9452,10 @@ "type": "number" } }, - "required": [ - "read", - "write" - ] + "required": ["read", "write"] } }, - "required": [ - "input", - "output", - "reasoning", - "cache" - ] + "required": ["input", "output", "reasoning", "cache"] }, "structured": {}, "variant": { @@ -9643,15 +9504,10 @@ "$ref": "#/components/schemas/Message" } }, - "required": [ - "info" - ] + "required": ["info"] } }, - "required": [ - "type", - "properties" - ] + "required": ["type", "properties"] }, "Event.message.removed": { "type": "object", @@ -9670,16 +9526,10 @@ "type": "string" } }, - "required": [ - "sessionID", - "messageID" - ] + "required": ["sessionID", "messageID"] } }, - "required": [ - "type", - "properties" - ] + "required": ["type", "properties"] }, "TextPart": { "type": "object", @@ -9716,9 +9566,7 @@ "type": "number" } }, - "required": [ - "start" - ] + "required": ["start"] }, "metadata": { "type": "object", @@ -9728,13 +9576,7 @@ "additionalProperties": {} } }, - "required": [ - "id", - "sessionID", - "messageID", - "type", - "text" - ] + "required": ["id", "sessionID", "messageID", "type", "text"] }, "SubtaskPart": { "type": "object", @@ -9771,24 +9613,13 @@ "type": "string" } }, - "required": [ - "providerID", - "modelID" - ] + "required": ["providerID", "modelID"] }, "command": { "type": "string" } }, - "required": [ - "id", - "sessionID", - "messageID", - "type", - "prompt", - "description", - "agent" - ] + "required": ["id", "sessionID", "messageID", "type", "prompt", "description", "agent"] }, "ReasoningPart": { "type": "object", @@ -9826,19 +9657,10 @@ "type": "number" } }, - "required": [ - "start" - ] + "required": ["start"] } }, - "required": [ - "id", - "sessionID", - "messageID", - "type", - "text", - "time" - ] + "required": ["id", "sessionID", "messageID", "type", "text", "time"] }, "FilePartSourceText": { "type": "object", @@ -9857,11 +9679,7 @@ "maximum": 9007199254740991 } }, - "required": [ - "value", - "start", - "end" - ] + "required": ["value", "start", "end"] }, "FileSource": { "type": "object", @@ -9877,11 +9695,7 @@ "type": "string" } }, - "required": [ - "text", - "type", - "path" - ] + "required": ["text", "type", "path"] }, "Range": { "type": "object", @@ -9896,10 +9710,7 @@ "type": "number" } }, - "required": [ - "line", - "character" - ] + "required": ["line", "character"] }, "end": { "type": "object", @@ -9911,16 +9722,10 @@ "type": "number" } }, - "required": [ - "line", - "character" - ] + "required": ["line", "character"] } }, - "required": [ - "start", - "end" - ] + "required": ["start", "end"] }, "SymbolSource": { "type": "object", @@ -9947,14 +9752,7 @@ "maximum": 9007199254740991 } }, - "required": [ - "text", - "type", - "path", - "range", - "name", - "kind" - ] + "required": ["text", "type", "path", "range", "name", "kind"] }, "ResourceSource": { "type": "object", @@ -9973,12 +9771,7 @@ "type": "string" } }, - "required": [ - "text", - "type", - "clientName", - "uri" - ] + "required": ["text", "type", "clientName", "uri"] }, "FilePartSource": { "anyOf": [ @@ -10022,14 +9815,7 @@ "$ref": "#/components/schemas/FilePartSource" } }, - "required": [ - "id", - "sessionID", - "messageID", - "type", - "mime", - "url" - ] + "required": ["id", "sessionID", "messageID", "type", "mime", "url"] }, "ToolStatePending": { "type": "object", @@ -10049,11 +9835,7 @@ "type": "string" } }, - "required": [ - "status", - "input", - "raw" - ] + "required": ["status", "input", "raw"] }, "ToolStateRunning": { "type": "object", @@ -10086,16 +9868,10 @@ "type": "number" } }, - "required": [ - "start" - ] + "required": ["start"] } }, - "required": [ - "status", - "input", - "time" - ] + "required": ["status", "input", "time"] }, "ToolStateCompleted": { "type": "object", @@ -10137,10 +9913,7 @@ "type": "number" } }, - "required": [ - "start", - "end" - ] + "required": ["start", "end"] }, "attachments": { "type": "array", @@ -10149,14 +9922,7 @@ } } }, - "required": [ - "status", - "input", - "output", - "title", - "metadata", - "time" - ] + "required": ["status", "input", "output", "title", "metadata", "time"] }, "ToolStateError": { "type": "object", @@ -10192,18 +9958,10 @@ "type": "number" } }, - "required": [ - "start", - "end" - ] + "required": ["start", "end"] } }, - "required": [ - "status", - "input", - "error", - "time" - ] + "required": ["status", "input", "error", "time"] }, "ToolState": { "anyOf": [ @@ -10254,15 +10012,7 @@ "additionalProperties": {} } }, - "required": [ - "id", - "sessionID", - "messageID", - "type", - "callID", - "tool", - "state" - ] + "required": ["id", "sessionID", "messageID", "type", "callID", "tool", "state"] }, "StepStartPart": { "type": "object", @@ -10284,12 +10034,7 @@ "type": "string" } }, - "required": [ - "id", - "sessionID", - "messageID", - "type" - ] + "required": ["id", "sessionID", "messageID", "type"] }, "StepFinishPart": { "type": "object", @@ -10341,29 +10086,13 @@ "type": "number" } }, - "required": [ - "read", - "write" - ] + "required": ["read", "write"] } }, - "required": [ - "input", - "output", - "reasoning", - "cache" - ] + "required": ["input", "output", "reasoning", "cache"] } }, - "required": [ - "id", - "sessionID", - "messageID", - "type", - "reason", - "cost", - "tokens" - ] + "required": ["id", "sessionID", "messageID", "type", "reason", "cost", "tokens"] }, "SnapshotPart": { "type": "object", @@ -10385,13 +10114,7 @@ "type": "string" } }, - "required": [ - "id", - "sessionID", - "messageID", - "type", - "snapshot" - ] + "required": ["id", "sessionID", "messageID", "type", "snapshot"] }, "PatchPart": { "type": "object", @@ -10419,14 +10142,7 @@ } } }, - "required": [ - "id", - "sessionID", - "messageID", - "type", - "hash", - "files" - ] + "required": ["id", "sessionID", "messageID", "type", "hash", "files"] }, "AgentPart": { "type": "object", @@ -10464,20 +10180,10 @@ "maximum": 9007199254740991 } }, - "required": [ - "value", - "start", - "end" - ] + "required": ["value", "start", "end"] } }, - "required": [ - "id", - "sessionID", - "messageID", - "type", - "name" - ] + "required": ["id", "sessionID", "messageID", "type", "name"] }, "RetryPart": { "type": "object", @@ -10508,20 +10214,10 @@ "type": "number" } }, - "required": [ - "created" - ] + "required": ["created"] } }, - "required": [ - "id", - "sessionID", - "messageID", - "type", - "attempt", - "error", - "time" - ] + "required": ["id", "sessionID", "messageID", "type", "attempt", "error", "time"] }, "CompactionPart": { "type": "object", @@ -10546,13 +10242,7 @@ "type": "boolean" } }, - "required": [ - "id", - "sessionID", - "messageID", - "type", - "auto" - ] + "required": ["id", "sessionID", "messageID", "type", "auto"] }, "Part": { "anyOf": [ @@ -10608,15 +10298,10 @@ "$ref": "#/components/schemas/Part" } }, - "required": [ - "part" - ] + "required": ["part"] } }, - "required": [ - "type", - "properties" - ] + "required": ["type", "properties"] }, "Event.message.part.delta": { "type": "object", @@ -10644,19 +10329,10 @@ "type": "string" } }, - "required": [ - "sessionID", - "messageID", - "partID", - "field", - "delta" - ] + "required": ["sessionID", "messageID", "partID", "field", "delta"] } }, - "required": [ - "type", - "properties" - ] + "required": ["type", "properties"] }, "Event.message.part.removed": { "type": "object", @@ -10678,17 +10354,10 @@ "type": "string" } }, - "required": [ - "sessionID", - "messageID", - "partID" - ] + "required": ["sessionID", "messageID", "partID"] } }, - "required": [ - "type", - "properties" - ] + "required": ["type", "properties"] }, "PermissionRequest": { "type": "object", @@ -10733,20 +10402,10 @@ "type": "string" } }, - "required": [ - "messageID", - "callID" - ] + "required": ["messageID", "callID"] } }, - "required": [ - "id", - "sessionID", - "permission", - "patterns", - "metadata", - "always" - ] + "required": ["id", "sessionID", "permission", "patterns", "metadata", "always"] }, "Event.permission.asked": { "type": "object", @@ -10759,10 +10418,7 @@ "$ref": "#/components/schemas/PermissionRequest" } }, - "required": [ - "type", - "properties" - ] + "required": ["type", "properties"] }, "Event.permission.replied": { "type": "object", @@ -10782,24 +10438,13 @@ }, "reply": { "type": "string", - "enum": [ - "once", - "always", - "reject" - ] + "enum": ["once", "always", "reject"] } }, - "required": [ - "sessionID", - "requestID", - "reply" - ] + "required": ["sessionID", "requestID", "reply"] } }, - "required": [ - "type", - "properties" - ] + "required": ["type", "properties"] }, "SessionStatus": { "anyOf": [ @@ -10811,9 +10456,7 @@ "const": "idle" } }, - "required": [ - "type" - ] + "required": ["type"] }, { "type": "object", @@ -10832,12 +10475,7 @@ "type": "number" } }, - "required": [ - "type", - "attempt", - "message", - "next" - ] + "required": ["type", "attempt", "message", "next"] }, { "type": "object", @@ -10847,9 +10485,7 @@ "const": "busy" } }, - "required": [ - "type" - ] + "required": ["type"] } ] }, @@ -10870,16 +10506,10 @@ "$ref": "#/components/schemas/SessionStatus" } }, - "required": [ - "sessionID", - "status" - ] + "required": ["sessionID", "status"] } }, - "required": [ - "type", - "properties" - ] + "required": ["type", "properties"] }, "Event.session.idle": { "type": "object", @@ -10895,15 +10525,10 @@ "type": "string" } }, - "required": [ - "sessionID" - ] + "required": ["sessionID"] } }, - "required": [ - "type", - "properties" - ] + "required": ["type", "properties"] }, "QuestionOption": { "type": "object", @@ -10921,10 +10546,7 @@ "type": "string" } }, - "required": [ - "label", - "description" - ] + "required": ["label", "description"] }, "QuestionInfo": { "type": "object", @@ -10953,11 +10575,7 @@ "type": "boolean" } }, - "required": [ - "question", - "header", - "options" - ] + "required": ["question", "header", "options"] }, "QuestionRequest": { "type": "object", @@ -10977,6 +10595,10 @@ "$ref": "#/components/schemas/QuestionInfo" } }, + "blocking": { + "description": "Whether this question blocks prompt input (default: true)", + "type": "boolean" + }, "tool": { "type": "object", "properties": { @@ -10987,17 +10609,10 @@ "type": "string" } }, - "required": [ - "messageID", - "callID" - ] + "required": ["messageID", "callID"] } }, - "required": [ - "id", - "sessionID", - "questions" - ] + "required": ["id", "sessionID", "questions"] }, "Event.question.asked": { "type": "object", @@ -11010,10 +10625,7 @@ "$ref": "#/components/schemas/QuestionRequest" } }, - "required": [ - "type", - "properties" - ] + "required": ["type", "properties"] }, "QuestionAnswer": { "type": "array", @@ -11044,17 +10656,10 @@ } } }, - "required": [ - "sessionID", - "requestID", - "answers" - ] + "required": ["sessionID", "requestID", "answers"] } }, - "required": [ - "type", - "properties" - ] + "required": ["type", "properties"] }, "Event.question.rejected": { "type": "object", @@ -11073,16 +10678,136 @@ "type": "string" } }, - "required": [ - "sessionID", - "requestID" - ] + "required": ["sessionID", "requestID"] } }, - "required": [ - "type", - "properties" - ] + "required": ["type", "properties"] + }, + "SuggestionAction": { + "type": "object", + "properties": { + "label": { + "description": "Button or option label (1-5 words)", + "type": "string" + }, + "description": { + "description": "Brief explanation of what this action does", + "type": "string" + }, + "prompt": { + "description": "Synthetic user prompt to inject when this action is accepted", + "type": "string" + } + }, + "required": ["label", "prompt"] + }, + "SuggestionRequest": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^sug.*" + }, + "sessionID": { + "type": "string", + "pattern": "^ses.*" + }, + "text": { + "description": "Suggestion text shown to the user", + "type": "string" + }, + "actions": { + "description": "Available actions the user can take", + "minItems": 1, + "maxItems": 2, + "type": "array", + "items": { + "$ref": "#/components/schemas/SuggestionAction" + } + }, + "blocking": { + "description": "Whether this suggestion blocks prompt input (default: true)", + "type": "boolean" + }, + "tool": { + "type": "object", + "properties": { + "messageID": { + "type": "string" + }, + "callID": { + "type": "string" + } + }, + "required": ["messageID", "callID"] + } + }, + "required": ["id", "sessionID", "text", "actions"] + }, + "Event.suggestion.shown": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "suggestion.shown" + }, + "properties": { + "$ref": "#/components/schemas/SuggestionRequest" + } + }, + "required": ["type", "properties"] + }, + "Event.suggestion.accepted": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "suggestion.accepted" + }, + "properties": { + "type": "object", + "properties": { + "sessionID": { + "type": "string" + }, + "requestID": { + "type": "string" + }, + "index": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "action": { + "$ref": "#/components/schemas/SuggestionAction" + } + }, + "required": ["sessionID", "requestID", "index", "action"] + } + }, + "required": ["type", "properties"] + }, + "Event.suggestion.dismissed": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "suggestion.dismissed" + }, + "properties": { + "type": "object", + "properties": { + "sessionID": { + "type": "string" + }, + "requestID": { + "type": "string" + } + }, + "required": ["sessionID", "requestID"] + } + }, + "required": ["type", "properties"] }, "Event.session.compacted": { "type": "object", @@ -11098,15 +10823,10 @@ "type": "string" } }, - "required": [ - "sessionID" - ] + "required": ["sessionID"] } }, - "required": [ - "type", - "properties" - ] + "required": ["type", "properties"] }, "Event.file.watcher.updated": { "type": "object", @@ -11138,16 +10858,10 @@ ] } }, - "required": [ - "file", - "event" - ] + "required": ["file", "event"] } }, - "required": [ - "type", - "properties" - ] + "required": ["type", "properties"] }, "Todo": { "type": "object", @@ -11165,11 +10879,7 @@ "type": "string" } }, - "required": [ - "content", - "status", - "priority" - ] + "required": ["content", "status", "priority"] }, "Event.todo.updated": { "type": "object", @@ -11191,16 +10901,10 @@ } } }, - "required": [ - "sessionID", - "todos" - ] + "required": ["sessionID", "todos"] } }, - "required": [ - "type", - "properties" - ] + "required": ["type", "properties"] }, "Event.tui.prompt.append": { "type": "object", @@ -11216,15 +10920,10 @@ "type": "string" } }, - "required": [ - "text" - ] + "required": ["text"] } }, - "required": [ - "type", - "properties" - ] + "required": ["type", "properties"] }, "Event.tui.command.execute": { "type": "object", @@ -11265,15 +10964,10 @@ ] } }, - "required": [ - "command" - ] + "required": ["command"] } }, - "required": [ - "type", - "properties" - ] + "required": ["type", "properties"] }, "Event.tui.toast.show": { "type": "object", @@ -11293,12 +10987,7 @@ }, "variant": { "type": "string", - "enum": [ - "info", - "success", - "warning", - "error" - ] + "enum": ["info", "success", "warning", "error"] }, "duration": { "description": "Duration in milliseconds", @@ -11306,16 +10995,10 @@ "type": "number" } }, - "required": [ - "message", - "variant" - ] + "required": ["message", "variant"] } }, - "required": [ - "type", - "properties" - ] + "required": ["type", "properties"] }, "Event.tui.session.select": { "type": "object", @@ -11333,15 +11016,10 @@ "pattern": "^ses" } }, - "required": [ - "sessionID" - ] + "required": ["sessionID"] } }, - "required": [ - "type", - "properties" - ] + "required": ["type", "properties"] }, "Event.mcp.tools.changed": { "type": "object", @@ -11357,15 +11035,10 @@ "type": "string" } }, - "required": [ - "server" - ] + "required": ["server"] } }, - "required": [ - "type", - "properties" - ] + "required": ["type", "properties"] }, "Event.mcp.browser.open.failed": { "type": "object", @@ -11384,16 +11057,10 @@ "type": "string" } }, - "required": [ - "mcpName", - "url" - ] + "required": ["mcpName", "url"] } }, - "required": [ - "type", - "properties" - ] + "required": ["type", "properties"] }, "Event.command.executed": { "type": "object", @@ -11420,26 +11087,14 @@ "pattern": "^msg.*" } }, - "required": [ - "name", - "sessionID", - "arguments", - "messageID" - ] + "required": ["name", "sessionID", "arguments", "messageID"] } }, - "required": [ - "type", - "properties" - ] + "required": ["type", "properties"] }, "PermissionAction": { "type": "string", - "enum": [ - "allow", - "deny", - "ask" - ] + "enum": ["allow", "deny", "ask"] }, "PermissionRule": { "type": "object", @@ -11454,11 +11109,7 @@ "$ref": "#/components/schemas/PermissionAction" } }, - "required": [ - "permission", - "pattern", - "action" - ] + "required": ["permission", "pattern", "action"] }, "PermissionRuleset": { "type": "array", @@ -11508,11 +11159,7 @@ } } }, - "required": [ - "additions", - "deletions", - "files" - ] + "required": ["additions", "deletions", "files"] }, "share": { "type": "object", @@ -11521,9 +11168,7 @@ "type": "string" } }, - "required": [ - "url" - ] + "required": ["url"] }, "title": { "type": "string" @@ -11547,10 +11192,7 @@ "type": "number" } }, - "required": [ - "created", - "updated" - ] + "required": ["created", "updated"] }, "permission": { "$ref": "#/components/schemas/PermissionRuleset" @@ -11571,20 +11213,10 @@ "type": "string" } }, - "required": [ - "messageID" - ] + "required": ["messageID"] } }, - "required": [ - "id", - "slug", - "projectID", - "directory", - "title", - "version", - "time" - ] + "required": ["id", "slug", "projectID", "directory", "title", "version", "time"] }, "Event.session.created": { "type": "object", @@ -11600,15 +11232,10 @@ "$ref": "#/components/schemas/Session" } }, - "required": [ - "info" - ] + "required": ["info"] } }, - "required": [ - "type", - "properties" - ] + "required": ["type", "properties"] }, "Event.session.updated": { "type": "object", @@ -11624,15 +11251,10 @@ "$ref": "#/components/schemas/Session" } }, - "required": [ - "info" - ] + "required": ["info"] } }, - "required": [ - "type", - "properties" - ] + "required": ["type", "properties"] }, "Event.session.deleted": { "type": "object", @@ -11648,15 +11270,10 @@ "$ref": "#/components/schemas/Session" } }, - "required": [ - "info" - ] + "required": ["info"] } }, - "required": [ - "type", - "properties" - ] + "required": ["type", "properties"] }, "Event.session.diff": { "type": "object", @@ -11678,16 +11295,10 @@ } } }, - "required": [ - "sessionID", - "diff" - ] + "required": ["sessionID", "diff"] } }, - "required": [ - "type", - "properties" - ] + "required": ["type", "properties"] }, "Event.session.error": { "type": "object", @@ -11730,10 +11341,7 @@ } } }, - "required": [ - "type", - "properties" - ] + "required": ["type", "properties"] }, "Event.session.turn.open": { "type": "object", @@ -11749,15 +11357,10 @@ "type": "string" } }, - "required": [ - "sessionID" - ] + "required": ["sessionID"] } }, - "required": [ - "type", - "properties" - ] + "required": ["type", "properties"] }, "Event.session.turn.close": { "type": "object", @@ -11774,23 +11377,13 @@ }, "reason": { "type": "string", - "enum": [ - "completed", - "error", - "interrupted" - ] + "enum": ["completed", "error", "interrupted"] } }, - "required": [ - "sessionID", - "reason" - ] + "required": ["sessionID", "reason"] } }, - "required": [ - "type", - "properties" - ] + "required": ["type", "properties"] }, "Event.vcs.branch.updated": { "type": "object", @@ -11808,10 +11401,7 @@ } } }, - "required": [ - "type", - "properties" - ] + "required": ["type", "properties"] }, "Event.workspace.ready": { "type": "object", @@ -11827,15 +11417,10 @@ "type": "string" } }, - "required": [ - "name" - ] + "required": ["name"] } }, - "required": [ - "type", - "properties" - ] + "required": ["type", "properties"] }, "Event.workspace.failed": { "type": "object", @@ -11851,15 +11436,10 @@ "type": "string" } }, - "required": [ - "message" - ] + "required": ["message"] } }, - "required": [ - "type", - "properties" - ] + "required": ["type", "properties"] }, "Pty": { "type": "object", @@ -11885,24 +11465,13 @@ }, "status": { "type": "string", - "enum": [ - "running", - "exited" - ] + "enum": ["running", "exited"] }, "pid": { "type": "number" } }, - "required": [ - "id", - "title", - "command", - "args", - "cwd", - "status", - "pid" - ] + "required": ["id", "title", "command", "args", "cwd", "status", "pid"] }, "Event.pty.created": { "type": "object", @@ -11918,15 +11487,10 @@ "$ref": "#/components/schemas/Pty" } }, - "required": [ - "info" - ] + "required": ["info"] } }, - "required": [ - "type", - "properties" - ] + "required": ["type", "properties"] }, "Event.pty.updated": { "type": "object", @@ -11942,15 +11506,10 @@ "$ref": "#/components/schemas/Pty" } }, - "required": [ - "info" - ] + "required": ["info"] } }, - "required": [ - "type", - "properties" - ] + "required": ["type", "properties"] }, "Event.pty.exited": { "type": "object", @@ -11970,16 +11529,10 @@ "type": "number" } }, - "required": [ - "id", - "exitCode" - ] + "required": ["id", "exitCode"] } }, - "required": [ - "type", - "properties" - ] + "required": ["type", "properties"] }, "Event.pty.deleted": { "type": "object", @@ -11996,15 +11549,10 @@ "pattern": "^pty.*" } }, - "required": [ - "id" - ] + "required": ["id"] } }, - "required": [ - "type", - "properties" - ] + "required": ["type", "properties"] }, "Event.worktree.ready": { "type": "object", @@ -12023,16 +11571,10 @@ "type": "string" } }, - "required": [ - "name", - "branch" - ] + "required": ["name", "branch"] } }, - "required": [ - "type", - "properties" - ] + "required": ["type", "properties"] }, "Event.worktree.failed": { "type": "object", @@ -12048,15 +11590,10 @@ "type": "string" } }, - "required": [ - "message" - ] + "required": ["message"] } }, - "required": [ - "type", - "properties" - ] + "required": ["type", "properties"] }, "Event": { "anyOf": [ @@ -12126,6 +11663,15 @@ { "$ref": "#/components/schemas/Event.question.rejected" }, + { + "$ref": "#/components/schemas/Event.suggestion.shown" + }, + { + "$ref": "#/components/schemas/Event.suggestion.accepted" + }, + { + "$ref": "#/components/schemas/Event.suggestion.dismissed" + }, { "$ref": "#/components/schemas/Event.session.compacted" }, @@ -12216,20 +11762,12 @@ "$ref": "#/components/schemas/Event" } }, - "required": [ - "directory", - "payload" - ] + "required": ["directory", "payload"] }, "LogLevel": { "description": "Log level", "type": "string", - "enum": [ - "DEBUG", - "INFO", - "WARN", - "ERROR" - ] + "enum": ["DEBUG", "INFO", "WARN", "ERROR"] }, "ServerConfig": { "description": "Server configuration for kilo serve and web commands", @@ -12267,11 +11805,7 @@ "anyOf": [ { "type": "string", - "enum": [ - "ask", - "allow", - "deny" - ] + "enum": ["ask", "allow", "deny"] }, { "type": "null" @@ -12414,11 +11948,7 @@ }, "mode": { "type": "string", - "enum": [ - "subagent", - "primary", - "all" - ] + "enum": ["subagent", "primary", "all"] }, "hidden": { "description": "Hide this subagent from the @ autocomplete menu (default: false, only applies to mode: subagent)", @@ -12440,15 +11970,7 @@ }, { "type": "string", - "enum": [ - "primary", - "secondary", - "accent", - "success", - "warning", - "error", - "info" - ] + "enum": ["primary", "secondary", "accent", "success", "warning", "error", "info"] } ] }, @@ -12534,15 +12056,10 @@ "properties": { "field": { "type": "string", - "enum": [ - "reasoning_content", - "reasoning_details" - ] + "enum": ["reasoning_content", "reasoning_details"] } }, - "required": [ - "field" - ], + "required": ["field"], "additionalProperties": false } ] @@ -12578,16 +12095,10 @@ "type": "number" } }, - "required": [ - "input", - "output" - ] + "required": ["input", "output"] } }, - "required": [ - "input", - "output" - ] + "required": ["input", "output"] }, "limit": { "type": "object", @@ -12602,10 +12113,7 @@ "type": "number" } }, - "required": [ - "context", - "output" - ] + "required": ["context", "output"] }, "modalities": { "type": "object", @@ -12614,61 +12122,39 @@ "type": "array", "items": { "type": "string", - "enum": [ - "text", - "audio", - "image", - "video", - "pdf" - ] + "enum": ["text", "audio", "image", "video", "pdf"] } }, "output": { "type": "array", "items": { "type": "string", - "enum": [ - "text", - "audio", - "image", - "video", - "pdf" - ] + "enum": ["text", "audio", "image", "video", "pdf"] } } }, - "required": [ - "input", - "output" - ] + "required": ["input", "output"] }, "recommendedIndex": { "type": "number" }, "prompt": { "type": "string", - "enum": [ - "codex", - "gemini", - "beast", - "anthropic", - "trinity", - "anthropic_without_todo" - ] + "enum": ["codex", "gemini", "beast", "anthropic", "trinity", "anthropic_without_todo"] }, "isFree": { "type": "boolean" }, + "ai_sdk_provider": { + "type": "string", + "enum": ["anthropic", "openai", "openai-compatible", "openrouter"] + }, "experimental": { "type": "boolean" }, "status": { "type": "string", - "enum": [ - "alpha", - "beta", - "deprecated" - ] + "enum": ["alpha", "beta", "deprecated"] }, "options": { "type": "object", @@ -12804,10 +12290,7 @@ "maximum": 9007199254740991 } }, - "required": [ - "type", - "command" - ], + "required": ["type", "command"], "additionalProperties": false }, "McpOAuthConfig": { @@ -12873,19 +12356,13 @@ "maximum": 9007199254740991 } }, - "required": [ - "type", - "url" - ], + "required": ["type", "url"], "additionalProperties": false }, "LayoutConfig": { "description": "@deprecated Always uses stretch layout.", "type": "string", - "enum": [ - "auto", - "stretch" - ] + "enum": ["auto", "stretch"] }, "Config": { "type": "object", @@ -12925,9 +12402,7 @@ "type": "boolean" } }, - "required": [ - "template" - ] + "required": ["template"] } }, "skills": { @@ -12973,16 +12448,16 @@ "share": { "description": "Control sharing behavior:'manual' allows manual sharing via commands, 'auto' enables automatic sharing, 'disabled' disables all sharing", "type": "string", - "enum": [ - "manual", - "auto", - "disabled" - ] + "enum": ["manual", "auto", "disabled"] }, "autoshare": { "description": "@deprecated Use 'share' field instead. Share newly created sessions automatically", "type": "boolean" }, + "remote_control": { + "description": "Enable remote control of sessions via Kilo Cloud. Equivalent to running /remote on startup.", + "type": "boolean" + }, "autoupdate": { "description": "Automatically update to the latest version. Set to true to auto-update, false to disable, or 'notify' to show update notifications", "anyOf": [ @@ -13128,9 +12603,7 @@ "type": "boolean" } }, - "required": [ - "enabled" - ], + "required": ["enabled"], "additionalProperties": false } ] @@ -13200,9 +12673,7 @@ "const": true } }, - "required": [ - "disabled" - ] + "required": ["disabled"] }, { "type": "object", @@ -13239,9 +12710,7 @@ "additionalProperties": {} } }, - "required": [ - "command" - ] + "required": ["command"] } ] } @@ -13358,11 +12827,7 @@ "const": false } }, - "required": [ - "data", - "errors", - "success" - ] + "required": ["data", "errors", "success"] }, "OAuth": { "type": "object", @@ -13387,12 +12852,7 @@ "type": "string" } }, - "required": [ - "type", - "refresh", - "access", - "expires" - ] + "required": ["type", "refresh", "access", "expires"] }, "ApiAuth": { "type": "object", @@ -13405,10 +12865,7 @@ "type": "string" } }, - "required": [ - "type", - "key" - ] + "required": ["type", "key"] }, "WellKnownAuth": { "type": "object", @@ -13424,11 +12881,7 @@ "type": "string" } }, - "required": [ - "type", - "key", - "token" - ] + "required": ["type", "key", "token"] }, "Auth": { "anyOf": [ @@ -13457,15 +12910,10 @@ "type": "string" } }, - "required": [ - "message" - ] + "required": ["message"] } }, - "required": [ - "name", - "data" - ] + "required": ["name", "data"] }, "Model": { "type": "object", @@ -13489,11 +12937,7 @@ "type": "string" } }, - "required": [ - "id", - "url", - "npm" - ] + "required": ["id", "url", "npm"] }, "name": { "type": "string" @@ -13535,13 +12979,7 @@ "type": "boolean" } }, - "required": [ - "text", - "audio", - "image", - "video", - "pdf" - ] + "required": ["text", "audio", "image", "video", "pdf"] }, "output": { "type": "object", @@ -13562,13 +13000,7 @@ "type": "boolean" } }, - "required": [ - "text", - "audio", - "image", - "video", - "pdf" - ] + "required": ["text", "audio", "image", "video", "pdf"] }, "interleaved": { "anyOf": [ @@ -13580,28 +13012,15 @@ "properties": { "field": { "type": "string", - "enum": [ - "reasoning_content", - "reasoning_details" - ] + "enum": ["reasoning_content", "reasoning_details"] } }, - "required": [ - "field" - ] + "required": ["field"] } ] } }, - "required": [ - "temperature", - "reasoning", - "attachment", - "toolcall", - "input", - "output", - "interleaved" - ] + "required": ["temperature", "reasoning", "attachment", "toolcall", "input", "output", "interleaved"] }, "cost": { "type": "object", @@ -13622,10 +13041,7 @@ "type": "number" } }, - "required": [ - "read", - "write" - ] + "required": ["read", "write"] }, "experimentalOver200K": { "type": "object", @@ -13646,24 +13062,13 @@ "type": "number" } }, - "required": [ - "read", - "write" - ] + "required": ["read", "write"] } }, - "required": [ - "input", - "output", - "cache" - ] + "required": ["input", "output", "cache"] } }, - "required": [ - "input", - "output", - "cache" - ] + "required": ["input", "output", "cache"] }, "limit": { "type": "object", @@ -13678,19 +13083,11 @@ "type": "number" } }, - "required": [ - "context", - "output" - ] + "required": ["context", "output"] }, "status": { "type": "string", - "enum": [ - "alpha", - "beta", - "deprecated", - "active" - ] + "enum": ["alpha", "beta", "deprecated", "active"] }, "options": { "type": "object", @@ -13729,17 +13126,14 @@ }, "prompt": { "type": "string", - "enum": [ - "codex", - "gemini", - "beast", - "anthropic", - "trinity", - "anthropic_without_todo" - ] + "enum": ["codex", "gemini", "beast", "anthropic", "trinity", "anthropic_without_todo"] }, "isFree": { "type": "boolean" + }, + "ai_sdk_provider": { + "type": "string", + "enum": ["anthropic", "openai", "openai-compatible", "openrouter"] } }, "required": [ @@ -13767,12 +13161,7 @@ }, "source": { "type": "string", - "enum": [ - "env", - "config", - "custom", - "api" - ] + "enum": ["env", "config", "custom", "api"] }, "env": { "type": "array", @@ -13800,14 +13189,7 @@ } } }, - "required": [ - "id", - "name", - "source", - "env", - "options", - "models" - ] + "required": ["id", "name", "source", "env", "options", "models"] }, "ToolIDs": { "type": "array", @@ -13826,11 +13208,7 @@ }, "parameters": {} }, - "required": [ - "id", - "description", - "parameters" - ] + "required": ["id", "description", "parameters"] }, "ToolList": { "type": "array", @@ -13890,15 +13268,7 @@ "type": "string" } }, - "required": [ - "id", - "type", - "branch", - "name", - "directory", - "extra", - "projectID" - ] + "required": ["id", "type", "branch", "name", "directory", "extra", "projectID"] }, "Worktree": { "type": "object", @@ -13913,11 +13283,7 @@ "type": "string" } }, - "required": [ - "name", - "branch", - "directory" - ] + "required": ["name", "branch", "directory"] }, "WorktreeCreateInput": { "type": "object", @@ -13938,9 +13304,7 @@ "type": "string" } }, - "required": [ - "directory" - ] + "required": ["directory"] }, "WorktreeResetInput": { "type": "object", @@ -13949,9 +13313,7 @@ "type": "string" } }, - "required": [ - "directory" - ] + "required": ["directory"] }, "WorktreeDiffItem": { "type": "object", @@ -13973,11 +13335,7 @@ }, "status": { "type": "string", - "enum": [ - "added", - "deleted", - "modified" - ] + "enum": ["added", "deleted", "modified"] }, "tracked": { "type": "boolean" @@ -14017,10 +13375,7 @@ "type": "string" } }, - "required": [ - "id", - "worktree" - ] + "required": ["id", "worktree"] }, "GlobalSession": { "type": "object", @@ -14064,11 +13419,7 @@ } } }, - "required": [ - "additions", - "deletions", - "files" - ] + "required": ["additions", "deletions", "files"] }, "share": { "type": "object", @@ -14077,9 +13428,7 @@ "type": "string" } }, - "required": [ - "url" - ] + "required": ["url"] }, "title": { "type": "string" @@ -14103,10 +13452,7 @@ "type": "number" } }, - "required": [ - "created", - "updated" - ] + "required": ["created", "updated"] }, "permission": { "$ref": "#/components/schemas/PermissionRuleset" @@ -14127,9 +13473,7 @@ "type": "string" } }, - "required": [ - "messageID" - ] + "required": ["messageID"] }, "project": { "anyOf": [ @@ -14142,16 +13486,7 @@ ] } }, - "required": [ - "id", - "slug", - "projectID", - "directory", - "title", - "version", - "time", - "project" - ] + "required": ["id", "slug", "projectID", "directory", "title", "version", "time", "project"] }, "McpResource": { "type": "object", @@ -14172,11 +13507,7 @@ "type": "string" } }, - "required": [ - "name", - "uri", - "client" - ] + "required": ["name", "uri", "client"] }, "TextPartInput": { "type": "object", @@ -14207,9 +13538,7 @@ "type": "number" } }, - "required": [ - "start" - ] + "required": ["start"] }, "metadata": { "type": "object", @@ -14219,10 +13548,7 @@ "additionalProperties": {} } }, - "required": [ - "type", - "text" - ] + "required": ["type", "text"] }, "FilePartInput": { "type": "object", @@ -14247,11 +13573,7 @@ "$ref": "#/components/schemas/FilePartSource" } }, - "required": [ - "type", - "mime", - "url" - ] + "required": ["type", "mime", "url"] }, "AgentPartInput": { "type": "object", @@ -14283,17 +13605,10 @@ "maximum": 9007199254740991 } }, - "required": [ - "value", - "start", - "end" - ] + "required": ["value", "start", "end"] } }, - "required": [ - "type", - "name" - ] + "required": ["type", "name"] }, "SubtaskPartInput": { "type": "object", @@ -14324,21 +13639,13 @@ "type": "string" } }, - "required": [ - "providerID", - "modelID" - ] + "required": ["providerID", "modelID"] }, "command": { "type": "string" } }, - "required": [ - "type", - "prompt", - "description", - "agent" - ] + "required": ["type", "prompt", "description", "agent"] }, "ProviderAuthMethod": { "type": "object", @@ -14359,10 +13666,7 @@ "type": "string" } }, - "required": [ - "type", - "label" - ] + "required": ["type", "label"] }, "ProviderAuthAuthorization": { "type": "object", @@ -14386,11 +13690,7 @@ "type": "string" } }, - "required": [ - "url", - "method", - "instructions" - ] + "required": ["url", "method", "instructions"] }, "Symbol": { "type": "object", @@ -14411,17 +13711,10 @@ "$ref": "#/components/schemas/Range" } }, - "required": [ - "uri", - "range" - ] + "required": ["uri", "range"] } }, - "required": [ - "name", - "kind", - "location" - ] + "required": ["name", "kind", "location"] }, "FileNode": { "type": "object", @@ -14437,32 +13730,20 @@ }, "type": { "type": "string", - "enum": [ - "file", - "directory" - ] + "enum": ["file", "directory"] }, "ignored": { "type": "boolean" } }, - "required": [ - "name", - "path", - "absolute", - "type", - "ignored" - ] + "required": ["name", "path", "absolute", "type", "ignored"] }, "FileContent": { "type": "object", "properties": { "type": { "type": "string", - "enum": [ - "text", - "binary" - ] + "enum": ["text", "binary"] }, "content": { "type": "string" @@ -14509,24 +13790,14 @@ } } }, - "required": [ - "oldStart", - "oldLines", - "newStart", - "newLines", - "lines" - ] + "required": ["oldStart", "oldLines", "newStart", "newLines", "lines"] } }, "index": { "type": "string" } }, - "required": [ - "oldFileName", - "newFileName", - "hunks" - ] + "required": ["oldFileName", "newFileName", "hunks"] }, "encoding": { "type": "string", @@ -14536,10 +13807,7 @@ "type": "string" } }, - "required": [ - "type", - "content" - ] + "required": ["type", "content"] }, "File": { "type": "object", @@ -14559,19 +13827,10 @@ }, "status": { "type": "string", - "enum": [ - "added", - "deleted", - "modified" - ] + "enum": ["added", "deleted", "modified"] } }, - "required": [ - "path", - "added", - "removed", - "status" - ] + "required": ["path", "added", "removed", "status"] }, "MCPStatusConnected": { "type": "object", @@ -14581,9 +13840,7 @@ "const": "connected" } }, - "required": [ - "status" - ] + "required": ["status"] }, "MCPStatusDisabled": { "type": "object", @@ -14593,9 +13850,7 @@ "const": "disabled" } }, - "required": [ - "status" - ] + "required": ["status"] }, "MCPStatusFailed": { "type": "object", @@ -14608,10 +13863,7 @@ "type": "string" } }, - "required": [ - "status", - "error" - ] + "required": ["status", "error"] }, "MCPStatusNeedsAuth": { "type": "object", @@ -14621,9 +13873,7 @@ "const": "needs_auth" } }, - "required": [ - "status" - ] + "required": ["status"] }, "MCPStatusNeedsClientRegistration": { "type": "object", @@ -14636,10 +13886,7 @@ "type": "string" } }, - "required": [ - "status", - "error" - ] + "required": ["status", "error"] }, "MCPStatus": { "anyOf": [ @@ -14679,13 +13926,7 @@ "type": "string" } }, - "required": [ - "home", - "state", - "config", - "worktree", - "directory" - ] + "required": ["home", "state", "config", "worktree", "directory"] }, "VcsInfo": { "type": "object", @@ -14694,9 +13935,7 @@ "type": "string" } }, - "required": [ - "branch" - ] + "required": ["branch"] }, "Command": { "type": "object", @@ -14715,11 +13954,7 @@ }, "source": { "type": "string", - "enum": [ - "command", - "mcp", - "skill" - ] + "enum": ["command", "mcp", "skill"] }, "template": { "anyOf": [ @@ -14741,11 +13976,7 @@ } } }, - "required": [ - "name", - "template", - "hints" - ] + "required": ["name", "template", "hints"] }, "Agent": { "type": "object", @@ -14761,11 +13992,7 @@ }, "mode": { "type": "string", - "enum": [ - "subagent", - "primary", - "all" - ] + "enum": ["subagent", "primary", "all"] }, "native": { "type": "boolean" @@ -14798,10 +14025,7 @@ "type": "string" } }, - "required": [ - "modelID", - "providerID" - ] + "required": ["modelID", "providerID"] }, "variant": { "type": "string" @@ -14822,12 +14046,7 @@ "maximum": 9007199254740991 } }, - "required": [ - "name", - "mode", - "permission", - "options" - ] + "required": ["name", "mode", "permission", "options"] }, "LSPStatus": { "type": "object", @@ -14854,12 +14073,7 @@ ] } }, - "required": [ - "id", - "name", - "root", - "status" - ] + "required": ["id", "name", "root", "status"] }, "FormatterStatus": { "type": "object", @@ -14877,12 +14091,8 @@ "type": "boolean" } }, - "required": [ - "name", - "extensions", - "enabled" - ] + "required": ["name", "extensions", "enabled"] } } } -} \ No newline at end of file +}