From 199cf27b761dece8d3acabb43f7aee1aae642888 Mon Sep 17 00:00:00 2001 From: joyawang <13715852+JoyaWang@users.noreply.github.com> Date: Mon, 21 Sep 2026 16:54:19 +0800 Subject: [PATCH 1/2] feat(mcp): expose pending asktool requests The desktop renderer receives every `asktool_request` as an in-memory `AgentEventEnvelope`, so the requestId lives only in the renderer's zustand store. The MCP control plane can already answer such a question (`agent/askTool/resolve`) but had no way to read it back, which left remote clients unable to discover what an Agent is waiting for: `plans/pending` has a read operation, `agent/askTool/pending` did not. Add a process-memory registry of pending asks in Electron main, fed by the same envelopes the renderer sees, and expose it through a new read operation and core tool: - `pending-asks.ts`: registry keyed by session, deduped by requestId, bounded per session (oldest pruned first) and globally across session buckets. Structural clones on read; process memory only, never persisted or logged. - `agent/askTool/pending` operation + `pi_asktool_pending` tool, mirroring the existing `plans/pending` spec shape. - Ingested from the local sidecar, the native agent event path and the remote event bridge; cleared on resolve, abort, session delete, agent_end, tool_end, session archive, and when a remote host connection closes (a disconnected host can never resolve its asks, and no later event will arrive to settle them). Validation (from this request worktree, on top of latest origin/main): - `pnpm check:pr-base` PASS - `pnpm build:js` PASS - `node --test apps/desktop/test/{mcp-control,pending-asks,remote-event-bridge,remote-host-connection}.test.mjs` 36/36 PASS - `pnpm --filter @pi-desktop/desktop typecheck` PASS - `pnpm --filter @pi-desktop/shared typecheck` PASS - `pnpm lint:biome` PASS Not run: the E2E suites that drive a live Electron app. The full `pnpm --filter @pi-desktop/desktop test` run also fails on `apps/desktop/test/settings-remote-hosts.test.mjs`, an untouched pre-existing assertion in this tree; that file is unrelated to this change. --- apps/desktop/electron/main/ipc/agent-ipc.ts | 15 +- apps/desktop/electron/main/ipc/session-ipc.ts | 2 + apps/desktop/electron/main/mcp-control.ts | 8 + apps/desktop/electron/main/pending-asks.ts | 227 ++++++++++++++++++ .../electron/main/remote/backend-router.ts | 7 + .../electron/main/remote/remote-backend.ts | 2 + .../main/remote/remote-event-bridge.ts | 19 +- .../main/remote/remote-host-connection.ts | 8 + apps/desktop/electron/main/runtime/sidecar.ts | 13 +- apps/desktop/test/mcp-control.test.mjs | 22 ++ apps/desktop/test/pending-asks.test.mjs | 145 +++++++++++ .../desktop/test/remote-event-bridge.test.mjs | 29 +++ .../test/remote-host-connection.test.mjs | 30 +++ packages/shared/src/protocol.ts | 2 + 14 files changed, 522 insertions(+), 7 deletions(-) create mode 100644 apps/desktop/electron/main/pending-asks.ts create mode 100644 apps/desktop/test/pending-asks.test.mjs diff --git a/apps/desktop/electron/main/ipc/agent-ipc.ts b/apps/desktop/electron/main/ipc/agent-ipc.ts index e889b6914e..c0d29952e2 100644 --- a/apps/desktop/electron/main/ipc/agent-ipc.ts +++ b/apps/desktop/electron/main/ipc/agent-ipc.ts @@ -13,6 +13,7 @@ import type { PersistenceOutbox } from "../persistence-outbox"; import type { ComposerCommandService } from "./composer-ipc"; import type { IpcRegistrar } from "./types"; import { withPromptEnhancementTimeout } from "../prompt-enhancement-timeout"; +import { pendingAsksRegistry } from "../pending-asks"; export type AgentIpcDependencies = { registrar: IpcRegistrar; @@ -703,6 +704,7 @@ export function registerAgentIpc({ agentExtensions.cancelPrompts(req.sessionId); cancelSessionTools(req.sessionId, "Session turn was aborted"); result = await sidecar.call("agent.abort", req); + pendingAsksRegistry.clearSession(req.sessionId); } finally { // A turn that already stopped owning the session is refused inside the // finalizer, so the identity captured above is the only one used here. @@ -794,16 +796,27 @@ export function registerAgentIpc({ return resolved; }); + handle( + IPC.invoke.askToolPending, + async (input: { sessionId?: unknown } = {}) => { + const sessionId = + typeof input.sessionId === "string" ? input.sessionId.trim() : ""; + return pendingAsksRegistry.list(sessionId || undefined); + }, + ); + handle(IPC.invoke.askToolResolve, async (resolution: AskToolResolution) => { if (!sidecar) throw new Error("sidecar unavailable"); const sessionId = String(resolution?.sessionId ?? "").trim(); const requestId = String(resolution?.requestId ?? "").trim(); if (!sessionId || !requestId) throw new Error("asktool resolution identity required"); - return sidecar.call("asktool.resolve", { + const result = await sidecar.call("asktool.resolve", { ...resolution, sessionId, requestId, }); + pendingAsksRegistry.settle(sessionId, requestId); + return result; }); handle(IPC.invoke.plansPending, async (input: { sessionId?: string } = {}) => { diff --git a/apps/desktop/electron/main/ipc/session-ipc.ts b/apps/desktop/electron/main/ipc/session-ipc.ts index 65333f3d17..1a9647e146 100644 --- a/apps/desktop/electron/main/ipc/session-ipc.ts +++ b/apps/desktop/electron/main/ipc/session-ipc.ts @@ -28,6 +28,7 @@ import type { PluginRuntime } from "../plugin-runtime"; import { readSessionCollaboration } from "../services/session-collaboration"; import { searchSessionsAcrossSources } from "../services/session-search"; import type { IpcRegistrar } from "./types"; +import { pendingAsksRegistry } from "../pending-asks"; type RuntimeSession = { id?: string; @@ -324,6 +325,7 @@ export function registerSessionIpc({ } if (!host) throw new Error("host unavailable"); const res = await host.call("session.delete", { id }); + pendingAsksRegistry.clearSession(id); await persistenceOutbox.dropSession(id); // Drop the session's pi-agent so a later session with the same id (or a // stale runtime) can't answer with this session's context. diff --git a/apps/desktop/electron/main/mcp-control.ts b/apps/desktop/electron/main/mcp-control.ts index 804b49246a..0f855f3650 100644 --- a/apps/desktop/electron/main/mcp-control.ts +++ b/apps/desktop/electron/main/mcp-control.ts @@ -244,6 +244,7 @@ const CONTROL_OPERATION_SPECS: OperationSpec[] = [ spec("pullsList", "pulls/list", "List pull requests for the active workspace.", "read", []), spec("scheduledList", "scheduled/list", "List scheduled tasks.", "read", []), spec("toolResolvePermission", "tool/resolvePermission", "Resolve a pending tool permission request.", "dangerous", ["resolution"]), + spec("askToolPending", "agent/askTool/pending", "List pending Agent questions.", "read", ["input"]), spec("askToolResolve", "agent/askTool/resolve", "Answer an Agent question.", "dangerous", ["resolution"]), spec("plansPending", "plans/pending", "List pending Plan or Goal approvals.", "read", ["input"]), spec("plansResolve", "plans/resolve", "Approve or reject a Plan or Goal checkpoint.", "dangerous", ["resolution"]), @@ -405,6 +406,13 @@ const CORE_TOOL_SPECS = [ "agent/compact", (input) => [input], ), + coreTool( + "pi_asktool_pending", + "List pending Agent questions.", + objectSchema({ sessionId: stringSchema("Optional session id filter.") }), + "agent/askTool/pending", + (input) => [input], + ), coreTool( "pi_plans_pending", "List pending Plan or Goal approvals.", diff --git a/apps/desktop/electron/main/pending-asks.ts b/apps/desktop/electron/main/pending-asks.ts new file mode 100644 index 0000000000..a801c487ca --- /dev/null +++ b/apps/desktop/electron/main/pending-asks.ts @@ -0,0 +1,227 @@ +/** + * Process-memory registry of pending Agent asktool questions. + * + * The desktop renderer receives every `asktool_request` as an in-memory + * `AgentEventEnvelope`; the local MCP control plane can answer such a + * question (`agent/askTool/resolve`) but could not read it back: the request + * id lives only in the renderer's zustand store. This registry ingests the + * same envelopes in Electron main so a phone client can discover the + * requestId and the questions it needs for the resolve operation. + * + * Security bounds: + * - Process memory only. Never persisted to disk, log, notification, or + * transcript. State dies with the process, matching the runtime's own + * pending-request lifetime. + * - Bounded per session: when a session accumulates more than + * {@link MAX_PENDING_ASKS_PER_SESSION} unresolved requests, the oldest + * entry is pruned first. No unbounded growth. + * - `list()` returns structural clones so callers cannot mutate the + * registry's references. + * + * Lifecycle: + * - `asktool_request` adds (or dedupes by requestId). + * - `tool_end` for the same session removes the entry whose `toolCallId` + * matches (the asktool call has completed). + * - `agent_end` clears the session: the turn is over and every unresolved + * request is dead for that session. + */ + +import type { + AgentEventEnvelope, + AskToolQuestion, + AskToolRequest, +} from "@pi-desktop/shared"; + +/** Hard per-session bound; pruned oldest-first. */ +export const MAX_PENDING_ASKS_PER_SESSION = 20; + +/** Hard global bound on session buckets; prevents stale remote hosts from growing memory forever. */ +export const MAX_PENDING_ASK_SESSIONS = 1024; + +/** One pending ask as exposed over the read operation. */ +export type PendingAsk = { + requestId: string; + sessionId: string; + toolCallId: string; + questions: AskToolQuestion[]; + receivedAt: number; +}; + +export type PendingAsksResult = { + kind: "pending" | "none"; + asks: PendingAsk[]; +}; + +export type PendingAsksRegistry = { + ingest: (envelope: AgentEventEnvelope) => void; + settle: (sessionId: string, requestId: string) => void; + clearSession: (sessionId: string) => void; + /** + * Drop every bucket whose session id starts with `prefix`. Used when a remote + * host goes away: its asks can never be answered afterwards, and the ids are + * namespaced (`remote::`), so the prefix is exact. + * Returns the number of buckets removed. + */ + clearSessionsWithPrefix: (prefix: string) => number; + list: (sessionId?: string) => PendingAsksResult; +}; + +const isNonEmptyString = (value: unknown): value is string => + typeof value === "string" && value.trim().length > 0; + +/** + * Create the main-process pending-asks registry. Pure in-memory state with + * no timers, no persistence, and no external dependencies beyond the + * shared `AgentEventEnvelope` contract. + */ +export function createPendingAsksRegistry(): PendingAsksRegistry { + /** sessionId -> Array, ordered by receipt. */ + const bySession = new Map(); + + /** + * Keep the bucket count bounded. A remote host that disconnects without + * resolving its asks (or a long-lived desktop accumulating dead sessions) + * must not grow process memory without limit; the per-session cap alone does + * not bound the number of sessions. The oldest bucket (Map insertion order) + * goes first, matching the per-session oldest-first policy. + */ + const evictOldestSessions = (): void => { + while (bySession.size > MAX_PENDING_ASK_SESSIONS) { + const oldest = bySession.keys().next(); + if (oldest.done) return; + bySession.delete(oldest.value); + } + }; + + const ingest = (envelope: AgentEventEnvelope): void => { + if (!envelope || typeof envelope !== "object") return; + const sessionId = String(envelope.sessionId ?? "").trim(); + const event = envelope.event; + if (!sessionId || !event) return; + if (event.type === "asktool_request") { + const request: AskToolRequest | undefined = event.request; + if ( + !request || + !isNonEmptyString(request.requestId) || + !isNonEmptyString(request.toolCallId) || + request.sessionId !== sessionId || + !Array.isArray(request.questions) + ) { + return; + } + const questions: AskToolQuestion[] = []; + for (const raw of request.questions as unknown[]) { + if (!raw || typeof raw !== "object") return; + const question = raw as Partial; + if ( + !isNonEmptyString(question.question) || + !Array.isArray(question.options) || + !question.options.every((option) => typeof option === "string") + ) { + return; + } + questions.push({ + question: question.question, + options: [...question.options], + ...(question.multiSelect === true ? { multiSelect: true } : {}), + }); + } + if (questions.length === 0) return; + const existing = bySession.get(sessionId) ?? []; + if (existing.some((ask) => ask.requestId === request.requestId)) return; + const entry: PendingAsk = { + requestId: request.requestId, + sessionId, + toolCallId: request.toolCallId, + questions, + receivedAt: Number.isFinite(envelope.ts) ? envelope.ts : Date.now(), + }; + const next = [...existing, entry]; + while (next.length > MAX_PENDING_ASKS_PER_SESSION) { + next.shift(); + } + // Re-insert so this session becomes the newest bucket: Map iteration order + // is insertion order, which is what `evictOldestSessions` relies on. + bySession.delete(sessionId); + bySession.set(sessionId, next); + evictOldestSessions(); + return; + } + if (event.type === "tool_end") { + const toolCallId = event.toolCallId; + if (!isNonEmptyString(toolCallId)) return; + const existing = bySession.get(sessionId); + if (!existing?.length) return; + const remaining = existing.filter((ask) => ask.toolCallId !== toolCallId); + if (remaining.length === existing.length) return; + if (remaining.length === 0) bySession.delete(sessionId); + else bySession.set(sessionId, remaining); + return; + } + if (event.type === "agent_end") { + bySession.delete(sessionId); + } + }; + + const settle = (sessionId: string, requestId: string): void => { + const normalizedSessionId = String(sessionId ?? "").trim(); + const normalizedRequestId = String(requestId ?? "").trim(); + if (!normalizedSessionId || !normalizedRequestId) return; + const existing = bySession.get(normalizedSessionId); + if (!existing?.length) return; + const remaining = existing.filter( + (ask) => ask.requestId !== normalizedRequestId, + ); + if (remaining.length === existing.length) return; + if (remaining.length === 0) bySession.delete(normalizedSessionId); + else bySession.set(normalizedSessionId, remaining); + }; + + const clearSession = (sessionId: string): void => { + const normalized = String(sessionId ?? "").trim(); + if (normalized) bySession.delete(normalized); + }; + + const clearSessionsWithPrefix = (prefix: string): number => { + const normalized = String(prefix ?? ""); + if (!normalized) return 0; + let removed = 0; + for (const sessionId of [...bySession.keys()]) { + if (!sessionId.startsWith(normalized)) continue; + bySession.delete(sessionId); + removed += 1; + } + return removed; + }; + + const list = (sessionId?: string): PendingAsksResult => { + const sources: PendingAsk[][] = []; + if (isNonEmptyString(sessionId)) { + const existing = bySession.get(sessionId.trim()); + if (existing?.length) sources.push(existing); + } else { + for (const bucket of bySession.values()) { + if (bucket.length) sources.push(bucket); + } + } + const asks = sources.flat().sort((left, right) => { + if (left.receivedAt !== right.receivedAt) return left.receivedAt - right.receivedAt; + return left.requestId < right.requestId ? -1 : left.requestId > right.requestId ? 1 : 0; + }); + return { + kind: asks.length > 0 ? "pending" : "none", + asks: asks.map((ask) => ({ + ...ask, + questions: ask.questions.map((question) => ({ + ...question, + options: [...question.options], + })), + })), + }; + }; + + return { ingest, settle, clearSession, clearSessionsWithPrefix, list }; +} + +/** Shared process-memory registry used by local, native and remote event paths. */ +export const pendingAsksRegistry = createPendingAsksRegistry(); diff --git a/apps/desktop/electron/main/remote/backend-router.ts b/apps/desktop/electron/main/remote/backend-router.ts index 9e38a4309d..84502a3d52 100644 --- a/apps/desktop/electron/main/remote/backend-router.ts +++ b/apps/desktop/electron/main/remote/backend-router.ts @@ -21,6 +21,13 @@ export const ROUTE_LOCAL = Symbol("pi-desktop.route-local"); /** Namespaced-id prefix for sessions owned by a remote host. */ const REMOTE_PREFIX = "remote:"; +/** + * The same prefix, exported for callers that must reason about a whole host's + * sessions at once (e.g. clearing that host's pending-ask buckets when it goes + * away) instead of one namespaced id at a time. + */ +export const REMOTE_SESSION_PREFIX = REMOTE_PREFIX; + /** * Delimiter that embeds the renderer-visible remote session id inside a tool * permission `requestId`. `toolResolvePermission` carries only `{requestId, diff --git a/apps/desktop/electron/main/remote/remote-backend.ts b/apps/desktop/electron/main/remote/remote-backend.ts index b3237ab786..bd6ec8f058 100644 --- a/apps/desktop/electron/main/remote/remote-backend.ts +++ b/apps/desktop/electron/main/remote/remote-backend.ts @@ -39,6 +39,7 @@ import { sessionIdForCall, } from "./backend-router.js"; import { racpSessionToSummary, snapshotToSessionDetail } from "./remote-transcript.js"; +import { pendingAsksRegistry } from "../pending-asks"; /** The subset of `RacpClient` this backend needs; kept minimal for testing. */ export type RemoteRacpClient = { @@ -306,6 +307,7 @@ export function createRemoteBackend(options: RemoteBackendOptions): RemoteBacken answers: resolution.answers, context: context(), }); + pendingAsksRegistry.settle(resolution.sessionId, resolution.requestId); return { ok: true }; } case IPC.invoke.plansResolve: { diff --git a/apps/desktop/electron/main/remote/remote-event-bridge.ts b/apps/desktop/electron/main/remote/remote-event-bridge.ts index fcac20a202..9830174d10 100644 --- a/apps/desktop/electron/main/remote/remote-event-bridge.ts +++ b/apps/desktop/electron/main/remote/remote-event-bridge.ts @@ -21,6 +21,7 @@ import type { ToolPermissionRequest, } from "@pi-desktop/shared"; import { makeRemoteApprovalRequestId, makeRemoteSessionId } from "./backend-router.js"; +import { pendingAsksRegistry } from "../pending-asks"; /** A minimal shape of the session field carried by host-scope session events. * Both the RACP `RacpSession` and the host's smaller `SessionSummary` extend @@ -120,10 +121,10 @@ function toAskToolRequest( return { requestId: input.id, sessionId: remoteSessionId, - // Ask-tool needs a toolCallId to attach the answer to; the RACP schema - // supplies it as `parentToolCallId` when the input came from a subagent, - // and leaves it undefined for the top-level agent. - toolCallId: input.parentToolCallId ?? "", + // The RACP response is keyed by `input.id`, not this field. Preserve the + // parent tool call when present; for a top-level ask use the input id as a + // stable non-empty lifecycle key for the main-process registry. + toolCallId: input.parentToolCallId ?? input.id, questions: input.questions.map((question) => ({ question: question.question, options: question.options, @@ -156,6 +157,7 @@ export function createRemoteEventBridge(options: RemoteEventBridgeOptions): Remo ...(envelope.parentToolCallId ? { parentToolCallId: envelope.parentToolCallId } : {}), ...(envelope.agentName ? { agentName: envelope.agentName } : {}), }; + pendingAsksRegistry.ingest(local); emit(IPC.event.agentMessage, local); }; @@ -181,6 +183,7 @@ export function createRemoteEventBridge(options: RemoteEventBridgeOptions): Remo } // "session.archived": pass through to the lifecycle handler for router // cleanup, then refresh the renderer's session list. + pendingAsksRegistry.clearSession(remoteSessionId); onLifecycle?.({ kind: "session.archived", hostSessionId: session.id, remoteSessionId, session }); emit(IPC.event.sessionsChanged, { reason: "remote.session.archived" }); }; @@ -244,8 +247,14 @@ export function createRemoteEventBridge(options: RemoteEventBridgeOptions): Remo }); return; } + case "input.resolved": { + const payload = envelope.payload; + if (isRecord(payload) && typeof payload.inputId === "string") { + pendingAsksRegistry.settle(remoteSessionId, payload.inputId); + } + return; + } case "approval.resolved": - case "input.resolved": case "terminal.changed": case "terminal.output": case "resync.required": diff --git a/apps/desktop/electron/main/remote/remote-host-connection.ts b/apps/desktop/electron/main/remote/remote-host-connection.ts index 174832f12b..929cf5e4ac 100644 --- a/apps/desktop/electron/main/remote/remote-host-connection.ts +++ b/apps/desktop/electron/main/remote/remote-host-connection.ts @@ -21,6 +21,8 @@ import { type RemoteEventBridge, type RemoteLifecycleEvent, } from "./remote-event-bridge.js"; +import { pendingAsksRegistry } from "../pending-asks"; +import { REMOTE_SESSION_PREFIX } from "./backend-router.js"; /** * The transport surface this connection needs. {@link RemoteRacpClient} covers @@ -147,6 +149,12 @@ export function createRemoteHostConnection( bridge = null; for (const remoteSessionId of registered) router.unregisterBackend(remoteSessionId); registered.clear(); + // A host that went away can never resolve its asks, and no later event + // will arrive to settle or clear them: `input.resolved` and + // `session.archived` both come from the same dead stream. Without this the + // dead entries stay visible in unfiltered listings and their buckets are + // never reclaimed. The namespaced id makes the prefix exact for this host. + pendingAsksRegistry.clearSessionsWithPrefix(`${REMOTE_SESSION_PREFIX}${hostKey}:`); }, }; } diff --git a/apps/desktop/electron/main/runtime/sidecar.ts b/apps/desktop/electron/main/runtime/sidecar.ts index 679362cbf5..45e90b91cd 100644 --- a/apps/desktop/electron/main/runtime/sidecar.ts +++ b/apps/desktop/electron/main/runtime/sidecar.ts @@ -24,6 +24,7 @@ import type { PluginRuntime } from "../plugin-runtime"; import type { RuntimeState } from "./context"; import type { FinishTurn } from "./plans"; import { formatSkillToolContent, type LoadedSkillDocument } from "../skill-document"; +import { pendingAsksRegistry } from "../pending-asks"; export type SidecarRuntimeDependencies = { runtimeState: RuntimeState; @@ -101,6 +102,7 @@ export function createSidecarRuntime({ // the current turn's state in Agent Host or the renderer. Persistence is a // separate call, so dropping it here still archives it as history. if (isStaleTerminalEvent(envelope)) return; + pendingAsksRegistry.ingest(envelope); runtimeState.agentHostBridge?.ingest(envelope); sendToRenderer(IPC.event.agentMessage, envelope); }; @@ -183,7 +185,9 @@ export function createSidecarRuntime({ if (method === "native.agent.event") { // Native AgentSession already persisted the event to its canonical Pi // JSONL. It owns neither the Desktop outbox nor Host queue/turn state. - sendToRenderer(IPC.event.agentMessage, params as AgentEventEnvelope); + const envelope = params as AgentEventEnvelope; + pendingAsksRegistry.ingest(envelope); + sendToRenderer(IPC.event.agentMessage, envelope); return; } if (method === "agent.event") { @@ -255,6 +259,13 @@ export function createSidecarRuntime({ activeToolCalls.clear(); runtimeState.sidecar = null; steeringReplies.clear(); + const crashedSessions = new Set([ + ...activeTurns.keys(), + ...interruptedToolCalls.map((tool) => tool.sessionId), + ]); + for (const sessionId of crashedSessions) { + pendingAsksRegistry.clearSession(sessionId); + } if (intentional || isQuitting()) return; for (const tool of interruptedToolCalls) { logger.app("tool", "error", "tool execution interrupted", { diff --git a/apps/desktop/test/mcp-control.test.mjs b/apps/desktop/test/mcp-control.test.mjs index 1a3c751e24..0cf538156e 100644 --- a/apps/desktop/test/mcp-control.test.mjs +++ b/apps/desktop/test/mcp-control.test.mjs @@ -74,6 +74,7 @@ const fixtureChannels = { sessionDelete: "pi-desktop/session/delete", sessionConfigure: "pi-desktop/session/configure", plansResolve: "pi-desktop/plans/resolve", + askToolPending: "pi-desktop/agent/askTool/pending", agentPrompt: "pi-desktop/agent/prompt", }; @@ -173,8 +174,29 @@ test("local MCP control server authenticates, discovers, and invokes desktop ope assert.ok(toolNames.includes("pi_control_describe")); assert.ok(toolNames.includes("pi_project_open")); assert.ok(toolNames.includes("pi_plans_resolve")); + assert.ok(toolNames.includes("pi_asktool_pending")); assert.ok(toolNames.includes("pi_session_configure")); + const pendingAsks = await post( + info.url, + info.token, + { + jsonrpc: "2.0", + id: 21, + method: "tools/call", + params: { + name: "pi_asktool_pending", + arguments: { sessionId: "session-1" }, + }, + }, + { "Mcp-Session-Id": sessionId }, + ); + assert.equal(pendingAsks.body.result.structuredContent.ok, true); + assert.deepEqual(calls.at(-1), { + channel: "pi-desktop/agent/askTool/pending", + args: [{ sessionId: "session-1" }], + }); + const opened = await post( info.url, info.token, diff --git a/apps/desktop/test/pending-asks.test.mjs b/apps/desktop/test/pending-asks.test.mjs new file mode 100644 index 0000000000..c19d2dd5bb --- /dev/null +++ b/apps/desktop/test/pending-asks.test.mjs @@ -0,0 +1,145 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { join, dirname } from "node:path"; +import { register } from "node:module"; +import { pathToFileURL, fileURLToPath } from "node:url"; + +const here = dirname(fileURLToPath(import.meta.url)); +register(pathToFileURL(join(here, "helpers/ts-import-hooks.mjs"))); +const { + MAX_PENDING_ASKS_PER_SESSION, + MAX_PENDING_ASK_SESSIONS, + createPendingAsksRegistry, +} = await import("../electron/main/pending-asks.ts"); + +function ask(sessionId, requestId, toolCallId, ts, question = requestId) { + return { + sessionId, + ts, + event: { + type: "asktool_request", + request: { + requestId, + sessionId, + toolCallId, + questions: [{ question, options: ["a", "b"] }], + }, + }, + }; +} + +function toolEnd(sessionId, toolCallId, ts = 999) { + return { sessionId, ts, event: { type: "tool_end", toolCallId, result: {} } }; +} + +function agentEnd(sessionId, ts = 1000) { + return { sessionId, ts, event: { type: "agent_end", messageIds: [] } }; +} + +test("pending ask registry dedupes, filters, clones and orders stably", () => { + const registry = createPendingAsksRegistry(); + registry.ingest(ask("s2", "r2", "t2", 20)); + const trimmed = ask(" s1 ", "r1", "t1", 10); + trimmed.event.request.sessionId = "s1"; + registry.ingest(trimmed); + registry.ingest(ask("s1", "r1", "t1", 99, "duplicate must not replace")); + + assert.deepEqual(registry.list().asks.map((entry) => entry.requestId), ["r1", "r2"]); + assert.deepEqual(registry.list("s1").asks.map((entry) => entry.requestId), ["r1"]); + assert.equal(registry.list("missing").kind, "none"); + + const listed = registry.list("s1"); + listed.asks[0].questions[0].question = "mutated"; + listed.asks[0].questions[0].options.push("mutated"); + assert.equal(registry.list("s1").asks[0].questions[0].question, "r1"); + assert.deepEqual(registry.list("s1").asks[0].questions[0].options, ["a", "b"]); + assert.equal(registry.list("s1").asks[0].receivedAt, 10); +}); + +test("malformed question shapes are ignored without throwing", () => { + const registry = createPendingAsksRegistry(); + for (const questions of [ + [null], + [{ question: "broken" }], + [{ question: "broken", options: "not-an-array" }], + [{ question: "broken", options: ["ok", 2] }], + ]) { + const envelope = ask("s1", `bad-${JSON.stringify(questions)}`, "t", 1); + envelope.event.request.questions = questions; + assert.doesNotThrow(() => registry.ingest(envelope)); + } + assert.equal(registry.list().kind, "none"); +}); + +test("tool_end removes one request and agent_end clears the session", () => { + const registry = createPendingAsksRegistry(); + registry.ingest(ask("s1", "r1", "t1", 1)); + registry.ingest(ask("s1", "r2", "t2", 2)); + registry.ingest(toolEnd("s1", "t1")); + assert.deepEqual(registry.list("s1").asks.map((entry) => entry.requestId), ["r2"]); + registry.ingest(agentEnd("s1")); + assert.deepEqual(registry.list("s1"), { kind: "none", asks: [] }); +}); + +test("identity mismatch is rejected and settlement stays session-scoped", () => { + const registry = createPendingAsksRegistry(); + const mismatched = ask("envelope-session", "bad", "t0", 0); + mismatched.event.request.sessionId = "request-session"; + registry.ingest(mismatched); + assert.equal(registry.list().kind, "none"); + + registry.ingest(ask("s1", "same", "t1", 1)); + registry.ingest(ask("s2", "same", "t2", 2)); + registry.settle("s1", "same"); + assert.equal(registry.list("s1").kind, "none"); + assert.equal(registry.list("s2").asks[0].requestId, "same"); + registry.clearSession("s2"); + assert.equal(registry.list().kind, "none"); +}); + +test("per-session queue is bounded and prunes oldest first", () => { + const registry = createPendingAsksRegistry(); + for (let index = 0; index < MAX_PENDING_ASKS_PER_SESSION + 3; index += 1) { + registry.ingest(ask("s1", `r${index}`, `t${index}`, index)); + } + const asks = registry.list("s1").asks; + assert.equal(asks.length, MAX_PENDING_ASKS_PER_SESSION); + assert.equal(asks[0].requestId, "r3"); + assert.equal(asks.at(-1).requestId, `r${MAX_PENDING_ASKS_PER_SESSION + 2}`); +}); + +test("the session-bucket count is bounded and evicts the oldest bucket first", () => { + const registry = createPendingAsksRegistry(); + const total = MAX_PENDING_ASK_SESSIONS + 5; + for (let index = 0; index < total; index += 1) { + registry.ingest(ask(`s${index}`, `r${index}`, `t${index}`, index)); + } + + // The newest bucket survives; the oldest ones are gone. + assert.equal(registry.list(`s${total - 1}`).kind, "pending"); + assert.equal(registry.list("s0").kind, "none"); + assert.equal(registry.list("s4").kind, "none"); + assert.equal(registry.list("s5").kind, "pending"); + assert.equal( + registry.list().asks.length, + MAX_PENDING_ASK_SESSIONS, + "an unfiltered listing must stay bounded too", + ); +}); + +test("clearSessionsWithPrefix drops exactly one host's buckets", () => { + const registry = createPendingAsksRegistry(); + registry.ingest(ask("remote:hostA:s1", "r1", "t1", 1)); + registry.ingest(ask("remote:hostA:s2", "r2", "t2", 2)); + registry.ingest(ask("remote:hostB:s1", "r3", "t3", 3)); + // A session whose id merely shares a prefix with another host's namespace must + // not be touched by that host's cleanup. + registry.ingest(ask("local:s1", "r4", "t4", 4)); + + assert.equal(registry.clearSessionsWithPrefix("remote:hostA:"), 2); + assert.equal(registry.list("remote:hostA:s1").kind, "none"); + assert.equal(registry.list("remote:hostA:s2").kind, "none"); + assert.equal(registry.list("remote:hostB:s1").kind, "pending"); + assert.equal(registry.list("local:s1").kind, "pending"); + assert.equal(registry.clearSessionsWithPrefix(""), 0, "an empty prefix clears nothing"); +}); diff --git a/apps/desktop/test/remote-event-bridge.test.mjs b/apps/desktop/test/remote-event-bridge.test.mjs index 9780472d55..46049358a1 100644 --- a/apps/desktop/test/remote-event-bridge.test.mjs +++ b/apps/desktop/test/remote-event-bridge.test.mjs @@ -14,6 +14,9 @@ const { makeRemoteApprovalRequestId, makeRemoteSessionId } = await import( const { createRemoteEventBridge } = await import( "../electron/main/remote/remote-event-bridge.ts" ); +const { pendingAsksRegistry } = await import( + "../electron/main/pending-asks.ts" +); const HOST_KEY = "hostA"; const HOST_SESSION_ID = "sess-1"; @@ -219,6 +222,32 @@ test("input.requested synthesizes an asktool_request keyed by the RACP input id" assert.equal(request.toolCallId, "tc-parent"); assert.equal(request.questions.length, 1); assert.equal(request.questions[0].multiSelect, false); + const pending = pendingAsksRegistry.list(REMOTE_SESSION_ID); + assert.equal(pending.kind, "pending"); + assert.equal(pending.asks[0].requestId, "input-42"); + assert.equal(pending.asks[0].sessionId, REMOTE_SESSION_ID); +}); + +test("top-level input uses its id as lifecycle key and input.resolved clears it", () => { + const { bridge } = collect(); + bridge.handle(makeEnvelope({ + kind: "input.requested", + payload: { + id: "top-level-input", + sessionId: HOST_SESSION_ID, + questions: [{ id: "q", question: "continue?", options: ["yes", "no"] }], + }, + })); + let pending = pendingAsksRegistry.list(REMOTE_SESSION_ID); + assert.equal(pending.asks.at(-1).requestId, "top-level-input"); + assert.equal(pending.asks.at(-1).toolCallId, "top-level-input"); + + bridge.handle(makeEnvelope({ + kind: "input.resolved", + payload: { inputId: "top-level-input" }, + })); + pending = pendingAsksRegistry.list(REMOTE_SESSION_ID); + assert.equal(pending.asks.some((ask) => ask.requestId === "top-level-input"), false); }); test("terminal and resync kinds are silently dropped in Stage 2 — later stages own them", () => { diff --git a/apps/desktop/test/remote-host-connection.test.mjs b/apps/desktop/test/remote-host-connection.test.mjs index f73254f99e..b869bad642 100644 --- a/apps/desktop/test/remote-host-connection.test.mjs +++ b/apps/desktop/test/remote-host-connection.test.mjs @@ -14,6 +14,7 @@ const { createBackendRouter, makeRemoteSessionId } = await import( const { createRemoteHostConnection } = await import( "../electron/main/remote/remote-host-connection.ts" ); +const { pendingAsksRegistry } = await import("../electron/main/pending-asks.ts"); const HOST_KEY = "hostA"; @@ -186,3 +187,32 @@ test("session/list failure leaves the connection registered for nothing but does ); assert.equal(client.hasListener(), true); }); + +test("close clears the disconnected host's pending asks", async () => { + // A host that goes away can never resolve its asks, and no later event will + // arrive to settle them: `input.resolved` / `session.archived` ride the same + // dead stream. Leaving them behind made a dead host's questions show up in + // unfiltered listings forever. + const remoteId = makeRemoteSessionId(HOST_KEY, "s1"); + const { conn, client } = setup({ sessions: [makeSession("s1")] }); + await conn.open(); + client.push( + makeEnvelope({ + sessionId: "s1", + kind: "input.requested", + payload: { + id: "ask-1", + questions: [{ question: "which one?", options: ["a", "b"] }], + }, + }), + ); + assert.equal(pendingAsksRegistry.list(remoteId).kind, "pending"); + + await conn.close(); + + assert.equal( + pendingAsksRegistry.list(remoteId).kind, + "none", + "a closed host must not leave pending asks behind", + ); +}); diff --git a/packages/shared/src/protocol.ts b/packages/shared/src/protocol.ts index 7313e9f1b9..86254c95ca 100644 --- a/packages/shared/src/protocol.ts +++ b/packages/shared/src/protocol.ts @@ -175,6 +175,8 @@ export const IPC = { scheduledExecute: "pi-desktop/scheduled/execute", scheduledListRuns: "pi-desktop/scheduled/listRuns", toolResolvePermission: "pi-desktop/tool/resolvePermission", + /** Process-memory registry of pending Agent asktool questions. */ + askToolPending: "pi-desktop/agent/askTool/pending", askToolResolve: "pi-desktop/agent/askTool/resolve", plansPending: "pi-desktop/plans/pending", plansResolve: "pi-desktop/plans/resolve", From 67ead61214578a37919480358b311396b00ee6f1 Mon Sep 17 00:00:00 2001 From: joyawang <13715852+JoyaWang@users.noreply.github.com> Date: Sat, 26 Sep 2026 09:57:41 +0800 Subject: [PATCH 2/2] docs(spec): register the pending-asktool read operation and answer the review Register what #1014 added and close the four review points: - 01-ipc-protocol.md (en + zh-CN): list the pi-desktop/agent/askTool/pending channel and pi_asktool_pending in the 13d tool list, and document the cross-session listing semantics (mirroring plans/pending) plus the registry's process-memory lifetime. - 04-e2e-test-plan.md (en + zh-CN): add E2E-MCP-pending-asktool-questions-are-readable after E2E-220 with a NOT RUN record (reason, alternative validation, remaining risk) and extend the traceability matrix row. - permission-inline.test.mjs: pin that a renderer ask with a non-empty toolCallId is cleared by its own tool_end, guarding the parentToolCallId ?? input.id fallback the review flagged. - native-pi-sessions / session-collaboration-ipc harnesses: declare the new ../pending-asks dependency the strict IPC whitelist load rejects. Validation: docs:check (81 pairs, 516 pages), the seven touched test files 80/80, lint:biome, typecheck desktop+shared, check:pr-base. Full desktop suite: 2922 pass, 3 pre-existing environment failures (browser-cdp, bundled-plugins, plugin-work-panel-views) that also fail on a clean upstream/main checkout. --- apps/desktop/test/native-pi-sessions.test.mjs | 3 ++ apps/desktop/test/permission-inline.test.mjs | 28 ++++++++++++ .../test/session-collaboration-ipc.test.mjs | 1 + docs/spec/03-runtime/01-ipc-protocol.md | 14 ++++++ docs/spec/06-delivery/04-e2e-test-plan.md | 45 ++++++++++++++++++- docs/zh-CN/spec/03-runtime/01-ipc-protocol.md | 10 +++++ .../spec/06-delivery/04-e2e-test-plan.md | 31 ++++++++++++- 7 files changed, 128 insertions(+), 4 deletions(-) diff --git a/apps/desktop/test/native-pi-sessions.test.mjs b/apps/desktop/test/native-pi-sessions.test.mjs index f2d7babe7d..aa4ae2d9ee 100644 --- a/apps/desktop/test/native-pi-sessions.test.mjs +++ b/apps/desktop/test/native-pi-sessions.test.mjs @@ -222,6 +222,9 @@ function forkHarness({ host, sidecar, activeTurns = new Map() }) { "../importers": { convertSession() {}, scanAllSources() {}, scanModelConfigs() {} }, "../services/session-collaboration": { readSessionCollaboration() {} }, "../services/session-search": { searchSessionsAcrossSources }, + // The session IPC also clears the session's pending asktool entries on + // delete; a no-op registry is enough for the fork contract under test. + "../pending-asks": { pendingAsksRegistry: { clearSession() {} } }, }); registerSessionIpc({ registrar: { handle: (channel, handler) => handlers.set(channel, handler) }, diff --git a/apps/desktop/test/permission-inline.test.mjs b/apps/desktop/test/permission-inline.test.mjs index 04ecd9e4cb..4140fb9876 100644 --- a/apps/desktop/test/permission-inline.test.mjs +++ b/apps/desktop/test/permission-inline.test.mjs @@ -157,6 +157,34 @@ test("asktool requests queue independently and never expire", () => { ); assert.deepEqual(Object.keys(clearSessionAsks(next, "session-a")), []); }); +test("asktool tool-row matching keys on the ask's own tool call, not an empty id", () => { + // A top-level remote ask used to arrive with `toolCallId: ""`, while the + // runtime's own asks always carry a non-empty id (the local runtime passes + // the asktool call's own id). Queue removal is an exact match on that id, and + // a real `tool_end` always carries a non-empty `toolCallId`, so an empty id + // could never be cleared by the event that actually ends the ask: the entry + // stayed queued until the session did. Pin the non-empty contract here so a + // future fallback cannot silently reintroduce the leak. + const topLevel = { + sessionId: "session-a", + requestId: "ask-remote", + toolCallId: "top-level-input", + questions: [{ question: "Color?", options: ["Blue"] }], + }; + const legacyEmpty = { ...topLevel, requestId: "ask-legacy", toolCallId: "" }; + const queues = enqueueAsk(enqueueAsk({}, topLevel), legacyEmpty); + + const afterOtherTool = removeAskForToolCall(queues, "session-a", "some-other-tool"); + assert.equal(afterOtherTool, queues, "an unrelated tool end removes nothing"); + + const afterOwnTool = removeAskForToolCall(queues, "session-a", "top-level-input"); + assert.equal(headAsk(afterOwnTool, "session-a"), legacyEmpty); + assert.equal( + afterOwnTool["session-a"].length, + 1, + "the non-empty ask is cleared by its own tool end", + ); +}); test("asktool card is a stepwise, non-expiring composer question surface", () => { assert.match(chatSurfaceSource, /pendingAsk/); diff --git a/apps/desktop/test/session-collaboration-ipc.test.mjs b/apps/desktop/test/session-collaboration-ipc.test.mjs index 9308aa8d99..30622ba4ac 100644 --- a/apps/desktop/test/session-collaboration-ipc.test.mjs +++ b/apps/desktop/test/session-collaboration-ipc.test.mjs @@ -35,6 +35,7 @@ const { registerSessionIpc } = load("../electron/main/ipc/session-ipc.ts", { "../importers": {}, "../services/session-collaboration": collaboration, "../services/session-search": { searchSessionsAcrossSources: async () => ({ hits: [], nextOffset: null }) }, + "../pending-asks": { pendingAsksRegistry: { clearSession() {} } }, }); const summary = { sessionId: "worker-session-id", diff --git a/docs/spec/03-runtime/01-ipc-protocol.md b/docs/spec/03-runtime/01-ipc-protocol.md index eaf3b4aa35..6898fc6943 100644 --- a/docs/spec/03-runtime/01-ipc-protocol.md +++ b/docs/spec/03-runtime/01-ipc-protocol.md @@ -50,6 +50,7 @@ Examples: - `pi-desktop/agent/stop` - `pi-desktop/agent/abort` - `pi-desktop/agent/event/message` +- `pi-desktop/agent/askTool/pending` - `pi-desktop/agent/askTool/resolve` - `pi-desktop/session/list` - `pi-desktop/session/summarizeTitle` @@ -2208,9 +2209,22 @@ The named tools cover the common Agent workflow: `pi_session_configure` - `pi_agent_prompt`, `pi_agent_status`, `pi_agent_stop`, `pi_agent_abort`, `pi_agent_compact` +- `pi_asktool_pending` - `pi_plans_pending`, `pi_plans_resolve` - `pi_workspace_diff`, `pi_fs_list`, `pi_fs_read` +`pi_asktool_pending` is the read side of `agent/askTool/resolve`. It returns the +Agent questions that are still waiting for an answer, with the `requestId` the +resolve operation needs. `sessionId` is an optional filter: without it the +operation lists asks from every session, including asks that belong to a paired +remote host (`remote::`), because a client that does not +yet know the session must still be able to discover what an Agent is waiting +for. This is the same cross-session shape `plans/pending` already has. The +registry is process memory only: it is never persisted, logged, or written into +the transcript, and an entry disappears when the question is resolved, the turn +ends, the session is deleted or archived, the sidecar crashes (clearing the +sessions it was running), or a remote host connection closes. + `pi_control_describe` returns the reviewed operation catalog. `pi_desktop_invoke` accepts an operation id and positional IPC arguments: diff --git a/docs/spec/06-delivery/04-e2e-test-plan.md b/docs/spec/06-delivery/04-e2e-test-plan.md index 78b0f169fd..cfeb28a256 100644 --- a/docs/spec/06-delivery/04-e2e-test-plan.md +++ b/docs/spec/06-delivery/04-e2e-test-plan.md @@ -8680,7 +8680,7 @@ must keep splitting are covered by `markdown-blocks.test.mjs`. | M6+ (Chat file references) | E2E-PLUGIN-file-view-collapse-persists | | M6+ (project folder roots) | E2E-PLUGIN-file-view-switches-folder-per-project | | Post-MVP | E2E-022A, E2E-022B, E2E-022C, E2E-024I, E2E-024J, E2E-024K, E2E-024L, E2E-024M (plugin roadmap R2/R3/R6) | -| Post-baseline local automation | E2E-220 | +| Post-baseline local automation | E2E-220, E2E-MCP-pending-asktool-questions-are-readable | | Post-MVP remote control | E2E-221, E2E-222, E2E-223, E2E-224, E2E-225, E2E-226, E2E-227, E2E-228, E2E-229, E2E-230, E2E-231, E2E-232 | | Trusted extensions (R7 v1) | E2E-DIALOG-long-text-boundaries, E2E-241, E2E-242, E2E-HOOKS-cancel-and-dispose, E2E-TRUSTED-EXTENSION-custom-agent-stream-and-binding, E2E-243, E2E-244, E2E-245, E2E-PLUGIN-imported-pi-package-skills, E2E-PLUGIN-import-extension-installs-dependencies, E2E-PLUGIN-import-extension-reports-missing-dependency, E2E-PLUGIN-declared-provider-appears-in-the-native-provider-list | | Trusted extensions (R7 v1 npm recovery) | E2E-PLUGIN-import-extension-recovers-missing-npm | @@ -12595,7 +12595,48 @@ are withdrawn with ADR 0165. - **Milestone**: M6+ - **Status**: MCP protocol/unit-covered by `apps/desktop/test/mcp-control.test.mjs`; full Electron journey documented and remains deferred by the no-local-E2E - policy + policy. The pending-question read side is covered by the scenario below. + +#### E2E-MCP-pending-asktool-questions-are-readable: A remote client discovers the pending question + +- **Preconditions**: Start PI-Desktop with `PI_DESKTOP_MCP_CONTROL=1` and a + clean profile, a configured model, and one Agent session. A prompt that makes + the Agent open an asktool question is available. +- **Steps**: 1) Prompt the Agent so it opens an asktool question and stays + waiting. 2) Read `mcp-control.json` and call `pi_asktool_pending` with no + `sessionId`. 3) Repeat with the waiting session's `sessionId`, then with an + unknown session id. 4) Call `pi_desktop_invoke` for the generic + `agent/askTool/pending` operation. 5) Answer the question through + `agent/askTool/resolve` and read again; repeat by stopping the turn instead. + 6) Archive the session, then read again. 7) Delete the session, then read + again. +- **Expected**: Step 1 leaves the Agent waiting on a question the desktop card + shows. Step 2 returns `kind: "pending"` with the question text, its options, + and the `requestId` the resolve operation needs; the listing spans every + session bucket, including a paired remote host's + (`remote::`), so a client that does not yet know the + session still discovers the request. Step 3 filters to exactly the named + session, and an unknown session id returns `kind: "none"` with an empty list + rather than an error. Step 4 returns the same payload through the reviewed + read catalog. Step 5 removes the entry for both an answered question and a + stopped turn, because neither can be answered afterwards. Steps 6 and 7 leave + no entry for that session. No listing returns an ask whose `sessionId` is not + the requested one, and `pi_control_describe` still omits secret-write, + native-picker, and `plugin/loadDev` channels. +- **Specs linked**: `03-runtime/01-ipc-protocol.md` §13d, + `03-runtime/17-asktool-questions.md`, `05-security/01-security.md`, ADR 0203, + D370 +- **Acceptance**: A (app control), C (conversation & stream), Security, Quality +- **Milestone**: M6+ +- **Status**: NOT RUN for the Electron journey above — it needs a running + desktop driving a live Agent turn, which the no-local-E2E policy defers. + Alternative validation: the registry lifecycle, the reviewed catalog entry, + and the remote ingestion path are unit-covered by + `apps/desktop/test/pending-asks.test.mjs`, + `apps/desktop/test/mcp-control.test.mjs`, and + `apps/desktop/test/remote-event-bridge.test.mjs`. Remaining risk is the HTTP + round trip and the live asktool prompt path, which the unit coverage does not + exercise. #### E2E-234: Workspace security denylist and ignore layers diff --git a/docs/zh-CN/spec/03-runtime/01-ipc-protocol.md b/docs/zh-CN/spec/03-runtime/01-ipc-protocol.md index 8aa9e0f16f..029ce5779c 100644 --- a/docs/zh-CN/spec/03-runtime/01-ipc-protocol.md +++ b/docs/zh-CN/spec/03-runtime/01-ipc-protocol.md @@ -52,6 +52,7 @@ event: pi-desktop//event/ - `pi-desktop/agent/steer` - `pi-desktop/agent/abort` - `pi-desktop/agent/event/message` +- `pi-desktop/agent/askTool/pending` - `pi-desktop/agent/askTool/resolve` - `pi-desktop/session/list` - `pi-desktop/project/open` @@ -1777,9 +1778,18 @@ Electron 等待主机关闭之前会停止服务,并将清单标记为非活 `pi_session_configure` - `pi_agent_prompt`、`pi_agent_status`、`pi_agent_stop`、`pi_agent_abort`、 `pi_agent_compact` +- `pi_asktool_pending` - `pi_plans_pending`、`pi_plans_resolve` - `pi_workspace_diff`、`pi_fs_list`、`pi_fs_read` +`pi_asktool_pending` 是 `agent/askTool/resolve` 的读取侧。它返回仍在等待回答的 +Agent 问题,并带上 resolve 操作所需的 `requestId`。`sessionId` 是可选过滤器: +不带它时,该操作会列出所有会话的问题,包括属于已配对远端主机的问题 +(`remote::`),因为尚未知道会话 id 的客户端仍须能够 +发现 Agent 正在等待什么。这与 `plans/pending` 已有的跨会话语义一致。注册表仅存在于 +进程内存中:绝不持久化、不写日志、不写入对话记录;问题被回答、回合结束、会话被删除或 +归档、sidecar 崩溃(清掉其正在运行的会话条目),或远端主机连接关闭时,对应条目即消失。 + `pi_control_describe` 返回经过审查的操作目录。`pi_desktop_invoke` 接受操作 id 和位置参数形式的 IPC 参数: diff --git a/docs/zh-CN/spec/06-delivery/04-e2e-test-plan.md b/docs/zh-CN/spec/06-delivery/04-e2e-test-plan.md index 5eb4f4533e..772bad08b2 100644 --- a/docs/zh-CN/spec/06-delivery/04-e2e-test-plan.md +++ b/docs/zh-CN/spec/06-delivery/04-e2e-test-plan.md @@ -5479,7 +5479,7 @@ eleven-tool-round desktop paths are verified by | M6+(聊天文件引用) | E2E-PLUGIN-file-view-collapse-persists | | M6+(项目文件夹根) | E2E-PLUGIN-file-view-switches-folder-per-project | | 后MVP | E2E-022A、E2E-022B、E2E-022C、E2E-024I、E2E-024J、E2E-024K、E2E-024L、E2E-024M(插件路线图 R2/R3/R6) | -| 基线后本地自动化 | E2E-220 | +| 基线后本地自动化 | E2E-220、E2E-MCP-pending-asktool-questions-are-readable | | MVP 后远程控制 | E2E-221、E2E-222、E2E-223、E2E-224、E2E-225、E2E-226、E2E-227、E2E-228、E2E-229、E2E-230、E2E-231、E2E-232 | | 受信任扩展(R7 v1) | E2E-DIALOG-long-text-boundaries、E2E-241、E2E-242、E2E-HOOKS-cancel-and-dispose、E2E-243、E2E-244、E2E-245、E2E-PLUGIN-imported-pi-package-skills、E2E-PLUGIN-import-extension-installs-dependencies、E2E-PLUGIN-import-extension-reports-missing-dependency、E2E-PLUGIN-declared-provider-appears-in-the-native-provider-list | | 受信任扩展(R7 v1 npm 恢复) | E2E-PLUGIN-import-extension-recovers-missing-npm | @@ -7446,7 +7446,34 @@ eleven-tool-round desktop paths are verified by - **验收**:A(应用控制)、C(会话)、安全、质量 - **里程碑**:M6+ - **状态**:由 `apps/desktop/test/mcp-control.test.mjs` 覆盖 MCP 协议/单元;完整 Electron - 旅程已记录,仍按策略延后 + 旅程已记录,仍按策略延后。待处理问题的读取侧由下方场景覆盖。 + +#### E2E-MCP-pending-asktool-questions-are-readable:远端客户端发现待处理问题 + +- **前提条件**:使用 `PI_DESKTOP_MCP_CONTROL=1` 和干净配置启动 PI-Desktop,已配置模型, + 并存在一个 Agent 会话。可用能让 Agent 打开 asktool 问题的提示词。 +- **步骤**:1)发送提示词,使 Agent 打开一个 asktool 问题并保持等待。2)读取 + `mcp-control.json`,不带 `sessionId` 调用 `pi_asktool_pending`。3)改用等待中会话的 + `sessionId` 重复,再用一个未知会话 id 重复。4)通过 `pi_desktop_invoke` 调用通用 + `agent/askTool/pending` 操作。5)通过 `agent/askTool/resolve` 回答问题后再读一次; + 改为停止回合再重复一次。6)归档该会话后再读一次。7)删除该会话后再读一次。 +- **预期**:步骤 1 后 Agent 停留在桌面卡片可见的问题上。步骤 2 返回 `kind: "pending"`, + 含问题文本、选项,以及 resolve 操作所需的 `requestId`;列表覆盖所有会话桶,包括已配对 + 远端主机的(`remote::`),因此尚不知道会话的客户端仍能发现该 + 请求。步骤 3 只过滤出指定会话;未知会话 id 返回 `kind: "none"` 和空列表,而不是报错。 + 步骤 4 通过已审查的只读目录返回同样的载荷。步骤 5 对已回答的问题和被停止的回合都会移除 + 该条目,因为二者之后都无法再回答。步骤 6 和步骤 7 之后该会话不再有条目。任何列表都不会 + 返回 `sessionId` 与请求不一致的问题,且 `pi_control_describe` 仍不包含密钥写入、 + 原生选择器和 `plugin/loadDev` 通道。 +- **链接规格**:`03-runtime/01-ipc-protocol.md` §13d、 + `03-runtime/17-asktool-questions.md`、`05-security/01-security.md`、ADR 0203、D370 +- **验收**:A(应用控制)、C(对话与流)、安全、质量 +- **里程碑**:M6+ +- **状态**:上述 Electron 旅程为 NOT RUN——它需要运行中的桌面驱动真实 Agent 回合,而 + no-local-E2E 策略将其延后。替代验证:注册表生命周期、已审查的目录条目和远端接入路径由 + `apps/desktop/test/pending-asks.test.mjs`、`apps/desktop/test/mcp-control.test.mjs` + 和 `apps/desktop/test/remote-event-bridge.test.mjs` 覆盖。剩余风险是 HTTP 往返和真实 + asktool 提示路径,单元覆盖未涉及这两者。 ## 受信任扩展场景(R7 v1)