diff --git a/apps/desktop/electron/main/mcp-control.ts b/apps/desktop/electron/main/mcp-control.ts index ed78970f2..4f270d049 100644 --- a/apps/desktop/electron/main/mcp-control.ts +++ b/apps/desktop/electron/main/mcp-control.ts @@ -527,12 +527,22 @@ export function stripSecretMaterial(value: unknown): unknown { return output; } -export function boundMcpResult(value: unknown): unknown { - let text: string; +function serializeMcpResult(value: unknown): string { try { - text = JSON.stringify(value ?? null); + return JSON.stringify(value ?? null); } catch { - text = JSON.stringify({ value: String(value) }); + return JSON.stringify({ value: String(value) }); + } +} + +export function boundMcpResult( + value: unknown, + projectOversized?: (value: unknown) => unknown, +): unknown { + let text = serializeMcpResult(value); + if (text.length > MAX_RESULT_CHARS && projectOversized) { + value = projectOversized(value); + text = serializeMcpResult(value); } if (text.length <= MAX_RESULT_CHARS) { try { @@ -548,6 +558,55 @@ export function boundMcpResult(value: unknown): unknown { }; } +/** Scalar compaction fields small enough to keep in a control-plane answer. */ +const COMPACTION_SCALAR_KEYS = [ + "id", + "firstKeptMessageId", + "throughMessageId", + "tokensBefore", + "providerId", + "modelId", + "createdAt", +] as const; + +/** + * Bounds the `session/get` answer for the control plane (mocode #495). + * + * A durable session's `ContextCompactionRecord` (`summary` / `retainedTail` / + * `details.modifiedFiles`) grows without bound: on a long session it alone can + * exceed {@link MAX_RESULT_CHARS}, so {@link boundMcpResult} replaced the WHOLE + * answer with a half-JSON `preview` and external clients (`pi_session_get`) + * could never reach `messages` — the phone reported it as an "unexpected + * format" and the session was unopenable. + * + * External clients only need the compact identity the tools contract promises + * (`compaction.createdAt` and `details.generation`), never the summary text, + * the retained tail, or the artifact list. Keep that whitelist and drop the + * rest, so the transcript survives bounding. Non-`session/get` shapes and + * sessions without a compaction record are returned untouched. + */ +export function projectSessionGetResult(value: unknown): unknown { + if (!value || typeof value !== "object" || Array.isArray(value)) return value; + const root = value as Record; + const session = root.session; + if (!session || typeof session !== "object" || Array.isArray(session)) return value; + const compaction = (session as Record).compaction; + if (!compaction || typeof compaction !== "object" || Array.isArray(compaction)) return value; + + const source = compaction as Record; + const bounded: Record = {}; + for (const key of COMPACTION_SCALAR_KEYS) { + if (source[key] !== undefined) bounded[key] = source[key]; + } + const details = source.details; + if (details && typeof details === "object" && !Array.isArray(details)) { + const generation = (details as Record).generation; + if (generation !== undefined) bounded.details = { generation }; + } + + return { ...root, session: { ...(session as Record), compaction: bounded } }; +} + function errorInfo(error: unknown): { code: string; message: string; details?: unknown } { const candidate = error as { code?: unknown; @@ -1099,7 +1158,13 @@ export class McpControlServer { const tool = this.toolsList.find((candidate) => candidate.name === name); if (!tool) return { response: rpcError(id, -32602, `unknown tool: ${name}`) }; try { - const value = boundMcpResult(await tool.execute(input)); + const raw = await tool.execute(input); + // Preserve ordinary session details; project oversized session/get + // compaction metadata only before falling back to the truncation envelope. + const value = boundMcpResult( + raw, + name === "pi_session_get" ? projectSessionGetResult : undefined, + ); return { response: response(id, { content: [{ type: "text", text: JSON.stringify(value) }], diff --git a/apps/desktop/test/mcp-control.test.mjs b/apps/desktop/test/mcp-control.test.mjs index e053d535d..acb5e1813 100644 --- a/apps/desktop/test/mcp-control.test.mjs +++ b/apps/desktop/test/mcp-control.test.mjs @@ -16,6 +16,7 @@ const { isLoopbackBindHost, mcpControlRendererEvent, negotiateMcpProtocolVersion, + projectSessionGetResult, stripSecretMaterial, tokensEqual, } = await import("../electron/main/mcp-control.ts"); @@ -81,6 +82,36 @@ test("local MCP control server authenticates, discovers, and invokes desktop ope const dataDir = mkdtempSync(join(tmpdir(), "pi-mcp-control-")); const calls = []; const events = []; + const sessionMessages = [ + { id: "m1", role: "user", content: "hello" }, + { id: "m2", role: "assistant", content: "hi" }, + ]; + const largeSession = { + session: { + id: "large-session", + compaction: { + id: "cp-large", + createdAt: "2026-10-03T07:37:04.094Z", + summary: "x".repeat(700_000), + retainedTail: [{ content: "large" }], + details: { generation: 13, modifiedFiles: ["src/a.ts"] }, + }, + messages: sessionMessages, + }, + }; + const smallSession = { + session: { + id: "small-session", + compaction: { + id: "cp-small", + createdAt: "2026-10-03T07:37:04.094Z", + summary: "Keep this summary", + retainedTail: [{ content: "Keep this reply" }], + details: { generation: 2, modifiedFiles: ["src/b.ts"] }, + }, + messages: sessionMessages, + }, + }; const server = new McpControlServer({ dataDir, port: 0, @@ -97,6 +128,10 @@ test("local MCP control server authenticates, discovers, and invokes desktop ope if (channel === "pi-desktop/session/create") { return { session: { id: "session-created", projectPath: args[0]?.projectPath ?? null } }; } + if (channel === "pi-desktop/session/get") { + if (args[0]?.id === largeSession.session.id) return largeSession; + if (args[0]?.id === smallSession.session.id) return smallSession; + } return { ok: true }; }, onOperationComplete: (operation, result, args) => { @@ -392,6 +427,39 @@ test("local MCP control server authenticates, discovers, and invokes desktop ope assert.equal(readSession.body.result.isError, undefined); assert.equal(events.filter(Boolean).length, refreshCount); + const readLargeSession = await post( + info.url, + info.token, + { + jsonrpc: "2.0", + id: 16, + method: "tools/call", + params: { name: "pi_session_get", arguments: { id: largeSession.session.id } }, + }, + { "Mcp-Session-Id": sessionId }, + ); + const largeResult = readLargeSession.body.result.structuredContent; + assert.equal(largeResult.truncated, undefined); + assert.deepEqual(largeResult.session.messages, sessionMessages); + assert.equal(largeResult.session.compaction.createdAt, largeSession.session.compaction.createdAt); + assert.equal(largeResult.session.compaction.details.generation, 13); + assert.equal(largeResult.session.compaction.summary, undefined); + assert.equal(largeResult.session.compaction.retainedTail, undefined); + assert.equal(largeResult.session.compaction.details.modifiedFiles, undefined); + + const readSmallSession = await post( + info.url, + info.token, + { + jsonrpc: "2.0", + id: 17, + method: "tools/call", + params: { name: "pi_session_get", arguments: { id: smallSession.session.id } }, + }, + { "Mcp-Session-Id": sessionId }, + ); + assert.deepEqual(readSmallSession.body.result.structuredContent, smallSession); + const missingPath = await post( info.url, info.token, @@ -506,6 +574,79 @@ test("control-plane helpers clamp protocol versions, strip secrets, and bound re assert.ok(JSON.stringify(bounded).length < 600_000); }); +test("session/get compaction metadata is projected before bounding (mocode #495)", () => { + const messages = [ + { id: "m1", role: "user", content: "hello" }, + { id: "m2", role: "assistant", content: "hi" }, + ]; + const huge = "x".repeat(700_000); + const raw = { + session: { + id: "s1", + title: "Long session", + modelId: "deepseek/deepseek-v4.1-flash", + mode: "agent", + messageStart: 0, + hasMoreBefore: false, + compaction: { + id: "cp1", + throughMessageId: "m900", + tokensBefore: 12_345, + createdAt: "2026-10-03T07:37:04.094Z", + summary: huge, + retainedTail: [{ huge }], + details: { generation: 13, modifiedFiles: ["a", "b", "c"] }, + providerId: "p1", + modelId: "deepseek/deepseek-v4.1-flash", + }, + messages, + }, + }; + + // Without the projection the whole answer is replaced by a half-JSON preview + // and `messages` becomes unreachable — exactly what the phone hit. + const unprojected = boundMcpResult(raw); + assert.equal(unprojected.truncated, true); + + const bounded = boundMcpResult(raw, projectSessionGetResult); + assert.notEqual(bounded.truncated, true, "projected answer must fit the limit"); + + const session = bounded.session; + assert.deepEqual(session.messages, messages, "messages must survive"); + assert.equal(session.title, "Long session"); + assert.equal(session.modelId, "deepseek/deepseek-v4.1-flash"); + // The compact identity external clients rely on is kept ... + assert.equal(session.compaction.createdAt, "2026-10-03T07:37:04.094Z"); + assert.equal(session.compaction.details.generation, 13); + // ... and the unbounded fields are dropped. + assert.equal(session.compaction.summary, undefined); + assert.equal(session.compaction.retainedTail, undefined); + assert.equal(session.compaction.details.modifiedFiles, undefined); +}); + +test("session/get projection leaves a small session untouched (mocode #495)", () => { + const raw = { + session: { + id: "s1", + compaction: { + id: "cp1", + createdAt: "t", + summary: "A small summary", + retainedTail: [{ role: "assistant", content: "A retained reply" }], + details: { generation: 1, modifiedFiles: ["src/a.ts"] }, + }, + messages: [], + }, + }; + assert.deepEqual(boundMcpResult(raw, projectSessionGetResult), raw); + // Non-session shapes and sessions without a compaction record pass through. + assert.deepEqual(boundMcpResult({ ok: true }, projectSessionGetResult), { ok: true }); + assert.deepEqual( + boundMcpResult({ session: { id: "s2" } }, projectSessionGetResult), + { session: { id: "s2" } }, + ); +}); + test("renderer refresh events fire only for mutating control operations", () => { const sessionOp = (id) => ({ id, diff --git a/docs/spec/03-runtime/01-ipc-protocol.md b/docs/spec/03-runtime/01-ipc-protocol.md index 84ec09552..98d7aa37f 100644 --- a/docs/spec/03-runtime/01-ipc-protocol.md +++ b/docs/spec/03-runtime/01-ipc-protocol.md @@ -2277,7 +2277,18 @@ generic operations and the named session-delete, session-configure, and plan-resolution tools require `confirm: true`. That flag is an agent acknowledgement, not a desktop user prompt. All calls still pass through the existing IPC handler validation, host permissions, workspace boundaries, and -error model. Both the text payload and `structuredContent` are size-bounded. +error model. Both the text payload and `structuredContent` are size-bounded to +512 KiB (`MAX_RESULT_CHARS`). A larger answer is not returned verbatim: it is +replaced by `{truncated: true, reason: "MCP_RESULT_LIMIT", preview: ""}`, so an external caller can never receive a silently +shortened payload. If an oversized answer comes from `session/get` +(`pi_session_get`) and has a `compaction` record, Main projects that record to +the compact identity (`createdAt` and `details.generation`) and checks the size +again before returning the truncation envelope. This lets a long session's +transcript survive when its unbounded `ContextCompactionRecord` (`summary` / +`retainedTail` / `details.modifiedFiles`) alone caused the overflow. Results +already under the limit retain their full compaction details, and the desktop's +own session detail is unchanged. The six `session/collaboration/*` operations are first-party-plugin-only: they require an authenticated plugin tool invocation context, so they appear in diff --git a/docs/spec/06-delivery/04-e2e-test-plan.md b/docs/spec/06-delivery/04-e2e-test-plan.md index de013f2b5..fd9539868 100644 --- a/docs/spec/06-delivery/04-e2e-test-plan.md +++ b/docs/spec/06-delivery/04-e2e-test-plan.md @@ -9397,6 +9397,7 @@ must keep splitting are covered by `markdown-blocks.test.mjs`. | 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 (MCP `pi_session_get` large compaction) | E2E-MCP-session-get-projects-large-compaction | | 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-prompt-chain, 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 | @@ -13433,6 +13434,29 @@ are withdrawn with ADR 0165. full Electron journey documented and remains deferred by the no-local-E2E policy +#### E2E-MCP-session-get-projects-large-compaction + +- **Preconditions**: Start PI-Desktop with `PI_DESKTOP_MCP_CONTROL=1`. A durable + session exists whose `session.compaction` record (`summary` / `retainedTail` / + `details.modifiedFiles`) alone serializes to more than the 512 KiB MCP result + limit. +- **Steps**: 1) Read `mcp-control.json`, use its URL and bearer token, and + complete the MCP handshake. 2) Call `pi_session_get` for that session with any + `messageLimit` / `contentLimit` / `messageBefore`. 3) Inspect + `structuredContent`. 4) Repeat for a small session. +- **Expected**: The answer is not the `{truncated: true, reason: + "MCP_RESULT_LIMIT", preview}` envelope; `session.messages` carries the + requested transcript page; `session.compaction` keeps `createdAt` and + `details.generation` while `summary`, `retainedTail`, and + `details.modifiedFiles` are absent. The small session's answer is unchanged. +- **Specs linked**: `03-runtime/01-ipc-protocol.md` §13d +- **Acceptance**: C (sessions), Quality +- **Milestone**: M6+ +- **Status**: The local MCP server contract test in + `apps/desktop/test/mcp-control.test.mjs` exercises authenticated JSON-RPC + `tools/call` for both oversized and under-limit `pi_session_get` results. The + separate full Electron-to-Host journey remains release qualification. + #### E2E-234: Workspace security denylist and ignore layers - **Preconditions**: A project containing `.env`, `.env.example`, 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 ce9976727..c5682c4a1 100644 --- a/docs/zh-CN/spec/03-runtime/01-ipc-protocol.md +++ b/docs/zh-CN/spec/03-runtime/01-ipc-protocol.md @@ -1798,7 +1798,14 @@ Electron 等待主机关闭之前会停止服务,并将清单标记为非活 `read`、`write` 或 `dangerous`;通用危险操作,以及命名的删除会话、配置会话和决议 计划工具,都要求 `confirm: true`。该标志是 Agent 确认,不是桌面用户弹窗。所有调用 仍会经过现有 IPC 处理器的校验、主机权限、工作区边界和错误模型。文本负载和 -`structuredContent` 都有大小上限。 +`structuredContent` 都有大小上限:512 KiB(`MAX_RESULT_CHARS`)。超出上限的答复不会被 +原样返回,而是替换为 `{truncated: true, reason: "MCP_RESULT_LIMIT", preview: ""}`,因此外部调用方永远不会收到被静默缩短的负载。如果超限答复来自 +`session/get`(`pi_session_get`)且含有 `compaction` 记录,Main 会先将该记录投影为精简身份 +(`createdAt` 与 `details.generation`),再复查大小,然后才返回截断信封。这样,当长会话中 +无界增长的 `ContextCompactionRecord`(`summary` / `retainedTail` / `details.modifiedFiles`) +本身导致超限时,转写仍可完整返回。未超限的答复保留完整 compaction 详情;桌面自己的会话详情 +保持不变。 六个 `session/collaboration/*` 操作仅限第一方插件:它们要求经过认证的插件工具调用上下文, 因此会出现在 `pi.desktop.listOperations` 中并可通过 `pi.desktop.invoke` 调用,但被排除在 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 abaecc5f1..0831bdb1f 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 @@ -5642,6 +5642,7 @@ eleven-tool-round desktop paths are verified by | 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 | +| 基线后本地自动化(MCP `pi_session_get` 超大 compaction) | E2E-MCP-session-get-projects-large-compaction | | 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 | @@ -7642,6 +7643,25 @@ eleven-tool-round desktop paths are verified by - **状态**:由 `apps/desktop/test/mcp-control.test.mjs` 覆盖 MCP 协议/单元;完整 Electron 旅程已记录,仍按策略延后 +#### E2E-MCP-session-get-projects-large-compaction:超大 compaction 记录不再让 pi_session_get 整包截断 + +- **前提条件**:使用 `PI_DESKTOP_MCP_CONTROL=1` 启动 PI-Desktop。存在一个持久会话,其 + `session.compaction` 记录(`summary` / `retainedTail` / `details.modifiedFiles`)单独 + 序列化即超过 512 KiB 的 MCP 结果上限。 +- **步骤**:1)读取 `mcp-control.json`,用其 URL 和 bearer token 完成 MCP 握手。2)对该会话 + 调用 `pi_session_get`,`messageLimit` / `contentLimit` / `messageBefore` 取任意值。3)检查 + `structuredContent`。4)对一个普通小会话重复。 +- **预期**:答复不是 `{truncated: true, reason: "MCP_RESULT_LIMIT", preview}` 信封; + `session.messages` 带回请求的转写页;`session.compaction` 保留 `createdAt` 与 + `details.generation`,而 `summary`、`retainedTail`、`details.modifiedFiles` 不存在。 + 小会话的答复不变。 +- **链接规格**:`03-runtime/01-ipc-protocol.md` §13d +- **验收**:C(会话)、质量 +- **里程碑**:M6+ +- **状态**:`apps/desktop/test/mcp-control.test.mjs` 中的本地 MCP Server 合约测试会运行带认证的 + JSON-RPC `tools/call`,分别验证超限和未超限的 `pi_session_get` 答复。完整 Electron 到 Host + 旅程仍属于发布验收。 + ## 受信任扩展场景(R7 v1) 以下场景是 D387 / ADR 0214 与 `07-plugins/16-trusted-extensions.md` 的验收目标;无头