Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/session-goals.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@kilocode/cli": minor
"kilo-code": minor
---

Keep working toward a session goal with `/goal`, with shared pause, resume, and clear controls in the terminal and VS Code. Pause goals on Stop, errors, new messages, and backend restarts.
47 changes: 27 additions & 20 deletions packages/kilo-vscode/src/KiloProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4119,7 +4119,8 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
)
resolved = await this.resolveSession(sessionID, draftID, context, contextDirectory)
if (!resolved) return
if (sandbox) await sandbox
const stopping = command === "goal" && (args.trim() === "pause" || args.trim() === "clear")
if (sandbox && !stopping) await sandbox
const sid = resolved.sid
const dir = resolved.dir

Expand All @@ -4135,26 +4136,31 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
source: f.source,
}))

await this.checkpoints.get(sid)
await runWithMessageConfirmation(this.confirmations, messageID, "KiloProvider: Command request", () =>
this.withRetry(
() =>
this.client!.session.command({
sessionID: sid,
directory: dir,
command,
arguments: args,
messageID,
model: providerID && modelID ? `${providerID}/${modelID}` : undefined,
agent,
variant,
parts,
snapshotInitialization: this.opts.snapshotInitialization,
}),
sid,
if (command !== "goal") await this.checkpoints.get(sid)
Comment thread
marius-kilocode marked this conversation as resolved.
Resolved
const send = () =>
this.client!.session.command({
sessionID: sid,
directory: dir,
command,
arguments: args,
messageID,
),
)
model: providerID && modelID ? `${providerID}/${modelID}` : undefined,
agent,
variant,
parts,
snapshotInitialization: this.opts.snapshotInitialization,
})
await runWithMessageConfirmation(this.confirmations, messageID, "KiloProvider: Command request", async () => {
if (command !== "goal") return this.withRetry(send, sid, messageID)
const result = await send()
if (result.error) throw result.error
if (args.trim()) return
const message = result.data?.parts
.filter((part) => part.type === "text")
.map((part) => part.text)
.join("\n")
if (message) void vscode.window.showInformationMessage(message)
})
if (messageID && completesWithoutStatus(command)) {
this.postMessage({ type: "sessionCommandCompleted", messageID })
}
Expand Down Expand Up @@ -4368,6 +4374,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
trackedSessionIds: this.trackedSessionIds,
connectionService: this.connectionService,
postMessage: (msg) => this.postMessage(msg),
notify: (message) => void vscode.window.showInformationMessage(message),
getWorkspaceDirectory: (sid) => this.getWorkspaceDirectory(sid),
gatherEditorContext: () => this.gatherEditorContext(),
runWithMessageConfirmation: (id, label, run) => runWithMessageConfirmation(this.confirmations, id, label, run),
Expand Down
14 changes: 13 additions & 1 deletion packages/kilo-vscode/src/kilo-provider-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -194,7 +194,10 @@ export async function runWithMessageConfirmation<T>(
}
}

export function sessionToWebview(session: Pick<Session, "id" | "parentID" | "title" | "time" | "summary" | "revert">) {
export function sessionToWebview(
session: Pick<Session, "id" | "parentID" | "title" | "time" | "summary" | "revert" | "metadata">,
) {
const goal = session.metadata?.["kilo.goal"]
return {
id: session.id,
parentID: session.parentID ?? null,
Expand All @@ -206,6 +209,15 @@ export function sessionToWebview(session: Pick<Session, "id" | "parentID" | "tit
// SolidJS store merge never clears the existing revert state.
revert: session.revert ?? null,
summary: session.summary ?? null,
goal:
goal &&
typeof goal === "object" &&
"text" in goal &&
typeof goal.text === "string" &&
"active" in goal &&
typeof goal.active === "boolean"
? { text: goal.text, active: goal.active }
: null,
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
export function completesWithoutStatus(command: string): boolean {
return command === "local-review" || command === "local-review-uncommitted"
return command === "goal" || command === "local-review" || command === "local-review-uncommitted"
}
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ export interface CloudSessionContext {
recordMessageSessionId(messageId: string, sessionId: string): void
}
postMessage(msg: unknown): void
notify?(message: string): void
getWorkspaceDirectory(sessionId?: string): string
gatherEditorContext(): Promise<EditorContext>
runWithMessageConfirmation?<T>(
Expand Down Expand Up @@ -195,7 +196,7 @@ export async function handleImportAndSend(
filename: f.filename,
source: f.source,
}))
await client.session.command(
const result = await client.session.command(
{
sessionID: session.id,
directory: dir,
Expand All @@ -209,6 +210,13 @@ export async function handleImportAndSend(
},
{ throwOnError: true },
)
if (command === "goal" && !commandArgs?.trim()) {
const message = result.data.parts
.filter((part) => part.type === "text")
.map((part) => part.text)
.join("\n")
if (message) ctx.notify?.(message)
}
return
}

Expand Down
16 changes: 13 additions & 3 deletions packages/kilo-vscode/src/services/attention/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ export function previewSound(value: string) {

export class AttentionService implements vscode.Disposable {
private readonly active = new Set<string>()
private readonly goals = new Set<string>()
private readonly errored = new Set<string>()
private readonly questions = new Set<string>()
private readonly permissions = new Set<string>()
Expand Down Expand Up @@ -59,12 +60,20 @@ export class AttentionService implements vscode.Disposable {
}

private sync(event: Sync) {
if (event.name !== "session.deleted.1") return
this.remove(event.data.sessionID)
if (event.name === "session.deleted.1") return this.remove(event.data.sessionID)
if (event.name !== "session.updated.1" && event.name !== "session.created.1") return
if (!("metadata" in event.data.info)) return
const goal = event.data.info.metadata?.["kilo.goal"]
if (goal && typeof goal === "object" && "active" in goal && goal.active === true) {
this.goals.add(event.data.sessionID)
return
}
this.goals.delete(event.data.sessionID)
}

private remove(sessionID: string) {
this.active.delete(sessionID)
this.goals.delete(sessionID)
this.errored.delete(sessionID)
}

Expand Down Expand Up @@ -110,7 +119,7 @@ export class AttentionService implements vscode.Disposable {
if (!this.active.delete(sessionID)) return
if (this.errored.delete(sessionID)) return
if (event.properties.reason !== "completed") return
if (event.properties.parentID !== undefined) return
if (event.properties.parentID !== undefined || this.goals.has(sessionID)) return
this.notify("done")
}

Expand All @@ -131,6 +140,7 @@ export class AttentionService implements vscode.Disposable {

private reset() {
this.active.clear()
this.goals.clear()
this.errored.clear()
this.questions.clear()
this.permissions.clear()
Expand Down
50 changes: 50 additions & 0 deletions packages/kilo-vscode/tests/fixtures/session-provider-activity.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -281,6 +281,56 @@ try {
value.setCurrentSessionID("root")
await check("root", "idle")
await check("background", "idle")

const goal = { text: "Fix the failing tests", active: true }
await emit({ type: "sessionUpdated", session: { ...info("root"), goal } })
assert.equal(value.currentSession()?.goal?.text, goal.text)
assert.equal(value.currentSession()?.goal?.active, true)
value.setCurrentSessionID("background")
assert.equal(value.currentSession()?.goal, undefined)
value.setCurrentSessionID("root")
value.abort()
assert.deepEqual(sent.at(-1), { type: "abort", sessionID: "root", scope: "session" })
await emit({ type: "sessionStatus", sessionID: "root", status: "busy" })
await emit({ type: "questionRequest", question: { id: "goal-question", sessionID: "root", questions: [] } })
await emit({
type: "suggestionRequest",
suggestion: { id: "goal-suggestion", sessionID: "root", text: "Continue?", actions: [] },
})
const count = value.messages().length
for (const args of ["pause", "", "resume", "clear", "A new goal"]) {
const start = sent.length
value.sendCommand("goal", args)
const command = sent.at(-1) as { type: string; messageID: string; sessionID: string; arguments: string }
const posted = sent.slice(start) as { type: string }[]
assert.equal(posted.filter((message) => message.type === "sendCommand").length, 1)
assert.equal(
posted.some((message) =>
["questionReply", "questionReject", "suggestionDismiss", "permissionResponse"].includes(message.type),
),
false,
)
assert.equal(command.type, "sendCommand")
assert.equal(command.sessionID, "root")
assert.equal(command.arguments, args)
assert.equal(value.messages().length, count)
assert.equal(value.submitting(), true)
await emit({ type: "sessionCommandCompleted", messageID: command.messageID })
assert.equal(value.submitting(), false)
assert.equal(value.status(), "busy")
assert.equal(value.questions().length, 1)
assert.equal(value.suggestions().length, 1)
}
await emit({ type: "sessionUpdated", session: { ...info("root"), goal: { ...goal, active: false } } })
assert.equal(value.currentSession()?.goal?.active, false)
await emit({ type: "sessionUpdated", session: { ...info("root"), goal: null } })
assert.equal(value.currentSession()?.goal, null)
await emit({ type: "questionResolved", requestID: "goal-question" })
await emit({ type: "suggestionResolved", requestID: "goal-suggestion" })
await emit({ type: "sessionStatus", sessionID: "root", status: "idle" })
const start = sent.length
value.abort()
assert.equal(sent.length, start)
for (const update of [setOperation, setRun]) {
update(true)
await settle()
Expand Down
27 changes: 27 additions & 0 deletions packages/kilo-vscode/tests/unit/attention.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,33 @@ describe("AttentionService", () => {
test.service.dispose()
})

it.each([{ "kilo.goal": { text: "Goal", active: false } }, {}])(
"suppresses active goal completions but retains attention and restores normal completion after %j",
(metadata) => {
const test = setup()
test.event(
event({
type: "sync",
name: "session.updated.1",
data: { sessionID: "s1", info: { metadata: { "kilo.goal": { text: "Goal", active: true } } } },
}),
)
test.event(event({ type: "session.status", properties: { sessionID: "s1", status: { type: "busy" } } }))
test.event(event({ type: "session.turn.close", properties: { sessionID: "s1", reason: "completed" } }))
expect(test.sounds).toEqual([])
test.event(event({ type: "question.asked", properties: { id: "q1", sessionID: "s1" } }))
test.event(event({ type: "permission.asked", properties: { id: "p1", sessionID: "s1" } }))
test.event(event({ type: "session.status", properties: { sessionID: "s1", status: { type: "busy" } } }))
test.event(event({ type: "session.error", properties: { sessionID: "s1", error: { name: "ApiError" } } }))
expect(test.sounds).toEqual(["question", "permission", "error"])
test.event(event({ type: "sync", name: "session.updated.1", data: { sessionID: "s1", info: { metadata } } }))
test.event(event({ type: "session.status", properties: { sessionID: "s1", status: { type: "busy" } } }))
test.event(event({ type: "session.turn.close", properties: { sessionID: "s1", reason: "completed" } }))
expect(test.sounds).toEqual(["question", "permission", "error", "done"])
test.service.dispose()
},
)

it("deduplicates question and permission requests", () => {
const test = setup()
test.event(event({ type: "question.asked", properties: { id: "q1", sessionID: "s1" } }))
Expand Down
35 changes: 35 additions & 0 deletions packages/kilo-vscode/tests/unit/cloud-session-handler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,41 @@ function context(sent: unknown[]) {
}

describe("cloud session preview handler", () => {
it.each(["", "pause", "clear", "resume"])("only shows bare goal status after cloud import: %j", async (args) => {
const sent: unknown[] = []
const notices: string[] = []
const ctx: CloudSessionContext = {
...context(sent),
client: {
kilo: {
cloud: {
session: { import: async () => ({ data: { id: "local", time: { created: 1, updated: 1 } } }) },
},
},
session: {
command: async () => ({ data: { parts: [{ type: "text", text: "Goal paused" }] } }),
},
} as unknown as CloudSessionContext["client"],
notify: (message) => notices.push(message),
}
await handleImportAndSend(
ctx,
"cloud",
"/goal",
"message",
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
"goal",
args,
)
expect(notices).toEqual(args ? [] : ["Goal paused"])
expect(sent).toContainEqual({ type: "sessionCommandCompleted", messageID: "message" })
})

it("reports a failure when the CLI preview request stalls", async () => {
const timeout = AbortSignal.timeout
AbortSignal.timeout = () => {
Expand Down
4 changes: 3 additions & 1 deletion packages/kilo-vscode/tests/unit/command-completion.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@ import { describe, expect, it } from "bun:test"
import { completesWithoutStatus } from "../../src/kilo-provider/command-completion"

describe("completesWithoutStatus", () => {
it("matches only deprecated static review aliases", () => {
it("matches goal and deprecated static review aliases", () => {
expect(completesWithoutStatus("goal")).toBe(true)
expect(completesWithoutStatus("goals")).toBe(false)
expect(completesWithoutStatus("local-review")).toBe(true)
expect(completesWithoutStatus("local-review-uncommitted")).toBe(true)
expect(completesWithoutStatus("review")).toBe(false)
Expand Down
Loading
Loading