diff --git a/plugins/agent-workbench/lib-agent/providers/pi/sessions/BUILD.bazel b/plugins/agent-workbench/lib-agent/providers/pi/sessions/BUILD.bazel
index d96dd9fa0be2..8c88bfd96d0d 100644
--- a/plugins/agent-workbench/lib-agent/providers/pi/sessions/BUILD.bazel
+++ b/plugins/agent-workbench/lib-agent/providers/pi/sessions/BUILD.bazel
@@ -39,6 +39,7 @@ jvm_library(
"//plugins/agent-workbench/lib-agent/core",
"//plugins/agent-workbench/lib-agent/json",
"//plugins/agent-workbench/prompt/core",
+ "//plugins/agent-workbench/sessions",
"//plugins/agent-workbench/lib-agent/sessions-core:ai-agent-sessions-core",
"//libraries/jackson3/jackson:libraries-jackson3",
"//libraries/netty/buffer",
@@ -77,6 +78,7 @@ jvm_library(
"//plugins/agent-workbench/lib-agent/core:core_test_lib",
"//plugins/agent-workbench/lib-agent/json:json_test_lib",
"//plugins/agent-workbench/prompt/core:core_test_lib",
+ "//plugins/agent-workbench/sessions:sessions_test_lib",
"//plugins/agent-workbench/lib-agent/sessions-core:ai-agent-sessions-core_test_lib",
"//libraries/jackson3/jackson:libraries-jackson3_test_lib",
"//libraries/netty/buffer:buffer_test_lib",
diff --git a/plugins/agent-workbench/lib-agent/providers/pi/sessions/intellij.platform.ai.agent.pi.sessions.iml b/plugins/agent-workbench/lib-agent/providers/pi/sessions/intellij.platform.ai.agent.pi.sessions.iml
index 99c165a23ba6..4ed17cbebb4d 100644
--- a/plugins/agent-workbench/lib-agent/providers/pi/sessions/intellij.platform.ai.agent.pi.sessions.iml
+++ b/plugins/agent-workbench/lib-agent/providers/pi/sessions/intellij.platform.ai.agent.pi.sessions.iml
@@ -26,6 +26,7 @@
+
diff --git a/plugins/agent-workbench/lib-agent/providers/pi/sessions/resources/pi-extension/agent-workbench-extension.ts b/plugins/agent-workbench/lib-agent/providers/pi/sessions/resources/pi-extension/agent-workbench-extension.ts
index 57f42ad772c5..6b20cb7d62cd 100644
--- a/plugins/agent-workbench/lib-agent/providers/pi/sessions/resources/pi-extension/agent-workbench-extension.ts
+++ b/plugins/agent-workbench/lib-agent/providers/pi/sessions/resources/pi-extension/agent-workbench-extension.ts
@@ -1,5 +1,5 @@
import {type ExtensionAPI, type ExtensionContext} from "@earendil-works/pi-coding-agent";
-import {registerTaskFolderTool, startControlBridge} from "./control.ts";
+import {registerTaskFolderTools, startControlBridge} from "./control.ts";
import {registerJbCentralProvider} from "./jbcentral.ts";
import {parseModelCatalogMetadata} from "./modelCatalog.ts";
import {registerOmlxProviders} from "./omlx.ts";
@@ -33,7 +33,7 @@ export default async function agentWorkbenchTheme(pi: ExtensionAPI) {
let lastSessionInfoSignature: string | undefined;
let lastSessionInfoLeafId: string | null | undefined;
- registerTaskFolderTool(pi, () => controlBridge);
+ registerTaskFolderTools(pi, () => controlBridge);
const updateLastStatusSignature = (signature: string) => {
lastStatusSignature = signature;
diff --git a/plugins/agent-workbench/lib-agent/providers/pi/sessions/resources/pi-extension/control.ts b/plugins/agent-workbench/lib-agent/providers/pi/sessions/resources/pi-extension/control.ts
index b9c2545fa021..20f86faea915 100644
--- a/plugins/agent-workbench/lib-agent/providers/pi/sessions/resources/pi-extension/control.ts
+++ b/plugins/agent-workbench/lib-agent/providers/pi/sessions/resources/pi-extension/control.ts
@@ -13,12 +13,7 @@ const STATUS_TOKEN = process.env[STATUS_TOKEN_ENV];
type AgentWorkbenchControlBridge = {
setContext: (ctx: ExtensionContext) => void;
- getCurrentTaskFolder: () => Promise;
- listTaskFolderThreads: (folderId?: string) => Promise;
- createAndAssignTaskFolder: (name: string, metadata?: Record) => Promise;
- getTaskFolderMetadata: (folderId?: string) => Promise | undefined>;
- setTaskFolderMetadata: (key: string, value: string, folderId?: string) => Promise;
- deleteTaskFolderMetadata: (key: string, folderId?: string) => Promise;
+ requestTaskFolder: (operation: string, args?: Record) => Promise;
close: () => void;
};
@@ -28,21 +23,10 @@ type AgentWorkbenchControlMessageType =
| "response"
| "navigateTree"
| "forkFromEntry"
- | "getCurrentTaskFolder"
- | "listTaskFolderThreads"
- | "createAndAssignTaskFolder"
- | "getTaskFolderMetadata"
- | "setTaskFolderMetadata"
- | "deleteTaskFolderMetadata";
+ | "taskFolderRequest";
type AgentWorkbenchControlSessionMessageType = "hello" | "sessionState";
-type AgentWorkbenchControlRequestType =
- | "getCurrentTaskFolder"
- | "listTaskFolderThreads"
- | "createAndAssignTaskFolder"
- | "getTaskFolderMetadata"
- | "setTaskFolderMetadata"
- | "deleteTaskFolderMetadata";
+type AgentWorkbenchControlRequestType = "taskFolderRequest";
type AgentWorkbenchControlCommand = {
type?: AgentWorkbenchControlMessageType;
@@ -54,15 +38,9 @@ type AgentWorkbenchControlCommand = {
ok?: boolean;
cancelled?: boolean;
error?: string;
- folderId?: string;
- name?: string;
- key?: string;
- value?: string;
- changed?: boolean;
- folder?: AgentWorkbenchTaskFolder | null;
- threads?: AgentWorkbenchTaskFolderThread[];
- metadata?: Record;
- assigned?: boolean;
+ operation?: string;
+ arguments?: Record;
+ result?: unknown;
};
type AgentWorkbenchControlThread = {
@@ -90,11 +68,6 @@ type AgentWorkbenchTaskFolderThread = {
assignedAt?: number;
};
-type AgentWorkbenchCreatedTaskFolder = {
- folder: AgentWorkbenchTaskFolder;
- assigned: boolean;
-};
-
type AgentWorkbenchControlContext = ExtensionContext & {
navigateTree?: (entryId: string) => Promise | void;
fork?: (
@@ -296,32 +269,9 @@ export function startControlBridge(ctx: ExtensionContext): AgentWorkbenchControl
currentCtx = nextCtx;
sendSessionMessage("sessionState");
},
- getCurrentTaskFolder: async () => {
- const response = requireOk(await sendRequest("getCurrentTaskFolder"));
- return response.folder ?? undefined;
- },
- listTaskFolderThreads: async (folderId) => {
- const response = requireOk(await sendRequest("listTaskFolderThreads", {folderId}));
- return response.threads ?? [];
- },
- createAndAssignTaskFolder: async (name, metadata) => {
- const response = requireOk(await sendRequest("createAndAssignTaskFolder", {name, metadata}));
- if (response.folder === undefined || response.folder === null) {
- throw new Error("Agent Workbench did not return the created task folder");
- }
- return {folder: response.folder, assigned: response.assigned === true};
- },
- getTaskFolderMetadata: async (folderId) => {
- const response = requireOk(await sendRequest("getTaskFolderMetadata", {folderId}));
- return response.metadata;
- },
- setTaskFolderMetadata: async (key, value, folderId) => {
- const response = requireOk(await sendRequest("setTaskFolderMetadata", {folderId, key, value}));
- return response.changed === true;
- },
- deleteTaskFolderMetadata: async (key, folderId) => {
- const response = requireOk(await sendRequest("deleteTaskFolderMetadata", {folderId, key}));
- return response.changed === true;
+ requestTaskFolder: async (operation: string, args: Record = {}): Promise => {
+ const response = requireOk(await sendRequest("taskFolderRequest", {operation, arguments: args}));
+ return response.result as T;
},
close: () => {
for (const [requestId, pending] of pendingRequests) {
@@ -334,37 +284,253 @@ export function startControlBridge(ctx: ExtensionContext): AgentWorkbenchControl
};
}
-export function registerTaskFolderTool(
+type AgentWorkbenchTaskFolderToolDefinition = {
+ name: string;
+ label: string;
+ description: string;
+ promptSnippet?: string;
+ promptGuidelines?: string[];
+ parameters: ReturnType;
+ operation: string;
+ arguments?: (params: Record) => Record;
+ confirm?: (params: Record, ctx: ExtensionContext) => Promise;
+ resultText: (result: unknown) => string;
+};
+
+export function registerTaskFolderTools(
pi: ExtensionAPI,
bridgeProvider: () => AgentWorkbenchControlBridge | undefined,
): void {
- pi.registerTool(defineTool({
+ for (const definition of TASK_FOLDER_TOOL_DEFINITIONS) {
+ pi.registerTool(defineTool({
+ name: definition.name,
+ label: definition.label,
+ description: definition.description,
+ promptSnippet: definition.promptSnippet,
+ promptGuidelines: definition.promptGuidelines,
+ parameters: definition.parameters,
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
+ const normalizedParams = params as Record;
+ const bridge = bridgeProvider();
+ if (bridge === undefined) {
+ throw new Error("Agent Workbench control bridge is unavailable");
+ }
+ if (definition.confirm !== undefined && !(await definition.confirm(normalizedParams, ctx))) {
+ return {
+ content: [{type: "text", text: "Cancelled task folder operation."}],
+ details: {cancelled: true},
+ };
+ }
+ const result = await bridge.requestTaskFolder(definition.operation, definition.arguments?.(normalizedParams) ?? normalizedParams);
+ return {
+ content: [{type: "text", text: definition.resultText(result)}],
+ details: result,
+ };
+ },
+ }));
+ }
+}
+
+const TASK_FOLDER_METADATA_DESCRIPTION = "String metadata. Conventional keys are 'issue' and 'review'; custom keys are allowed.";
+
+const TASK_FOLDER_TOOL_DEFINITIONS: AgentWorkbenchTaskFolderToolDefinition[] = [
+ {
+ name: "agent_workbench_get_current_task_folder",
+ label: "Get Current Task Folder",
+ description: "Get the Agent Workbench task folder assigned to the current Pi session, if any.",
+ promptSnippet: "Inspect the task folder assigned to the current Pi thread",
+ promptGuidelines: [
+ "Use agent_workbench_get_current_task_folder before updating a task folder when you are unsure whether this Pi thread is already assigned.",
+ ],
+ parameters: Type.Object({}),
+ operation: "getCurrent",
+ resultText: (result) => {
+ const folder = resultFolder(result);
+ return folder === undefined ? "No task folder is assigned to this thread." : `Current task folder: '${folder.name}'.`;
+ },
+ },
+ {
+ name: "agent_workbench_list_task_folders",
+ label: "List Task Folders",
+ description: "List Agent Workbench task folders for the current project.",
+ promptSnippet: "List Agent Workbench task folders for the current project",
+ promptGuidelines: [
+ "Use agent_workbench_list_task_folders when you need a task folder id or need to choose an existing task folder.",
+ ],
+ parameters: Type.Object({
+ includeDone: Type.Optional(Type.Boolean({description: "Whether to include done task folders"})),
+ }),
+ operation: "listFolders",
+ resultText: (result) => `Found ${resultFolders(result).length} task folder(s).`,
+ },
+ {
+ name: "agent_workbench_list_task_folder_threads",
+ label: "List Task Folder Threads",
+ description: "List threads assigned to an Agent Workbench task folder. Defaults to the current task folder.",
+ promptSnippet: "List threads assigned to an Agent Workbench task folder",
+ parameters: Type.Object({
+ folderId: Type.Optional(Type.String({description: "Task folder id; defaults to the current task folder"})),
+ }),
+ operation: "listThreads",
+ resultText: (result) => `Found ${resultThreads(result).length} assigned thread(s).`,
+ },
+ {
name: "agent_workbench_create_task_folder",
label: "Create Task Folder",
description: "Create an Agent Workbench task folder for the current project and assign the current Pi session to it.",
promptSnippet: "Create an Agent Workbench task folder and assign this Pi thread to it",
promptGuidelines: [
- "Use agent_workbench_create_task_folder when the user asks to create a task folder or start work in a task folder.",
- "Pass the issue id as issue when the user mentions an issue tracker id.",
+ "Use agent_workbench_create_task_folder when the user asks to create a task folder or start work in a new task folder.",
+ "Use metadata key 'issue' for issue tracker ids and 'review' for review ids; do not use separate issue parameters.",
+ "If the current Pi thread already has a task folder, update it with metadata or rename tools instead of creating another folder.",
],
parameters: Type.Object({
name: Type.String({description: "Task folder name"}),
- issue: Type.Optional(Type.String({description: "Issue tracker id to store as task folder metadata key 'issue'"})),
+ metadata: Type.Optional(Type.Record(Type.String(), Type.String({description: TASK_FOLDER_METADATA_DESCRIPTION}))),
}),
- async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
- const bridge = bridgeProvider();
- if (bridge === undefined) {
- throw new Error("Agent Workbench control bridge is unavailable");
+ operation: "createAndAssign",
+ resultText: (result) => {
+ const folder = resultFolder(result);
+ if (folder === undefined) {
+ return "Task folder request completed.";
}
- const issue = params.issue?.trim();
- const metadata = issue === undefined || issue.length === 0 ? undefined : {issue};
- const result = await bridge.createAndAssignTaskFolder(params.name, metadata);
- return {
- content: [{type: "text", text: `Created task folder '${result.folder.name}' and assigned this thread.`}],
- details: result,
- };
+ return resultBoolean(result, "created") === false
+ ? `This thread is already assigned to task folder '${folder.name}'. Use metadata or rename tools for updates.`
+ : `Created task folder '${folder.name}' and assigned this thread.`;
},
- }));
+ },
+ {
+ name: "agent_workbench_assign_current_thread_to_task_folder",
+ label: "Assign Current Thread",
+ description: "Assign the current Pi session to an existing Agent Workbench task folder in the current project.",
+ promptSnippet: "Assign this Pi thread to an existing task folder",
+ parameters: Type.Object({
+ folderId: Type.String({description: "Task folder id"}),
+ }),
+ operation: "assignCurrentThread",
+ resultText: mutationResultText("Assigned current thread to task folder."),
+ },
+ {
+ name: "agent_workbench_remove_current_thread_from_task_folder",
+ label: "Remove Current Thread",
+ description: "Remove the current Pi session from its Agent Workbench task folder.",
+ promptSnippet: "Remove this Pi thread from its current task folder",
+ parameters: Type.Object({}),
+ operation: "unassignCurrentThread",
+ resultText: mutationResultText("Removed current thread from task folder."),
+ },
+ {
+ name: "agent_workbench_rename_task_folder",
+ label: "Rename Task Folder",
+ description: "Rename an Agent Workbench task folder. Defaults to the current task folder.",
+ promptSnippet: "Rename an Agent Workbench task folder",
+ parameters: Type.Object({
+ folderId: Type.Optional(Type.String({description: "Task folder id; defaults to the current task folder"})),
+ name: Type.String({description: "New task folder name"}),
+ }),
+ operation: "rename",
+ resultText: (result) => {
+ const folder = resultFolder(result);
+ return folder === undefined ? "Task folder rename completed." : `Renamed task folder to '${folder.name}'.`;
+ },
+ },
+ {
+ name: "agent_workbench_set_task_folder_metadata",
+ label: "Set Task Folder Metadata",
+ description: "Set a string metadata key on an Agent Workbench task folder. Defaults to the current task folder.",
+ promptSnippet: "Set task folder metadata such as issue or review",
+ promptGuidelines: [
+ "Use agent_workbench_set_task_folder_metadata to associate an issue id with an existing task folder using key 'issue'.",
+ "Use agent_workbench_set_task_folder_metadata to associate a review id with an existing task folder using key 'review'.",
+ ],
+ parameters: Type.Object({
+ folderId: Type.Optional(Type.String({description: "Task folder id; defaults to the current task folder"})),
+ key: Type.String({description: "Metadata key. Conventional keys are 'issue' and 'review'."}),
+ value: Type.String({description: "Metadata value"}),
+ }),
+ operation: "setMetadata",
+ resultText: mutationResultText("Updated task folder metadata."),
+ },
+ {
+ name: "agent_workbench_delete_task_folder_metadata",
+ label: "Delete Task Folder Metadata",
+ description: "Delete a metadata key from an Agent Workbench task folder. Defaults to the current task folder.",
+ promptSnippet: "Delete task folder metadata",
+ parameters: Type.Object({
+ folderId: Type.Optional(Type.String({description: "Task folder id; defaults to the current task folder"})),
+ key: Type.String({description: "Metadata key to delete"}),
+ }),
+ operation: "deleteMetadata",
+ resultText: mutationResultText("Deleted task folder metadata."),
+ },
+ {
+ name: "agent_workbench_mark_task_folder_done",
+ label: "Mark Task Folder Done",
+ description: "Mark an Agent Workbench task folder done after archiving assigned threads. Defaults to the current task folder.",
+ promptSnippet: "Mark a task folder done and archive assigned threads",
+ parameters: Type.Object({
+ folderId: Type.Optional(Type.String({description: "Task folder id; defaults to the current task folder"})),
+ }),
+ operation: "markDone",
+ confirm: (_params, ctx) => confirmTaskFolderOperation(ctx, "Mark Task Folder Done", "Archive assigned threads and mark this task folder done?"),
+ resultText: (result) => {
+ const archived = resultNumber(result, "archivedCount");
+ const requested = resultNumber(result, "requestedCount");
+ return `Marked task folder done. Archived ${archived} of ${requested} assigned thread(s).`;
+ },
+ },
+ {
+ name: "agent_workbench_delete_task_folder",
+ label: "Delete Task Folder",
+ description: "Delete an Agent Workbench task folder and remove its assignments. Threads are not archived.",
+ parameters: Type.Object({
+ folderId: Type.Optional(Type.String({description: "Task folder id; defaults to the current task folder"})),
+ }),
+ operation: "delete",
+ confirm: (_params, ctx) => confirmTaskFolderOperation(ctx, "Delete Task Folder", "Delete this task folder and remove its thread assignments? Threads will not be archived."),
+ resultText: mutationResultText("Deleted task folder."),
+ },
+];
+
+async function confirmTaskFolderOperation(ctx: ExtensionContext, title: string, message: string): Promise {
+ if (!ctx.hasUI) {
+ throw new Error("Task folder operation requires interactive confirmation");
+ }
+ return ctx.ui.confirm(title, message);
+}
+
+function mutationResultText(changedText: string): (result: unknown) => string {
+ return (result) => resultBoolean(result, "changed") ? changedText : "Task folder was already up to date.";
+}
+
+function resultFolder(result: unknown): AgentWorkbenchTaskFolder | undefined {
+ if (!isRecord(result)) return undefined;
+ const folder = result.folder;
+ return isRecord(folder) && typeof folder.name === "string" ? folder as AgentWorkbenchTaskFolder : undefined;
+}
+
+function resultFolders(result: unknown): AgentWorkbenchTaskFolder[] {
+ if (!isRecord(result) || !Array.isArray(result.folders)) return [];
+ return result.folders.filter(isRecord) as AgentWorkbenchTaskFolder[];
+}
+
+function resultThreads(result: unknown): AgentWorkbenchTaskFolderThread[] {
+ if (!isRecord(result) || !Array.isArray(result.threads)) return [];
+ return result.threads.filter(isRecord) as AgentWorkbenchTaskFolderThread[];
+}
+
+function resultBoolean(result: unknown, key: string): boolean | undefined {
+ if (!isRecord(result)) return undefined;
+ return typeof result[key] === "boolean" ? result[key] : undefined;
+}
+
+function resultNumber(result: unknown, key: string): number {
+ if (!isRecord(result)) return 0;
+ return typeof result[key] === "number" ? result[key] : 0;
+}
+
+function isRecord(value: unknown): value is Record {
+ return typeof value === "object" && value !== null;
}
function resolveCapabilities(ctx: ExtensionContext): { navigateTree: boolean; fork: boolean } {
diff --git a/plugins/agent-workbench/lib-agent/providers/pi/sessions/src/PiExtensionControlBridge.kt b/plugins/agent-workbench/lib-agent/providers/pi/sessions/src/PiExtensionControlBridge.kt
index ce7c971c5418..efbdc9cea5a6 100644
--- a/plugins/agent-workbench/lib-agent/providers/pi/sessions/src/PiExtensionControlBridge.kt
+++ b/plugins/agent-workbench/lib-agent/providers/pi/sessions/src/PiExtensionControlBridge.kt
@@ -87,13 +87,7 @@ internal object PiExtensionControlBridge {
PiControlMessageType.HELLO -> handleHello(webSocketClient, payload)
PiControlMessageType.SESSION_STATE -> handleSessionState(webSocketClient, payload)
PiControlMessageType.RESPONSE -> handleResponse(webSocketClient, payload)
- PiControlMessageType.GET_CURRENT_TASK_FOLDER,
- PiControlMessageType.LIST_TASK_FOLDER_THREADS,
- PiControlMessageType.CREATE_AND_ASSIGN_TASK_FOLDER,
- PiControlMessageType.GET_TASK_FOLDER_METADATA,
- PiControlMessageType.SET_TASK_FOLDER_METADATA,
- PiControlMessageType.DELETE_TASK_FOLDER_METADATA,
- -> handleTaskFolderRequest(webSocketClient, payload)
+ PiControlMessageType.TASK_FOLDER_REQUEST -> handleTaskFolderRequest(webSocketClient, payload)
PiControlMessageType.NAVIGATE_TREE,
PiControlMessageType.FORK_FROM_ENTRY,
null,
@@ -185,13 +179,11 @@ internal object PiExtensionControlBridge {
return
}
- sendControlText(
- client = client,
- text = taskFolderControlHandler.handle(
- context = PiControlSessionContext(projectPath = connection.projectPath, sessionId = connection.sessionId),
- payload = payload,
- requestId = requestId,
- ),
+ taskFolderControlHandler.handle(
+ context = PiControlSessionContext(projectPath = connection.projectPath, sessionId = connection.sessionId),
+ payload = payload,
+ requestId = requestId,
+ sendResponse = { response -> sendControlText(client = client, text = response) },
)
}
diff --git a/plugins/agent-workbench/lib-agent/providers/pi/sessions/src/PiExtensionControlProtocol.kt b/plugins/agent-workbench/lib-agent/providers/pi/sessions/src/PiExtensionControlProtocol.kt
index 9f150619048e..dd4ee1796923 100644
--- a/plugins/agent-workbench/lib-agent/providers/pi/sessions/src/PiExtensionControlProtocol.kt
+++ b/plugins/agent-workbench/lib-agent/providers/pi/sessions/src/PiExtensionControlProtocol.kt
@@ -19,12 +19,7 @@ internal enum class PiControlMessageType(@JvmField val wireName: String) {
RESPONSE("response"),
NAVIGATE_TREE("navigateTree"),
FORK_FROM_ENTRY("forkFromEntry"),
- GET_CURRENT_TASK_FOLDER("getCurrentTaskFolder"),
- LIST_TASK_FOLDER_THREADS("listTaskFolderThreads"),
- CREATE_AND_ASSIGN_TASK_FOLDER("createAndAssignTaskFolder"),
- GET_TASK_FOLDER_METADATA("getTaskFolderMetadata"),
- SET_TASK_FOLDER_METADATA("setTaskFolderMetadata"),
- DELETE_TASK_FOLDER_METADATA("deleteTaskFolderMetadata");
+ TASK_FOLDER_REQUEST("taskFolderRequest");
companion object {
private val byWireName: Map = entries.associateBy(PiControlMessageType::wireName)
@@ -69,10 +64,16 @@ internal data class PiControlPayload(
@JvmField val error: String? = null,
@JvmField val thread: PiControlThreadPayload? = null,
@JvmField val capabilities: PiControlCapabilities? = null,
+ @JvmField val operation: String? = null,
+ @JvmField val arguments: PiTaskFolderControlArguments? = null,
+)
+
+internal data class PiTaskFolderControlArguments(
@JvmField val folderId: String? = null,
@JvmField val name: String? = null,
@JvmField val key: String? = null,
@JvmField val value: String? = null,
+ @JvmField val includeDone: Boolean? = null,
@JvmField val metadata: Map? = null,
)
@@ -128,15 +129,6 @@ internal fun buildPiControlHelloAcknowledgement(requestId: String?, sessionId: S
}
}
-internal fun buildPiControlMutationResponse(requestId: String, changed: Boolean): String {
- return buildPiControlJsonObject { generator ->
- generator.writeStringProperty("type", PiControlMessageType.RESPONSE.wireName)
- generator.writeStringProperty("requestId", requestId)
- generator.writeBooleanProperty("ok", true)
- generator.writeBooleanProperty("changed", changed)
- }
-}
-
internal fun buildPiControlErrorResponse(requestId: String?, error: String): String {
return buildPiControlJsonObject { generator ->
generator.writeStringProperty("type", PiControlMessageType.RESPONSE.wireName)
@@ -167,11 +159,8 @@ private fun readControlPayload(parser: JsonParser): PiControlPayload {
var error: String? = null
var thread: PiControlThreadPayload? = null
var capabilities: PiControlCapabilities? = null
- var folderId: String? = null
- var name: String? = null
- var key: String? = null
- var value: String? = null
- var metadata: Map? = null
+ var operation: String? = null
+ var arguments: PiTaskFolderControlArguments? = null
forEachJsonObjectField(parser) { fieldName ->
when (fieldName) {
"type" -> type = PiControlMessageType.fromWireName(readJsonStringOrNull(parser))
@@ -184,11 +173,8 @@ private fun readControlPayload(parser: JsonParser): PiControlPayload {
"error" -> error = readJsonStringOrNull(parser)
"thread" -> thread = readControlThreadPayload(parser)
"capabilities" -> capabilities = readControlCapabilities(parser)
- "folderId" -> folderId = readJsonStringOrNull(parser)
- "name" -> name = readJsonStringOrNull(parser)
- "key" -> key = readJsonStringOrNull(parser)
- "value" -> value = readJsonStringOrNull(parser)
- "metadata" -> metadata = readControlStringMap(parser)
+ "operation" -> operation = readJsonStringOrNull(parser)
+ "arguments" -> arguments = readTaskFolderControlArguments(parser)
else -> parser.skipChildren()
}
true
@@ -204,10 +190,40 @@ private fun readControlPayload(parser: JsonParser): PiControlPayload {
error = error,
thread = thread,
capabilities = capabilities,
+ operation = operation,
+ arguments = arguments,
+ )
+}
+
+private fun readTaskFolderControlArguments(parser: JsonParser): PiTaskFolderControlArguments? {
+ if (parser.currentToken() != JsonToken.START_OBJECT) {
+ parser.skipChildren()
+ return null
+ }
+ var folderId: String? = null
+ var name: String? = null
+ var key: String? = null
+ var value: String? = null
+ var includeDone: Boolean? = null
+ var metadata: Map? = null
+ forEachJsonObjectField(parser) { fieldName ->
+ when (fieldName) {
+ "folderId" -> folderId = readJsonStringOrNull(parser)
+ "name" -> name = readJsonStringOrNull(parser)
+ "key" -> key = readJsonStringOrNull(parser)
+ "value" -> value = readJsonStringOrNull(parser)
+ "includeDone" -> includeDone = readJsonBooleanOrNull(parser)
+ "metadata" -> metadata = readControlStringMap(parser)
+ else -> parser.skipChildren()
+ }
+ true
+ }
+ return PiTaskFolderControlArguments(
folderId = folderId,
name = name,
key = key,
value = value,
+ includeDone = includeDone,
metadata = metadata,
)
}
diff --git a/plugins/agent-workbench/lib-agent/providers/pi/sessions/src/PiTaskFolderControlHandler.kt b/plugins/agent-workbench/lib-agent/providers/pi/sessions/src/PiTaskFolderControlHandler.kt
index fd15c98356b4..4652e6fd0b83 100644
--- a/plugins/agent-workbench/lib-agent/providers/pi/sessions/src/PiTaskFolderControlHandler.kt
+++ b/plugins/agent-workbench/lib-agent/providers/pi/sessions/src/PiTaskFolderControlHandler.kt
@@ -1,110 +1,235 @@
// Copyright 2000-2026 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
package com.intellij.platform.ai.agent.pi.sessions
+import com.intellij.agent.workbench.sessions.model.ArchiveThreadTarget
+import com.intellij.agent.workbench.sessions.service.AgentSessionArchiveService
+import com.intellij.agent.workbench.sessions.statistics.AgentWorkbenchEntryPoint
import com.intellij.openapi.components.service
import com.intellij.platform.ai.agent.sessions.core.folders.AgentTaskFolder
import com.intellij.platform.ai.agent.sessions.core.folders.AgentTaskFolderService
+import com.intellij.platform.ai.agent.sessions.core.folders.AgentTaskFolderStatus
import com.intellij.platform.ai.agent.sessions.core.folders.AgentTaskFolderThreadAssignment
import tools.jackson.core.JsonGenerator
internal class PiTaskFolderControlHandler(
private val taskFolderServiceProvider: () -> AgentTaskFolderService = { service() },
+ private val archiveServiceProvider: () -> AgentSessionArchiveService = { service() },
) {
- fun handle(context: PiControlSessionContext, payload: PiControlPayload, requestId: String): String {
+ fun handle(
+ context: PiControlSessionContext,
+ payload: PiControlPayload,
+ requestId: String,
+ sendResponse: (String) -> Unit,
+ ) {
val service = taskFolderServiceProvider()
- return when (payload.type) {
- PiControlMessageType.GET_CURRENT_TASK_FOLDER -> {
+ val arguments = payload.arguments ?: PiTaskFolderControlArguments()
+ when (payload.operation?.trim()) {
+ OP_GET_CURRENT -> {
val folder = service.getFolderForThread(context.projectPath, PI_AGENT_SESSION_PROVIDER, context.sessionId)
- buildTaskFolderResponse(requestId = requestId, folder = folder)
+ sendResponse(buildTaskFolderResponse(requestId = requestId, folder = folder))
}
- PiControlMessageType.LIST_TASK_FOLDER_THREADS -> {
- val folderId = resolveTaskFolderId(service, context, payload)
- if (folderId == null) {
- buildPiControlErrorResponse(requestId, "Task folder is not available")
- }
- else {
- buildTaskFolderAssignmentsResponse(requestId, service.listFolderThreadAssignments(folderId))
- }
+ OP_LIST_FOLDERS -> {
+ val folders = service.listFolders(context.projectPath, includeDone = arguments.includeDone == true)
+ sendResponse(buildTaskFoldersResponse(requestId = requestId, folders = folders))
}
- PiControlMessageType.CREATE_AND_ASSIGN_TASK_FOLDER -> {
- val name = payload.name?.trim()?.takeIf { it.isNotEmpty() }
- if (name == null) {
- buildPiControlErrorResponse(requestId, "Task folder name is required")
- }
- else {
- val folder = service.createFolder(context.projectPath, name, payload.metadata.orEmpty())
- if (folder == null) {
- buildPiControlErrorResponse(requestId, "Task folder could not be created")
- }
- else {
- val assigned = service.assignThread(context.projectPath, PI_AGENT_SESSION_PROVIDER, context.sessionId, folder.id)
- buildTaskFolderCreatedResponse(requestId = requestId, folder = folder, assigned = assigned)
- }
- }
- }
- PiControlMessageType.GET_TASK_FOLDER_METADATA -> {
- val folder = resolveTaskFolder(service, context, payload)
+ OP_LIST_THREADS -> {
+ val folder = resolveTaskFolder(service, context, arguments)
if (folder == null) {
- buildPiControlErrorResponse(requestId, "Task folder is not available")
+ sendResponse(buildPiControlErrorResponse(requestId, "Task folder is not available"))
}
else {
- buildTaskFolderMetadataResponse(requestId, folder.metadata)
+ sendResponse(buildTaskFolderAssignmentsResponse(requestId, service.listFolderThreadAssignments(folder.id)))
}
}
- PiControlMessageType.SET_TASK_FOLDER_METADATA -> {
- val folderId = resolveTaskFolderId(service, context, payload)
- val key = payload.key?.trim()?.takeIf { it.isNotEmpty() }
- val value = payload.value
- if (folderId == null || key == null || value == null) {
- buildPiControlErrorResponse(requestId, "Task folder metadata request is incomplete")
+ OP_CREATE_AND_ASSIGN -> {
+ createAndAssignCurrentThread(service, context, arguments, requestId, sendResponse)
+ }
+ OP_ASSIGN_CURRENT_THREAD -> {
+ val folder = resolveTaskFolder(service, context, arguments)
+ if (folder == null) {
+ sendResponse(buildPiControlErrorResponse(requestId, "Task folder is not available"))
}
else {
- val changed = service.setMetadata(folderId, key, value)
- buildPiControlMutationResponse(requestId, changed)
+ val changed = service.assignThread(context.projectPath, PI_AGENT_SESSION_PROVIDER, context.sessionId, folder.id)
+ sendResponse(buildTaskFolderMutationResponse(requestId = requestId,
+ changed = changed,
+ folder = refreshedFolder(service, context, folder.id)))
}
}
- PiControlMessageType.DELETE_TASK_FOLDER_METADATA -> {
- val folderId = resolveTaskFolderId(service, context, payload)
- val key = payload.key?.trim()?.takeIf { it.isNotEmpty() }
- if (folderId == null || key == null) {
- buildPiControlErrorResponse(requestId, "Task folder metadata request is incomplete")
+ OP_UNASSIGN_CURRENT_THREAD -> {
+ val folder = service.getFolderForThread(context.projectPath, PI_AGENT_SESSION_PROVIDER, context.sessionId)
+ val changed = service.unassignThread(context.projectPath, PI_AGENT_SESSION_PROVIDER, context.sessionId)
+ sendResponse(buildTaskFolderMutationResponse(requestId = requestId, changed = changed, folder = folder))
+ }
+ OP_RENAME -> {
+ val folder = resolveTaskFolder(service, context, arguments)
+ val name = arguments.name?.trim()?.takeIf { it.isNotEmpty() }
+ if (folder == null || name == null) {
+ sendResponse(buildPiControlErrorResponse(requestId, "Task folder rename request is incomplete"))
}
else {
- val changed = service.deleteMetadata(folderId, key)
- buildPiControlMutationResponse(requestId, changed)
+ val changed = service.renameFolder(folder.id, name)
+ sendResponse(buildTaskFolderMutationResponse(requestId = requestId,
+ changed = changed,
+ folder = refreshedFolder(service, context, folder.id)))
}
}
- else -> buildPiControlErrorResponse(requestId, "Unsupported task folder request")
+ OP_SET_METADATA -> {
+ val folder = resolveTaskFolder(service, context, arguments)
+ val key = arguments.key?.trim()?.takeIf { it.isNotEmpty() }
+ val value = arguments.value
+ if (folder == null || key == null || value == null) {
+ sendResponse(buildPiControlErrorResponse(requestId, "Task folder metadata request is incomplete"))
+ }
+ else {
+ val changed = service.setMetadata(folder.id, key, value)
+ sendResponse(buildTaskFolderMutationResponse(requestId = requestId,
+ changed = changed,
+ folder = refreshedFolder(service, context, folder.id)))
+ }
+ }
+ OP_DELETE_METADATA -> {
+ val folder = resolveTaskFolder(service, context, arguments)
+ val key = arguments.key?.trim()?.takeIf { it.isNotEmpty() }
+ if (folder == null || key == null) {
+ sendResponse(buildPiControlErrorResponse(requestId, "Task folder metadata request is incomplete"))
+ }
+ else {
+ val changed = service.deleteMetadata(folder.id, key)
+ sendResponse(buildTaskFolderMutationResponse(requestId = requestId,
+ changed = changed,
+ folder = refreshedFolder(service, context, folder.id)))
+ }
+ }
+ OP_MARK_DONE -> {
+ val folder = resolveTaskFolder(service, context, arguments)
+ if (folder == null) {
+ sendResponse(buildPiControlErrorResponse(requestId, "Task folder is not available"))
+ }
+ else {
+ markDone(service, archiveServiceProvider, context, folder, requestId, sendResponse)
+ }
+ }
+ OP_DELETE -> {
+ val folder = resolveTaskFolder(service, context, arguments)
+ if (folder == null) {
+ sendResponse(buildPiControlErrorResponse(requestId, "Task folder is not available"))
+ }
+ else {
+ val changed = service.deleteFolder(folder.id)
+ sendResponse(buildTaskFolderMutationResponse(requestId = requestId, changed = changed, folder = folder))
+ }
+ }
+ else -> sendResponse(buildPiControlErrorResponse(requestId, "Unsupported task folder operation"))
}
}
}
-private fun buildTaskFolderCreatedResponse(requestId: String, folder: AgentTaskFolder, assigned: Boolean): String {
- return buildPiControlJsonObject { generator ->
- generator.writeStringProperty("type", PiControlMessageType.RESPONSE.wireName)
- generator.writeStringProperty("requestId", requestId)
- generator.writeBooleanProperty("ok", true)
+private fun createAndAssignCurrentThread(
+ service: AgentTaskFolderService,
+ context: PiControlSessionContext,
+ arguments: PiTaskFolderControlArguments,
+ requestId: String,
+ sendResponse: (String) -> Unit,
+) {
+ val existing = service.getFolderForThread(context.projectPath, PI_AGENT_SESSION_PROVIDER, context.sessionId)
+ if (existing != null) {
+ sendResponse(buildTaskFolderCreatedResponse(requestId = requestId, folder = existing, created = false, assigned = true))
+ return
+ }
+
+ val name = arguments.name?.trim()?.takeIf { it.isNotEmpty() }
+ if (name == null) {
+ sendResponse(buildPiControlErrorResponse(requestId, "Task folder name is required"))
+ return
+ }
+ val folder = service.createFolder(context.projectPath, name, arguments.metadata.orEmpty())
+ if (folder == null) {
+ sendResponse(buildPiControlErrorResponse(requestId, "Task folder could not be created"))
+ return
+ }
+ val assigned = service.assignThread(context.projectPath, PI_AGENT_SESSION_PROVIDER, context.sessionId, folder.id)
+ sendResponse(buildTaskFolderCreatedResponse(requestId = requestId, folder = folder, created = true, assigned = assigned))
+}
+
+private fun markDone(
+ service: AgentTaskFolderService,
+ archiveServiceProvider: () -> AgentSessionArchiveService,
+ context: PiControlSessionContext,
+ folder: AgentTaskFolder,
+ requestId: String,
+ sendResponse: (String) -> Unit,
+) {
+ if (folder.status == AgentTaskFolderStatus.DONE) {
+ sendResponse(buildTaskFolderDoneResponse(requestId = requestId,
+ changed = false,
+ folder = folder,
+ requestedCount = 0,
+ archivedCount = 0))
+ return
+ }
+ val targets = service.listFolderThreadAssignments(folder.id)
+ .map { assignment -> ArchiveThreadTarget.Thread(assignment.path, assignment.provider, assignment.threadId) }
+ .distinctBy { target -> ArchiveTargetKey(target.path, target.provider.value, target.threadId) }
+ if (targets.isEmpty()) {
+ val changed = service.setFolderStatus(folder.id, AgentTaskFolderStatus.DONE)
+ sendResponse(
+ buildTaskFolderDoneResponse(
+ requestId = requestId,
+ changed = changed,
+ folder = refreshedFolder(service, context, folder.id),
+ requestedCount = 0,
+ archivedCount = 0,
+ )
+ )
+ return
+ }
+ val archiveService = archiveServiceProvider()
+ if (!targets.all { target -> archiveService.canArchiveProvider(target.provider) }) {
+ sendResponse(buildPiControlErrorResponse(requestId, "Task folder contains threads that cannot be archived"))
+ return
+ }
+ archiveService.archiveThreads(targets, AgentWorkbenchEntryPoint.TREE_POPUP) { result ->
+ val changed = result.allRequestedArchived && service.setFolderStatus(folder.id, AgentTaskFolderStatus.DONE)
+ sendResponse(
+ buildTaskFolderDoneResponse(
+ requestId = requestId,
+ changed = changed,
+ folder = refreshedFolder(service, context, folder.id) ?: folder,
+ requestedCount = result.requestedCount,
+ archivedCount = result.archivedCount,
+ )
+ )
+ }
+}
+
+private fun buildTaskFolderCreatedResponse(requestId: String, folder: AgentTaskFolder, created: Boolean, assigned: Boolean): String {
+ return buildTaskFolderResultResponse(requestId) { generator ->
generator.writeName("folder")
writeTaskFolder(generator, folder)
+ generator.writeBooleanProperty("created", created)
generator.writeBooleanProperty("assigned", assigned)
}
}
private fun buildTaskFolderResponse(requestId: String, folder: AgentTaskFolder?): String {
- return buildPiControlJsonObject { generator ->
- generator.writeStringProperty("type", PiControlMessageType.RESPONSE.wireName)
- generator.writeStringProperty("requestId", requestId)
- generator.writeBooleanProperty("ok", true)
+ return buildTaskFolderResultResponse(requestId) { generator ->
generator.writeName("folder")
writeTaskFolder(generator, folder)
}
}
+private fun buildTaskFoldersResponse(requestId: String, folders: List): String {
+ return buildTaskFolderResultResponse(requestId) { generator ->
+ generator.writeName("folders")
+ generator.writeStartArray()
+ folders.forEach { folder -> writeTaskFolder(generator, folder) }
+ generator.writeEndArray()
+ }
+}
+
private fun buildTaskFolderAssignmentsResponse(requestId: String, assignments: List): String {
- return buildPiControlJsonObject { generator ->
- generator.writeStringProperty("type", PiControlMessageType.RESPONSE.wireName)
- generator.writeStringProperty("requestId", requestId)
- generator.writeBooleanProperty("ok", true)
+ return buildTaskFolderResultResponse(requestId) { generator ->
generator.writeName("threads")
generator.writeStartArray()
assignments.forEach { assignment -> writeTaskFolderAssignment(generator, assignment) }
@@ -112,14 +237,38 @@ private fun buildTaskFolderAssignmentsResponse(requestId: String, assignments: L
}
}
-private fun buildTaskFolderMetadataResponse(requestId: String, metadata: Map): String {
+private fun buildTaskFolderMutationResponse(requestId: String, changed: Boolean, folder: AgentTaskFolder?): String {
+ return buildTaskFolderResultResponse(requestId) { generator ->
+ generator.writeBooleanProperty("changed", changed)
+ generator.writeName("folder")
+ writeTaskFolder(generator, folder)
+ }
+}
+
+private fun buildTaskFolderDoneResponse(
+ requestId: String,
+ changed: Boolean,
+ folder: AgentTaskFolder?,
+ requestedCount: Int,
+ archivedCount: Int,
+): String {
+ return buildTaskFolderResultResponse(requestId) { generator ->
+ generator.writeBooleanProperty("changed", changed)
+ generator.writeNumberProperty("requestedCount", requestedCount)
+ generator.writeNumberProperty("archivedCount", archivedCount)
+ generator.writeName("folder")
+ writeTaskFolder(generator, folder)
+ }
+}
+
+private fun buildTaskFolderResultResponse(requestId: String, writeResult: (JsonGenerator) -> Unit): String {
return buildPiControlJsonObject { generator ->
generator.writeStringProperty("type", PiControlMessageType.RESPONSE.wireName)
generator.writeStringProperty("requestId", requestId)
generator.writeBooleanProperty("ok", true)
- generator.writeName("metadata")
+ generator.writeName("result")
generator.writeStartObject()
- metadata.forEach { (key, value) -> generator.writeStringProperty(key, value) }
+ writeResult(generator)
generator.writeEndObject()
}
}
@@ -156,13 +305,33 @@ private fun writeTaskFolderAssignment(generator: JsonGenerator, assignment: Agen
private fun resolveTaskFolder(
service: AgentTaskFolderService,
context: PiControlSessionContext,
- payload: PiControlPayload,
+ arguments: PiTaskFolderControlArguments,
): AgentTaskFolder? {
- val folderId = resolveTaskFolderId(service, context, payload) ?: return null
- return service.getFolder(folderId)
+ val explicitFolderId = arguments.folderId?.trim()?.takeIf { it.isNotEmpty() }
+ if (explicitFolderId != null) {
+ return service.snapshot(includeDone = true).folder(context.projectPath, explicitFolderId)
+ }
+ return service.getFolderForThread(context.projectPath, PI_AGENT_SESSION_PROVIDER, context.sessionId)
}
-private fun resolveTaskFolderId(service: AgentTaskFolderService, context: PiControlSessionContext, payload: PiControlPayload): String? {
- return payload.folderId?.trim()?.takeIf { it.isNotEmpty() }
- ?: service.getFolderForThread(context.projectPath, PI_AGENT_SESSION_PROVIDER, context.sessionId)?.id
+private fun refreshedFolder(service: AgentTaskFolderService, context: PiControlSessionContext, folderId: String): AgentTaskFolder? {
+ return service.snapshot(includeDone = true).folder(context.projectPath, folderId)
}
+
+private data class ArchiveTargetKey(
+ @JvmField val path: String,
+ @JvmField val providerId: String,
+ @JvmField val threadId: String,
+)
+
+private const val OP_GET_CURRENT: String = "getCurrent"
+private const val OP_LIST_FOLDERS: String = "listFolders"
+private const val OP_LIST_THREADS: String = "listThreads"
+private const val OP_CREATE_AND_ASSIGN: String = "createAndAssign"
+private const val OP_ASSIGN_CURRENT_THREAD: String = "assignCurrentThread"
+private const val OP_UNASSIGN_CURRENT_THREAD: String = "unassignCurrentThread"
+private const val OP_RENAME: String = "rename"
+private const val OP_SET_METADATA: String = "setMetadata"
+private const val OP_DELETE_METADATA: String = "deleteMetadata"
+private const val OP_MARK_DONE: String = "markDone"
+private const val OP_DELETE: String = "delete"
diff --git a/plugins/agent-workbench/lib-agent/providers/pi/sessions/testSrc/PiExtensionControlWebSocketHandlerTest.kt b/plugins/agent-workbench/lib-agent/providers/pi/sessions/testSrc/PiExtensionControlWebSocketHandlerTest.kt
index fd831ceebe92..fd93308eaf43 100644
--- a/plugins/agent-workbench/lib-agent/providers/pi/sessions/testSrc/PiExtensionControlWebSocketHandlerTest.kt
+++ b/plugins/agent-workbench/lib-agent/providers/pi/sessions/testSrc/PiExtensionControlWebSocketHandlerTest.kt
@@ -6,6 +6,7 @@ import com.intellij.platform.ai.agent.json.createJsonParser
import com.intellij.platform.ai.agent.json.forEachJsonObjectField
import com.intellij.platform.ai.agent.json.readJsonStringOrNull
import com.intellij.platform.ai.agent.sessions.core.folders.AgentTaskFolderService
+import com.intellij.platform.ai.agent.sessions.core.folders.AgentTaskFolderStatus
import com.intellij.platform.ai.agent.sessions.core.providers.AgentSessionSourceUpdate
import com.intellij.openapi.Disposable
import com.intellij.openapi.components.service
@@ -210,42 +211,60 @@ class PiExtensionControlWebSocketHandlerTest {
).join()
listener.nextMessage()
- webSocket.sendText(controlTaskFolderRequest("request-current", "getCurrentTaskFolder"), true).join()
+ webSocket.sendText(controlTaskFolderRequest("request-current", "getCurrent"), true).join()
val currentFolderResponse = listener.nextMessage()
assertThat(currentFolderResponse).contains("\"requestId\":\"request-current\"")
assertThat(currentFolderResponse).contains("\"ok\":true")
+ assertThat(currentFolderResponse).contains("\"result\":")
assertThat(currentFolderResponse).contains("\"id\":${folder.id.jsonString()}")
assertThat(currentFolderResponse).contains("\"issue\":\"IJPL-248623\"")
- webSocket.sendText(controlTaskFolderRequest("request-list", "listTaskFolderThreads", folderId = folder.id), true).join()
+ webSocket.sendText(controlTaskFolderRequest("request-folders", "listFolders"), true).join()
+ val foldersResponse = listener.nextMessage()
+ assertThat(foldersResponse).contains("\"folders\":")
+ assertThat(foldersResponse).contains("\"name\":\"Research\"")
+
+ webSocket.sendText(controlTaskFolderRequest("request-list", "listThreads", folderId = folder.id), true).join()
val listResponse = listener.nextMessage()
assertThat(listResponse).contains("\"threadId\":${sessionId.jsonString()}")
assertThat(listResponse).contains("\"folderId\":${folder.id.jsonString()}")
webSocket.sendText(
- controlTaskFolderRequest("request-set", "setTaskFolderMetadata", folderId = folder.id, key = "review", value = "backend"),
+ controlTaskFolderRequest("request-set", "setMetadata", folderId = folder.id, key = "review", value = "backend"),
true,
).join()
val setResponse = listener.nextMessage()
assertThat(setResponse).contains("\"changed\":true")
assertThat(folderService.getFolder(folder.id)?.metadata).containsEntry("review", "backend")
- webSocket.sendText(controlTaskFolderRequest("request-get-metadata", "getTaskFolderMetadata", folderId = folder.id), true).join()
- val metadataResponse = listener.nextMessage()
- assertThat(metadataResponse).contains("\"requestId\":\"request-get-metadata\"")
- assertThat(metadataResponse).contains("\"metadata\":")
- assertThat(metadataResponse).contains("\"review\":\"backend\"")
-
- webSocket.sendText(controlTaskFolderRequest("request-delete", "deleteTaskFolderMetadata", folderId = folder.id, key = "review"),
+ webSocket.sendText(controlTaskFolderRequest("request-delete-metadata", "deleteMetadata", folderId = folder.id, key = "review"),
true).join()
- val deleteResponse = listener.nextMessage()
- assertThat(deleteResponse).contains("\"changed\":true")
+ val deleteMetadataResponse = listener.nextMessage()
+ assertThat(deleteMetadataResponse).contains("\"changed\":true")
assertThat(folderService.getFolder(folder.id)?.metadata).doesNotContainKey("review")
+ webSocket.sendText(
+ controlTaskFolderRequest(
+ requestId = "request-duplicate-create",
+ operation = "createAndAssign",
+ name = "Should not be created",
+ metadata = mapOf("issue" to "IJPL-000000"),
+ ),
+ true,
+ ).join()
+ val duplicateCreateResponse = listener.nextMessage()
+ assertThat(duplicateCreateResponse).contains("\"created\":false")
+ assertThat(folderService.getFolderForThread(projectDir.toString(), PI_AGENT_SESSION_PROVIDER, sessionId)?.id).isEqualTo(folder.id)
+
+ webSocket.sendText(controlTaskFolderRequest("request-unassign", "unassignCurrentThread"), true).join()
+ val unassignResponse = listener.nextMessage()
+ assertThat(unassignResponse).contains("\"changed\":true")
+ assertThat(folderService.getFolderForThread(projectDir.toString(), PI_AGENT_SESSION_PROVIDER, sessionId)).isNull()
+
webSocket.sendText(
controlTaskFolderRequest(
requestId = "request-create",
- type = "createAndAssignTaskFolder",
+ operation = "createAndAssign",
name = "Follow-up task",
metadata = mapOf("issue" to "IJPL-999999"),
),
@@ -255,10 +274,39 @@ class PiExtensionControlWebSocketHandlerTest {
val createdFolder = requireNotNull(folderService.getFolderForThread(projectDir.toString(), PI_AGENT_SESSION_PROVIDER, sessionId))
assertThat(createResponse).contains("\"requestId\":\"request-create\"")
assertThat(createResponse).contains("\"ok\":true")
+ assertThat(createResponse).contains("\"created\":true")
assertThat(createResponse).contains("\"assigned\":true")
assertThat(createResponse).contains("\"id\":${createdFolder.id.jsonString()}")
assertThat(createdFolder.name).isEqualTo("Follow-up task")
assertThat(createdFolder.metadata).containsEntry("issue", "IJPL-999999")
+
+ webSocket.sendText(controlTaskFolderRequest("request-rename", "rename", name = "Renamed task"), true).join()
+ val renameResponse = listener.nextMessage()
+ assertThat(renameResponse).contains("\"changed\":true")
+ assertThat(folderService.getFolder(createdFolder.id)?.name).isEqualTo("Renamed task")
+
+ webSocket.sendText(controlTaskFolderRequest("request-assign-existing", "assignCurrentThread", folderId = folder.id), true).join()
+ val assignResponse = listener.nextMessage()
+ assertThat(assignResponse).contains("\"changed\":true")
+ assertThat(folderService.getFolderForThread(projectDir.toString(), PI_AGENT_SESSION_PROVIDER, sessionId)?.id).isEqualTo(folder.id)
+
+ val otherProjectFolder = requireNotNull(folderService.createFolder(tempDir.resolve("other-project").toString(), "Other"))
+ webSocket.sendText(controlTaskFolderRequest("request-cross-path", "listThreads", folderId = otherProjectFolder.id), true).join()
+ val crossPathResponse = listener.nextMessage()
+ assertThat(crossPathResponse).contains("\"ok\":false")
+ assertThat(crossPathResponse).contains("Task folder is not available")
+
+ val emptyFolder = requireNotNull(folderService.createFolder(projectDir.toString(), "Empty"))
+ webSocket.sendText(controlTaskFolderRequest("request-mark-done", "markDone", folderId = emptyFolder.id), true).join()
+ val markDoneResponse = listener.nextMessage()
+ assertThat(markDoneResponse).contains("\"changed\":true")
+ assertThat(markDoneResponse).contains("\"requestedCount\":0")
+ assertThat(folderService.getFolder(emptyFolder.id)?.status).isEqualTo(AgentTaskFolderStatus.DONE)
+
+ webSocket.sendText(controlTaskFolderRequest("request-delete-folder", "delete", folderId = emptyFolder.id), true).join()
+ val deleteFolderResponse = listener.nextMessage()
+ assertThat(deleteFolderResponse).contains("\"changed\":true")
+ assertThat(folderService.getFolder(emptyFolder.id)).isNull()
}
finally {
webSocket.sendClose(WebSocket.NORMAL_CLOSURE, "done").join()
@@ -285,7 +333,7 @@ class PiExtensionControlWebSocketHandlerTest {
).join()
listener.nextMessage()
- webSocket.sendText(controlTaskFolderRequest("request-unsupported", "unknownControl"), true).join()
+ webSocket.sendText("{\"type\":\"unknownControl\",\"requestId\":\"request-unsupported\"}", true).join()
val unsupportedResponse = listener.nextMessage()
assertThat(unsupportedResponse).contains("\"type\":\"response\"")
assertThat(unsupportedResponse).contains("\"requestId\":\"request-unsupported\"")
@@ -440,27 +488,32 @@ private fun controlForkResponsePayload(requestId: String, forkedSessionId: Strin
private fun controlTaskFolderRequest(
requestId: String,
- type: String,
+ operation: String,
folderId: String? = null,
name: String? = null,
key: String? = null,
value: String? = null,
+ includeDone: Boolean? = null,
metadata: Map? = null,
): String {
- val fields = mutableListOf(
- "\"type\":${type.jsonString()}",
- "\"requestId\":${requestId.jsonString()}",
- )
- folderId?.let { fields += "\"folderId\":${it.jsonString()}" }
- name?.let { fields += "\"name\":${it.jsonString()}" }
- key?.let { fields += "\"key\":${it.jsonString()}" }
- value?.let { fields += "\"value\":${it.jsonString()}" }
+ val arguments = mutableListOf()
+ folderId?.let { arguments += "\"folderId\":${it.jsonString()}" }
+ name?.let { arguments += "\"name\":${it.jsonString()}" }
+ key?.let { arguments += "\"key\":${it.jsonString()}" }
+ value?.let { arguments += "\"value\":${it.jsonString()}" }
+ includeDone?.let { arguments += "\"includeDone\":$it" }
metadata?.let { values ->
val metadataFields = values.entries.joinToString(",") { (metadataKey, metadataValue) ->
"${metadataKey.jsonString()}:${metadataValue.jsonString()}"
}
- fields += "\"metadata\":{$metadataFields}"
+ arguments += "\"metadata\":{$metadataFields}"
}
+ val fields = mutableListOf(
+ "\"type\":\"taskFolderRequest\"",
+ "\"requestId\":${requestId.jsonString()}",
+ "\"operation\":${operation.jsonString()}",
+ "\"arguments\":{${arguments.joinToString(",")}}",
+ )
return "{${fields.joinToString(",")}}"
}
diff --git a/plugins/agent-workbench/lib-agent/providers/pi/sessions/testSrc/PiThemeSupportTest.kt b/plugins/agent-workbench/lib-agent/providers/pi/sessions/testSrc/PiThemeSupportTest.kt
index e4eb2ebb7374..4b914ffbe018 100644
--- a/plugins/agent-workbench/lib-agent/providers/pi/sessions/testSrc/PiThemeSupportTest.kt
+++ b/plugins/agent-workbench/lib-agent/providers/pi/sessions/testSrc/PiThemeSupportTest.kt
@@ -211,16 +211,23 @@ class PiThemeSupportTest {
val control = readBundledPiExtensionText("control.ts")
assertThat(entrypoint).contains(
- "import {registerTaskFolderTool, startControlBridge} from \"./control.ts\";",
- "registerTaskFolderTool(pi, () => controlBridge);",
+ "import {registerTaskFolderTools, startControlBridge} from \"./control.ts\";",
+ "registerTaskFolderTools(pi, () => controlBridge);",
)
assertThat(control).contains(
+ "const TASK_FOLDER_TOOL_DEFINITIONS",
"pi.registerTool(defineTool({",
+ "name: \"agent_workbench_get_current_task_folder\"",
+ "name: \"agent_workbench_list_task_folders\"",
"name: \"agent_workbench_create_task_folder\"",
+ "name: \"agent_workbench_set_task_folder_metadata\"",
+ "name: \"agent_workbench_mark_task_folder_done\"",
+ "name: \"agent_workbench_delete_task_folder\"",
"parameters: Type.Object({",
"name: Type.String({description: \"Task folder name\"})",
- "issue: Type.Optional(Type.String({description: \"Issue tracker id to store as task folder metadata key 'issue'\"}))",
- "const result = await bridge.createAndAssignTaskFolder(params.name, metadata);",
+ "metadata: Type.Optional(Type.Record(Type.String(), Type.String({description: TASK_FOLDER_METADATA_DESCRIPTION})))",
+ "Use metadata key 'issue' for issue tracker ids and 'review' for review ids; do not use separate issue parameters.",
+ "const result = await bridge.requestTaskFolder(definition.operation, definition.arguments?.(normalizedParams) ?? normalizedParams);",
"details: result",
)
}
diff --git a/plugins/agent-workbench/sessions-toolwindow/src/actions/AgentSessionsTreePopupTaskFolderAgentActions.kt b/plugins/agent-workbench/sessions-toolwindow/src/actions/AgentSessionsTreePopupTaskFolderAgentActions.kt
index 7428673401e6..a3c8d921b5f4 100644
--- a/plugins/agent-workbench/sessions-toolwindow/src/actions/AgentSessionsTreePopupTaskFolderAgentActions.kt
+++ b/plugins/agent-workbench/sessions-toolwindow/src/actions/AgentSessionsTreePopupTaskFolderAgentActions.kt
@@ -199,7 +199,7 @@ internal fun buildTaskFolderAgentPrompt(
Project path: $path
- When you know the task folder name, call the agent_workbench_create_task_folder tool. If the user mentions an issue tracker id, pass it as the issue parameter.
+ When you know the task folder name, call the agent_workbench_create_task_folder tool. If the user mentions an issue tracker id, store it in metadata key "issue".
If the task details are missing, ask one concise follow-up before creating the folder.
""".trimIndent()
}
diff --git a/plugins/agent-workbench/spec/sessions/agent-sessions-pi.spec.md b/plugins/agent-workbench/spec/sessions/agent-sessions-pi.spec.md
index de1ce730bf97..100fabdbbb18 100644
--- a/plugins/agent-workbench/spec/sessions/agent-sessions-pi.spec.md
+++ b/plugins/agent-workbench/spec/sessions/agent-sessions-pi.spec.md
@@ -90,11 +90,13 @@ Agent Workbench treats Pi as a first-class terminal-backed provider. Pi sessions
- The bundled Pi extension must open one private IDE-local WebSocket control connection for live Structure View actions. The WebSocket handshake must be authenticated with `Authorization: Bearer `, the first `hello` frame must bind the connection to the same launch-scoped token/session id/cwd, and every command must target the currently bound session id. The IDE may send `navigateTree` and `forkFromEntry` commands only when the live connection advertises those capabilities. Fork commands must use Pi `fork(entryId, { position: "at", withSession })` and return the fresh replacement session state captured from `withSession`; there is no long-polling, polling, slash-command, or unauthenticated fallback.
[@test] ../../lib-agent/providers/pi/sessions/testSrc/PiExtensionControlWebSocketHandlerTest.kt
-- The same authenticated control WebSocket may carry explicit task-folder capability requests from the bundled Pi extension to the IDE:
- `getCurrentTaskFolder`, `listTaskFolderThreads`, `createAndAssignTaskFolder`, `getTaskFolderMetadata`,
- `setTaskFolderMetadata`, and `deleteTaskFolderMetadata`. These requests must use the bound cwd/session id to resolve current-session context, use global folder id
- for explicit folder mutations, must not inject prompt context automatically, and must return normal `response` frames with `ok`,
- `requestId`, and the requested folder, assignment, metadata, or mutation result fields.
+- The same authenticated control WebSocket may carry explicit task-folder capability requests from the bundled Pi extension to the IDE using
+ `taskFolderRequest` frames with an `operation` string and nested `arguments` object. Supported operations cover current-folder lookup,
+ folder listing, thread assignment listing, create-and-assign, current-thread assign/unassign, rename, metadata set/delete, mark done,
+ and delete. These requests must use the bound cwd/session id to resolve current-session context, use global folder id for explicit
+ folder mutations, must not inject prompt context automatically, and must return normal `response` frames with `ok`, `requestId`, and a
+ `result` object containing the requested folder, assignment, metadata, or mutation fields. Metadata is ordinary string key/value data;
+ `issue` and `review` are conventional keys, not separate protocol fields.
[@test] ../../lib-agent/providers/pi/sessions/testSrc/PiExtensionControlWebSocketHandlerTest.kt
[@test] ../../lib-agent/providers/pi/sessions/testSrc/PiThemeSupportTest.kt