diff --git a/.changeset/queue-busy-agent-manager-prompts.md b/.changeset/queue-busy-agent-manager-prompts.md new file mode 100644 index 00000000000..01356174d30 --- /dev/null +++ b/.changeset/queue-busy-agent-manager-prompts.md @@ -0,0 +1,6 @@ +--- +"kilo-code": patch +"@kilocode/cli": patch +--- + +Queue Agent Manager follow-up prompts when the target session is busy instead of rejecting them. diff --git a/packages/kilo-docs/pages/automate/agent-manager.md b/packages/kilo-docs/pages/automate/agent-manager.md index 8c4fa2c2332..570d927ad26 100644 --- a/packages/kilo-docs/pages/automate/agent-manager.md +++ b/packages/kilo-docs/pages/automate/agent-manager.md @@ -220,6 +220,8 @@ The companion `agent_manager_models` tool searches models and their supported re The same tool also manages existing sessions. It can return an overview of sections, worktrees, and local sessions, send a prompt to one managed session, stop a managed session, or move a session's worktree into a section. The overview includes section IDs, each section's assigned worktrees, worktree IDs, and session IDs. Use those exact IDs for a subsequent move. Moving accepts a section ID from the overview, or `null` to ungroup the worktree. Moving a session moves its whole worktree, including multi-version siblings. Local sessions cannot be assigned to a section. Stopping aborts the session's active work and removes it from the panel, just like closing the session tab. +Prompts to busy or retrying sessions enter the same queue as follow-up messages sent from chat. The tool returns when the prompt is accepted, without waiting for it to run or finish. Sessions with pending questions or permission requests still refuse prompts. Answer the question with `action: "answer"`, or resolve the permission request in Agent Manager, before prompting again. + The tool uses the `agent_manager` permission. Approval prompts are scoped to the requested capability, so approving `worktree` does not automatically approve `local`, an overview, or a targeted prompt. Prompting an existing managed session requires an explicit `prompt` approval the first time, even if Agent Manager session creation was previously approved broadly. Stopping a session likewise requires an explicit `stop` approval, and moving a worktree requires an explicit `move` approval. ## Sections diff --git a/packages/kilo-vscode/src/agent-manager/orchestration-domain.ts b/packages/kilo-vscode/src/agent-manager/orchestration-domain.ts index b3562931975..6f5ae24ecf0 100644 --- a/packages/kilo-vscode/src/agent-manager/orchestration-domain.ts +++ b/packages/kilo-vscode/src/agent-manager/orchestration-domain.ts @@ -355,10 +355,6 @@ function truncate(value: string, max: number): string { return value.length <= max ? value : `${value.slice(0, max - 1)}…` } -// Name what keeps the target from accepting a prompt. A session blocked on a question -// never becomes idle on its own, so naming the blocker here lets an orchestrating agent -// answer it instead of waiting out the idle timeout. The message echoes the exact session -// and question IDs so a follow-up answer call can copy them without guessing. async function blocked(input: Target, dir: string, name: string): Promise { const [perms, qs] = await Promise.all([ input.client.permission.list({ directory: dir }), @@ -398,14 +394,12 @@ export async function prompt(input: { text: string messageID: string signal?: AbortSignal - idleTimeoutMs?: number managed?: ManagedSession }): Promise { if (input.signal?.aborted) return const target = await locate(input) const blocker = await blocked(input, target.dir, target.name) if (blocker) throw new OrchestrationError("unavailable_session", blocker) - await waitForIdle(input.client, target.dir, input.sessionID, input.signal, input.idleTimeoutMs ?? 30_000) if (input.signal?.aborted) return await input.client.session.promptAsync( { @@ -467,29 +461,6 @@ export async function answer(input: { return { questionID: target.id } } -async function waitForIdle( - client: KiloClient, - directory: string, - sessionID: string, - signal: AbortSignal | undefined, - timeout: number, - start = Date.now(), -): Promise { - if (signal?.aborted) return - const status = await client.session.status({ directory }) - if (status.error) throw new OrchestrationError("host_error", "The managed session status could not be read") - const activity = status.data?.[sessionID]?.type ?? "idle" - if (activity === "idle") return - if (Date.now() - start >= timeout) { - throw new OrchestrationError( - "unavailable_session", - `The managed session is still ${activity}; only idle sessions can be prompted`, - ) - } - await new Promise((resolve) => setTimeout(resolve, 250)) - return waitForIdle(client, directory, sessionID, signal, timeout, start) -} - export function move(input: { state: WorktreeStateManager sessionID: string diff --git a/packages/kilo-vscode/tests/unit/agent-manager-orchestration-bridge.test.ts b/packages/kilo-vscode/tests/unit/agent-manager-orchestration-bridge.test.ts index 93c724715da..6406b61a6ad 100644 --- a/packages/kilo-vscode/tests/unit/agent-manager-orchestration-bridge.test.ts +++ b/packages/kilo-vscode/tests/unit/agent-manager-orchestration-bridge.test.ts @@ -153,8 +153,9 @@ describe("AgentManagerOrchestrationBridge", () => { prompt: "Continue", } - it("deduplicates prompt delivery and retries only the failed acknowledgement", async () => { + it("deduplicates busy-session prompt submission and retries only the failed acknowledgement", async () => { const test = harness() + test.client.session.status.mockImplementation(async () => ({ data: { ses_target: { type: "busy" } } })) test.status.failReply = true test.request(request) @@ -163,6 +164,8 @@ describe("AgentManagerOrchestrationBridge", () => { test.request(request) await waitFor(() => test.replies.length === 2) + expect(test.client.session.status).not.toHaveBeenCalled() + expect(test.rejections).toEqual([]) expect(test.promptAsync).toHaveBeenCalledTimes(1) expect(test.promptAsync).toHaveBeenCalledWith( expect.objectContaining({ diff --git a/packages/kilo-vscode/tests/unit/agent-manager-orchestration-domain.test.ts b/packages/kilo-vscode/tests/unit/agent-manager-orchestration-domain.test.ts index e2dd8829028..567b15b5e7a 100644 --- a/packages/kilo-vscode/tests/unit/agent-manager-orchestration-domain.test.ts +++ b/packages/kilo-vscode/tests/unit/agent-manager-orchestration-domain.test.ts @@ -176,7 +176,7 @@ describe("Agent Manager orchestration domain", () => { expect(dirs.size).toBe(4) }) - it("delivers only to an idle managed session in its authoritative directory", async () => { + it("delivers to a managed session in its authoritative directory", async () => { const managed = state.addWorktree({ branch: "fix/prompt", path: worktree, parentBranch: "main" }) state.addSession("ses_target", managed.id) const get = mock(async () => ({ @@ -302,32 +302,92 @@ describe("Agent Manager orchestration domain", () => { expect(delivered).toHaveBeenCalledWith(expect.objectContaining({ directory: worktree }), { throwOnError: true }) }) - it("waits for a busy managed session to become idle before prompting", async () => { - const managed = state.addWorktree({ branch: "fix/wait", path: worktree, parentBranch: "main" }) - state.addSession("ses_wait", managed.id) - let calls = 0 - const promptAsync = mock(async () => ({ data: undefined })) + it.each(["busy", "retry"] as const)("submits prompts to a %s session without waiting for idle", async (activity) => { + const managed = state.addWorktree({ branch: "fix/queue", path: worktree, parentBranch: "main" }) + state.addSession("ses_queue", managed.id) + const delivered = mock(async () => ({ data: undefined })) const client = { session: { - get: mock(async () => ({ data: { id: "ses_wait", directory: worktree, title: "Wait" } as Session })), - status: mock(async () => ({ data: calls++ === 0 ? { ses_wait: { type: "busy" } } : {} })), - promptAsync, + get: mock(async () => ({ data: { id: "ses_queue", directory: worktree, title: "Queue" } as Session })), + status: mock(async () => ({ data: { ses_queue: { type: activity } } })), + promptAsync: delivered, + abort: mock(async () => ({ data: true })), }, - permission: { - list: mock(async () => ({ data: [] })), + permission: { list: mock(async () => ({ data: [] })) }, + question: { list: mock(async () => ({ data: noQuestions })) }, + } as unknown as KiloClient + + await prompt({ client, root, state, sessionID: "ses_queue", text: "Continue", messageID: "amr_queue" }) + + expect(client.session.status).not.toHaveBeenCalled() + expect(client.session.abort).not.toHaveBeenCalled() + expect(delivered).toHaveBeenCalledTimes(1) + expect(delivered).toHaveBeenCalledWith( + { + sessionID: "ses_queue", + directory: worktree, + messageID: "msg_agent_manager_amr_queue", + parts: [{ type: "text", text: "Continue" }], + snapshotInitialization: "wait", }, + { throwOnError: true }, + ) + }) + + it("rejects prompts to a busy session with a pending permission", async () => { + state.addSession("ses_blocked", null) + const delivered = mock(async () => ({ data: undefined })) + const client = { + session: { + get: mock(async () => ({ data: { id: "ses_blocked", directory: root, title: "Blocked" } as Session })), + status: mock(async () => ({ data: { ses_blocked: { type: "busy" } } })), + promptAsync: delivered, + }, + permission: { list: mock(async () => ({ data: [{ id: "perm_1", sessionID: "ses_blocked" }] })) }, + question: { list: mock(async () => ({ data: noQuestions })) }, + } as unknown as KiloClient + + await expect( + prompt({ client, root, state, sessionID: "ses_blocked", text: "Continue", messageID: "amr_permission" }), + ).rejects.toMatchObject({ + code: "unavailable_session", + message: expect.stringContaining("pending permission request"), + }) + expect(delivered).not.toHaveBeenCalled() + }) + + it("does not submit a prompt cancelled during validation", async () => { + state.addSession("ses_cancelled", null) + const controller = new AbortController() + const delivered = mock(async () => ({ data: undefined })) + const client = { + session: { + get: mock(async () => ({ data: { id: "ses_cancelled", directory: root, title: "Cancelled" } as Session })), + promptAsync: delivered, + }, + permission: { list: mock(async () => ({ data: [] })) }, question: { - list: mock(async () => ({ data: noQuestions })), + list: mock(async () => { + controller.abort() + return { data: noQuestions } + }), }, } as unknown as KiloClient - await prompt({ client, root, state, sessionID: "ses_wait", text: "Continue", messageID: "amr_wait" }) + await prompt({ + client, + root, + state, + sessionID: "ses_cancelled", + text: "Continue", + messageID: "amr_cancelled", + signal: controller.signal, + }) - expect(client.session.status).toHaveBeenCalledTimes(2) - expect(promptAsync).toHaveBeenCalledTimes(1) + expect(delivered).not.toHaveBeenCalled() }) - it("fails fast with the pending question named instead of waiting out the idle timeout", async () => { + it("rejects prompts with the pending question and answer options named", async () => { const managed = state.addWorktree({ branch: "fix/blocked", path: worktree, parentBranch: "main" }) state.addSession("ses_blocked", managed.id) const promptAsync = mock(async () => ({ data: undefined })) @@ -420,7 +480,7 @@ describe("Agent Manager orchestration domain", () => { } satisfies Partial) }) - it("rejects unknown, stale, cross-workspace, and busy targets", async () => { + it("rejects unknown, stale, and cross-workspace targets", async () => { const managed = state.addWorktree({ branch: "fix/errors", path: worktree, parentBranch: "main" }) state.addSession("ses_target", managed.id) const promptAsync = mock(async () => ({ data: undefined })) @@ -430,7 +490,6 @@ describe("Agent Manager orchestration domain", () => { status: mock(async () => ({ data: {} })), promptAsync, }, - // Permission replies remain out of scope. This empty read keeps the test focused on question/idle handling. permission: { list: mock(async () => ({ data: [] })), }, @@ -471,25 +530,6 @@ describe("Agent Manager orchestration domain", () => { ).rejects.toMatchObject({ code: "cross_workspace", } satisfies Partial) - ;(client.session.get as ReturnType).mockImplementation(async () => ({ - data: { id: "ses_target", directory: worktree, title: "Target" } as Session, - })) - ;(client.session.status as ReturnType).mockImplementation(async () => ({ - data: { ses_target: { type: "busy" } }, - })) - await expect( - prompt({ - client, - root, - state, - sessionID: "ses_target", - text: "Continue", - messageID: "amr_busy", - idleTimeoutMs: 0, - }), - ).rejects.toMatchObject({ - code: "unavailable_session", - } satisfies Partial) fs.rmSync(worktree, { recursive: true, force: true }) await expect( diff --git a/packages/opencode/src/kilocode/tool/agent-manager.ts b/packages/opencode/src/kilocode/tool/agent-manager.ts index 630f08200f8..099900d73ad 100644 --- a/packages/opencode/src/kilocode/tool/agent-manager.ts +++ b/packages/opencode/src/kilocode/tool/agent-manager.ts @@ -409,8 +409,8 @@ export const AgentManagerTool = Tool.define< if (result.operation !== "prompt") return yield* Effect.die(new Error("Agent Manager host returned the wrong result type")) return { - title: "Prompt delivered", - output: `Delivered the prompt to Agent Manager session ${result.sessionID}. The session accepted it asynchronously.`, + title: "Prompt accepted", + output: `Agent Manager session ${result.sessionID} accepted the prompt. If the session is busy, the prompt is queued behind active work. This does not wait for completion.`, metadata: { action: "prompt", sessionID: result.sessionID }, } } diff --git a/packages/opencode/src/kilocode/tool/agent-manager.txt b/packages/opencode/src/kilocode/tool/agent-manager.txt index 4dbb410b840..421f81eef19 100644 --- a/packages/opencode/src/kilocode/tool/agent-manager.txt +++ b/packages/opencode/src/kilocode/tool/agent-manager.txt @@ -6,7 +6,7 @@ A session waiting on a question reports `attention: ["question"]` in the list ou Permission blockers are separate from questions: a session may report `attention: ["permission"]`; resolve that request in Agent Manager before prompting. The `answer` action resolves questions only and requires its own `answer` permission approval. -For any assignment request, the required sequence is: (1) call `agent_manager` with `{ "action": "list" }`; (2) read the returned `sections[].id`, `sections[].worktrees[].session.id` or `sessions[].id`, and `ungrouped[].session.id` or `sessions[].sessions[].id`; (3) call `agent_manager` with `{ "action": "move", "sessionID": "", "sectionID": "" }` once for each worktree; (4) use `sectionID: null` to unassign. Never invent IDs, use section names instead of IDs, or edit `.kilo/agent-manager.json`. The `list` result is the source of truth for IDs and assignments: each `sections` entry includes the section `id`, name, and its assigned `worktrees`; each worktree includes its worktree `id` and its session ID(s) in `session` or `sessions`; `ungrouped` lists worktrees that have no section; and `local.sessions` lists local sessions that cannot be assigned to a section. For `move`, pass the target session's ID as `sessionID` and a section ID as `sectionID`; pass `null` to unassign it. Optional filters can narrow by section ID or by `idle`, `busy`, `retry`, `offline`, or `waiting` state. Prompting, stopping, and moving are targeted only: they do not broadcast or create sessions, and prompting does not wait for the target to finish. Moving a session moves its whole worktree, including multi-version siblings; local sessions cannot be assigned to a section. +For any assignment request, the required sequence is: (1) call `agent_manager` with `{ "action": "list" }`; (2) read the returned `sections[].id`, `sections[].worktrees[].session.id` or `sessions[].id`, and `ungrouped[].session.id` or `sessions[].sessions[].id`; (3) call `agent_manager` with `{ "action": "move", "sessionID": "", "sectionID": "" }` once for each worktree; (4) use `sectionID: null` to unassign. Never invent IDs, use section names instead of IDs, or edit `.kilo/agent-manager.json`. The `list` result is the source of truth for IDs and assignments: each `sections` entry includes the section `id`, name, and its assigned `worktrees`; each worktree includes its worktree `id` and its session ID(s) in `session` or `sessions`; `ungrouped` lists worktrees that have no section; and `local.sessions` lists local sessions that cannot be assigned to a section. For `move`, pass the target session's ID as `sessionID` and a section ID as `sectionID`; pass `null` to unassign it. Optional filters can narrow by section ID or by `idle`, `busy`, `retry`, `offline`, or `waiting` state. Prompting, stopping, and moving are targeted only: they do not broadcast or create sessions, and prompting does not wait for the target to finish. Prompts to busy or retrying sessions are queued behind active work instead of rejected; you do not need to wait for idle before prompting. Moving a session moves its whole worktree, including multi-version siblings; local sessions cannot be assigned to a section. To start sessions, keep using the existing `mode` and `tasks` input without an action. Use start mode when the user explicitly asks you to fan out work into Agent Manager, create Agent Manager worktrees, or start multiple Agent Manager sessions for independent tasks. diff --git a/packages/opencode/test/kilocode/agent-manager-tool.test.ts b/packages/opencode/test/kilocode/agent-manager-tool.test.ts index 411d2d173f3..c61ce5dd1d2 100644 --- a/packages/opencode/test/kilocode/agent-manager-tool.test.ts +++ b/packages/opencode/test/kilocode/agent-manager-tool.test.ts @@ -491,7 +491,9 @@ describe("agent_manager tool", () => { prompt: "Continue the fix", }, ]) - expect(result.output).toContain("accepted it asynchronously") + expect(result.title).toBe("Prompt accepted") + expect(result.output).toContain("queued behind active work") + expect(result.output).toContain("does not wait for completion") expect(result.metadata).toEqual(expect.objectContaining({ action: "prompt", sessionID: "ses_target" })) await rt.dispose() })