Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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/agent-manager-stop-session.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@kilocode/cli": minor
"kilo-code": minor
---

Allow agents to stop and remove a targeted Agent Manager session.
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,11 @@ export class AgentManagerProvider implements Disposable {
},
stats: (refresh) => this.statsPoller.snapshot(refresh),
prs: () => this.prBridge.snapshot(),
managed: (id) => this.panelSessions.has(id) || !!this.state?.getSession(id),
close: async (id) => {
await this.onCloseSession(id)
this.postToWebview({ type: "agentManager.sessionClosed", sessionId: id })
},
log: (...args) => this.log(...args),
})
this.unsubTool = this.connectionService.onEventFiltered(
Expand Down
32 changes: 22 additions & 10 deletions packages/kilo-vscode/src/agent-manager/orchestration-bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,10 +25,12 @@ interface RequestBase {
type Request =
| (RequestBase & { operation: "overview"; filter?: OverviewFilter })
| (RequestBase & { operation: "prompt"; targetSessionID: string; prompt: string })
| (RequestBase & { operation: "stop"; targetSessionID: string })

type Result =
| { operation: "overview"; overview: Overview }
| { operation: "prompt"; sessionID: string; delivered: true }
| { operation: "stop"; sessionID: string; stopped: true }

interface Failure {
code: FailureCode | "cancelled" | "disconnected" | "timeout"
Expand All @@ -41,6 +43,8 @@ interface Options {
state(): WorktreeStateManager | undefined
stats(refresh?: boolean): Promise<{ worktrees: WorktreeStats[]; local?: LocalStats }>
prs(): Map<string, PRStatus>
managed(sessionID: string): boolean
close(sessionID: string): Promise<void>
log(...args: unknown[]): void
}

Expand Down Expand Up @@ -262,17 +266,25 @@ export class AgentManagerOrchestrationBridge {
})
return { result: { operation: "overview", overview: result } }
}
await prompt({
client,
root,
state,
sessionID: request.targetSessionID,
text: request.prompt,
messageID: request.id,
signal: active.controller.signal,
})
if (request.operation === "prompt") {
await prompt({
client,
root,
state,
sessionID: request.targetSessionID,
text: request.prompt,
messageID: request.id,
signal: active.controller.signal,
})
if (this.disposed || active.cancelled) return
return { result: { operation: "prompt", sessionID: request.targetSessionID, delivered: true } }
}
if (!this.options.managed(request.targetSessionID)) {
throw new OrchestrationError("unknown_session", "The session is not managed by this Agent Manager workspace")
}
await this.options.close(request.targetSessionID)
if (this.disposed || active.cancelled) return
return { result: { operation: "prompt", sessionID: request.targetSessionID, delivered: true } }
return { result: { operation: "stop", sessionID: request.targetSessionID, stopped: true } }
} catch (error) {
if (this.disposed || active.cancelled) return
return { error: failure(error) }
Expand Down
6 changes: 6 additions & 0 deletions packages/kilo-vscode/src/agent-manager/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,11 @@ interface SessionForkedMessage {
worktreeId?: string
}

interface SessionClosedMessage {
type: "agentManager.sessionClosed"
sessionId: string
}

interface MultiVersionProgressMessage {
type: "agentManager.multiVersionProgress"
status: "creating" | "done"
Expand Down Expand Up @@ -307,6 +312,7 @@ export type AgentManagerOutMessage =
| ErrorOutMessage
| SessionAddedMessage
| SessionForkedMessage
| SessionClosedMessage
| MultiVersionProgressMessage
| SetSessionModelMessage
| SendInitialMessage
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ const { AgentManagerProvider } = await import("../../src/agent-manager/AgentMana
type Manager = {
connectionService: { getClient: () => unknown }
panel: {
postMessage: (message: unknown) => void
sessions: {
getSessionDirectories: () => ReadonlyMap<string, string>
clearSessionDirectory: (id: string) => void
Expand All @@ -24,6 +25,7 @@ function createManager(options?: { dir?: string; panelDir?: string; state?: bool
const aborted: string[][] = []
const cleared: string[] = []
const removed: string[] = []
const messages: unknown[] = []
const events: string[] = []
const client = {
backgroundProcess: {
Expand All @@ -44,6 +46,7 @@ function createManager(options?: { dir?: string; panelDir?: string; state?: bool
const manager = Object.create(AgentManagerProvider.prototype) as Manager
manager.connectionService = { getClient: () => client }
manager.panel = {
postMessage: (message) => messages.push(message),
sessions: {
getSessionDirectories: () => new Map(options?.panelDir ? [["s1", options.panelDir]] : []),
clearSessionDirectory: (id) => cleared.push(id),
Expand All @@ -59,12 +62,12 @@ function createManager(options?: { dir?: string; panelDir?: string; state?: bool
manager.pushState = mock(() => undefined)
manager.log = mock(() => undefined)

return { manager, stopped, aborted, cleared, removed, events }
return { manager, stopped, aborted, cleared, removed, messages, events }
}

describe("AgentManagerProvider closeSession", () => {
it("aborts the agent before stopping processes and removing its tab", async () => {
const { manager, stopped, aborted, cleared, removed, events } = createManager({ dir: "/repo/worktree" })
const { manager, stopped, aborted, cleared, removed, messages, events } = createManager({ dir: "/repo/worktree" })

await manager.onCloseSession("s1")

Expand All @@ -73,6 +76,7 @@ describe("AgentManagerProvider closeSession", () => {
expect(events).toEqual(["abort", "processes", "remove"])
expect(removed).toEqual(["s1"])
expect(cleared).toEqual(["s1"])
expect(messages).toEqual([])
expect(manager.panelSessions.has("s1")).toBe(false)
})

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,9 @@ describe("AgentManagerOrchestrationBridge", () => {
state?: (state: "connecting" | "connected" | "disconnected" | "error") => void
} = {}
const status = { failList: "", failReply: false }
const managed = new Set(["ses_target"])
const promptAsync = mock(async () => ({ data: undefined }))
const close = mock(async () => undefined)
const client = {
session: {
get: mock(async () => ({
Expand Down Expand Up @@ -95,14 +97,16 @@ describe("AgentManagerOrchestrationBridge", () => {
state: () => state,
stats: async () => ({ worktrees: [] }),
prs: () => new Map(),
managed: (id) => managed.has(id),
close,
log: () => undefined,
})
const request = (value: AgentManagerRequest, directory = root) =>
handlers.event?.(
{ id: `event-${value.id}`, type: "kilocode.agent_manager.requested", properties: value } as SSEPayload,
directory,
)
return { bridge, client, handlers, lists, promptAsync, rejections, replies, request, status }
return { bridge, client, close, handlers, lists, managed, promptAsync, rejections, replies, request, status }
}

const request: AgentManagerRequest = {
Expand Down Expand Up @@ -148,6 +152,85 @@ describe("AgentManagerOrchestrationBridge", () => {
test.bridge.dispose()
})

it("stops a managed session through the same close operation as the UI", async () => {
const test = harness()
test.status.failReply = true
const stop: AgentManagerRequest = {
id: "amr_stop",
sessionID: "ses_caller",
operation: "stop",
targetSessionID: "ses_target",
}

test.request(stop)
await waitFor(() => test.replies.length === 1)
test.status.failReply = false
test.request(stop)
await waitFor(() => test.replies.length === 2)

expect(test.close).toHaveBeenCalledTimes(1)
expect(test.close).toHaveBeenCalledWith("ses_target")
expect(test.replies).toEqual([
{
requestID: "amr_stop",
directory: root,
result: { operation: "stop", sessionID: "ses_target", stopped: true },
},
{
requestID: "amr_stop",
directory: root,
result: { operation: "stop", sessionID: "ses_target", stopped: true },
},
])
test.bridge.dispose()
})

it("stops a live panel session before it is persisted", async () => {
const test = harness()
test.managed.add("ses_live")

test.request({
id: "amr_stop_live",
sessionID: "ses_caller",
operation: "stop",
targetSessionID: "ses_live",
})
await waitFor(() => test.replies.length === 1)

expect(state.getSession("ses_live")).toBeUndefined()
expect(test.close).toHaveBeenCalledWith("ses_live")
expect(test.replies[0]).toEqual({
requestID: "amr_stop_live",
directory: root,
result: { operation: "stop", sessionID: "ses_live", stopped: true },
})
test.bridge.dispose()
})

it("rejects stopping a session not managed by the current workspace", async () => {
const test = harness()
test.request({
id: "amr_stop_unknown",
sessionID: "ses_caller",
operation: "stop",
targetSessionID: "ses_unknown",
})
await waitFor(() => test.rejections.length === 1)

expect(test.close).not.toHaveBeenCalled()
expect(test.rejections).toEqual([
{
requestID: "amr_stop_unknown",
directory: root,
error: {
code: "unknown_session",
message: "The session is not managed by this Agent Manager workspace",
},
},
])
test.bridge.dispose()
})

it("rejects request origins outside the current Agent Manager workspace", async () => {
const test = harness()

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1215,8 +1215,8 @@ const AgentManagerContent: Component = () => {
// Mark sessions loaded as soon as the session context receives data (even if empty)
const unsubSessions = vscode.onMessage((msg) => {
if (msg.type === "sessionsLoaded" && !sessionsLoaded()) setSessionsLoaded(true)
if (msg.type === "agentManager.sessionClosed") handleCloseTab(msg.sessionId, false)
})

const unsubRun = vscode.onMessage((msg) => {
if (msg.type !== "agentManager.runStatus") return
const ev = msg as RunStatus
Expand Down Expand Up @@ -1969,7 +1969,7 @@ const AgentManagerContent: Component = () => {
if (!sel || sel === LOCAL) return vscode.postMessage(msg)
vscode.postMessage({ ...msg, worktreeId: sel })
}
const handleCloseTab = (sessionId: string) => {
const handleCloseTab = (sessionId: string, notify = true) => {
freezeTabs()
const pending = isPending(sessionId)
const isActive = pending ? sessionId === activePendingId() : session.currentSessionID() === sessionId
Expand Down Expand Up @@ -1999,7 +1999,7 @@ const AgentManagerContent: Component = () => {
if (session.isSubmitting(sessionId) || isPendingSend(sessionId)) discardPendingDraft(sessionId)
queueMicrotask(() => deletePendingDraft(sessionId))
}
vscode.postMessage({ type: "agentManager.closeSession", sessionId })
if (notify) vscode.postMessage({ type: "agentManager.closeSession", sessionId })
tabFocus.restore()
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -643,6 +643,11 @@ export interface AgentManagerSessionForkedMessage {
worktreeId?: string
}

export interface AgentManagerSessionClosedMessage {
type: "agentManager.sessionClosed"
sessionId: string
}

// Full state push from extension to webview
export interface AgentManagerStateMessage {
type: "agentManager.state"
Expand Down Expand Up @@ -1162,6 +1167,7 @@ export type ExtensionMessage =
| AgentManagerWorktreeSetupMessage
| AgentManagerSessionAddedMessage
| AgentManagerSessionForkedMessage
| AgentManagerSessionClosedMessage
| AgentManagerStateMessage
| AgentManagerRunStatusMessage
| AgentManagerKeybindingsMessage
Expand Down
16 changes: 14 additions & 2 deletions packages/opencode/src/kilocode/agent-manager/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,13 @@ export const PromptRequest = Schema.Struct({
prompt: Prompt,
}).annotate({ identifier: "AgentManagerPromptRequest" })

export const Request = Schema.Union([OverviewRequest, PromptRequest]).annotate({
export const StopRequest = Schema.Struct({
...Base,
operation: Schema.Literal("stop"),
targetSessionID: SessionID,
}).annotate({ identifier: "AgentManagerStopRequest" })

export const Request = Schema.Union([OverviewRequest, PromptRequest, StopRequest]).annotate({
identifier: "AgentManagerRequest",
})
export type Request = Schema.Schema.Type<typeof Request>
Expand All @@ -115,7 +121,13 @@ export const PromptResult = Schema.Struct({
delivered: Schema.Literal(true),
}).annotate({ identifier: "AgentManagerPromptResult" })

export const Result = Schema.Union([OverviewResult, PromptResult]).annotate({
export const StopResult = Schema.Struct({
operation: Schema.Literal("stop"),
sessionID: SessionID,
stopped: Schema.Literal(true),
}).annotate({ identifier: "AgentManagerStopResult" })

export const Result = Schema.Union([OverviewResult, PromptResult, StopResult]).annotate({
identifier: "AgentManagerResult",
})
export type Result = Schema.Schema.Type<typeof Result>
Expand Down
2 changes: 1 addition & 1 deletion packages/opencode/src/kilocode/agent-manager/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ interface State {

function matches(request: Request, result: Result) {
if (request.operation === "overview") return result.operation === "overview"
return result.operation === "prompt" && result.sessionID === request.targetSessionID
return result.operation === request.operation && result.sessionID === request.targetSessionID
}

export interface Interface {
Expand Down
6 changes: 3 additions & 3 deletions packages/opencode/src/kilocode/permission/agent-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,12 @@ import { type Rule } from "./rule"

export namespace AgentManagerPermission {
/**
* Prompting an existing Agent Manager session has an external side effect.
* Prompting or stopping an existing Agent Manager session has an external side effect.
* Broad approvals for legacy session creation must not silently grant it.
*/
export function harden(permission: string, pattern: string, rule: Rule): Rule {
if (permission !== "agent_manager" || pattern !== "prompt" || rule.action !== "allow") return rule
if (rule.permission === "agent_manager" && rule.pattern === "prompt") return rule
if (permission !== "agent_manager" || !["prompt", "stop"].includes(pattern) || rule.action !== "allow") return rule
if (rule.permission === "agent_manager" && rule.pattern === pattern) return rule
return { permission, pattern, action: "ask" }
}
}
Loading
Loading