diff --git a/apps/desktop/electron/main/mcp-control.ts b/apps/desktop/electron/main/mcp-control.ts index ed78970f2..63068789d 100644 --- a/apps/desktop/electron/main/mcp-control.ts +++ b/apps/desktop/electron/main/mcp-control.ts @@ -548,6 +548,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 +1148,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); + // `pi_session_get` can carry an unbounded compaction record; project it + // to the compact control-plane shape before bounding so the transcript + // survives (mocode #495). + const value = boundMcpResult( + name === "pi_session_get" ? projectSessionGetResult(raw) : raw, + ); 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..6e89ab451 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"); @@ -506,6 +507,72 @@ 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 projected = projectSessionGetResult(raw); + const bounded = boundMcpResult(projected); + 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: { createdAt: "t", details: { generation: 1 } }, + messages: [], + }, + }; + const projected = projectSessionGetResult(raw); + assert.deepEqual(projected.session.compaction, raw.session.compaction); + // Non-session shapes and sessions without a compaction record pass through. + assert.deepEqual(projectSessionGetResult({ ok: true }), { ok: true }); + assert.deepEqual(projectSessionGetResult({ session: { id: "s2" } }), { 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..1ca52e571 100644 --- a/docs/spec/03-runtime/01-ipc-protocol.md +++ b/docs/spec/03-runtime/01-ipc-protocol.md @@ -2277,7 +2277,17 @@ 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. `session/get` (`pi_session_get`) additionally projects the +session's `compaction` record down to the compact identity (`createdAt`, and +`details.generation`) before bounding: a long session's +`ContextCompactionRecord` (`summary` / `retainedTail` / `details.modifiedFiles`) +grows without bound and would otherwise push the whole answer — including +`messages` — over the limit, leaving external callers unable to read any +transcript. 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 62e544ba0..8d6f5bbbb 100644 --- a/docs/spec/06-delivery/04-e2e-test-plan.md +++ b/docs/spec/06-delivery/04-e2e-test-plan.md @@ -9371,6 +9371,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 | @@ -13407,6 +13408,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**: Unit-covered by `apps/desktop/test/mcp-control.test.mjs` + (`session/get compaction metadata is projected before bounding`, + `session/get projection leaves a small session untouched`); the full Electron + journey remains deferred by the no-local-E2E policy + #### 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..2cc1cefae 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,13 @@ 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` 记录投影为精简身份(`createdAt` +与 `details.generation`):长会话的 `ContextCompactionRecord`(`summary` / +`retainedTail` / `details.modifiedFiles`)会无界增长,否则会把整包答复——包括 +`messages`——推过上限,令外部调用方读不到任何转写。 六个 `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 93c998360..8acfddb16 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 @@ -5632,6 +5632,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 | @@ -7632,6 +7633,26 @@ 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` 单元覆盖 + (`session/get compaction metadata is projected before bounding`、 + `session/get projection leaves a small session untouched`);完整 Electron 旅程仍按 + 无本地 E2E 策略延后 + ## 受信任扩展场景(R7 v1) 以下场景是 D387 / ADR 0214 与 `07-plugins/16-trusted-extensions.md` 的验收目标;无头