From 5a12742d2598cfa359c342175713ff10205397a0 Mon Sep 17 00:00:00 2001 From: joyawang <13715852+JoyaWang@users.noreply.github.com> Date: Sat, 3 Oct 2026 21:30:36 +0800 Subject: [PATCH 1/3] fix(mcp): bound session/get compaction metadata so long sessions stay readable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A durable session's ContextCompactionRecord (summary / retainedTail / details.modifiedFiles) grows without bound; on a long session it alone exceeds MAX_RESULT_CHARS (512 KiB), so boundMcpResult replaced the WHOLE answer with a half-JSON preview and pi_session_get could never reach messages — external clients (e.g. the phone) reported a generic failure and the session was unopenable. Project the compaction record to the compact control-plane shape the tools contract promises (createdAt + details.generation) before bounding, so the transcript survives. Only the external MCP answer is projected; the desktop's own session detail is untouched. Ref: mocode #495 (client-side honest error) / mocode #497 (this root fix). --- apps/desktop/electron/main/mcp-control.ts | 57 +++++++++++++++- apps/desktop/test/mcp-control.test.mjs | 67 +++++++++++++++++++ docs/spec/03-runtime/01-ipc-protocol.md | 12 +++- docs/spec/06-delivery/04-e2e-test-plan.md | 24 +++++++ docs/zh-CN/spec/03-runtime/01-ipc-protocol.md | 8 ++- .../spec/06-delivery/04-e2e-test-plan.md | 21 ++++++ 6 files changed, 186 insertions(+), 3 deletions(-) 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 5aadef689..f40c85713 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 ca7583835..b4776ed0d 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` 的验收目标;无头 From 5b04e47751c2483cfcef56b38c94512079a47baa Mon Sep 17 00:00:00 2001 From: vastsa Date: Sat, 3 Oct 2026 22:35:00 +0800 Subject: [PATCH 2/3] fix(mcp): preserve small session/get details Apply the compaction projection only when session/get already exceeds the MCP result budget, then recheck the projected answer before truncating. This keeps the original details of ordinary sessions intact while allowing oversized transcript responses to survive. Exercise the real authenticated tools/call response for oversized and under-limit sessions so the public MCP boundary proves both behaviors. --- apps/desktop/electron/main/mcp-control.ts | 26 ++++-- apps/desktop/test/mcp-control.test.mjs | 88 +++++++++++++++++-- docs/spec/03-runtime/01-ipc-protocol.md | 15 ++-- docs/zh-CN/spec/03-runtime/01-ipc-protocol.md | 11 +-- 4 files changed, 113 insertions(+), 27 deletions(-) diff --git a/apps/desktop/electron/main/mcp-control.ts b/apps/desktop/electron/main/mcp-control.ts index 63068789d..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 { @@ -1149,11 +1159,11 @@ export class McpControlServer { if (!tool) return { response: rpcError(id, -32602, `unknown tool: ${name}`) }; try { 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). + // Preserve ordinary session details; project oversized session/get + // compaction metadata only before falling back to the truncation envelope. const value = boundMcpResult( - name === "pi_session_get" ? projectSessionGetResult(raw) : raw, + raw, + name === "pi_session_get" ? projectSessionGetResult : undefined, ); return { response: response(id, { diff --git a/apps/desktop/test/mcp-control.test.mjs b/apps/desktop/test/mcp-control.test.mjs index 6e89ab451..acb5e1813 100644 --- a/apps/desktop/test/mcp-control.test.mjs +++ b/apps/desktop/test/mcp-control.test.mjs @@ -82,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, @@ -98,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) => { @@ -393,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, @@ -541,8 +608,7 @@ test("session/get compaction metadata is projected before bounding (mocode #495) const unprojected = boundMcpResult(raw); assert.equal(unprojected.truncated, true); - const projected = projectSessionGetResult(raw); - const bounded = boundMcpResult(projected); + const bounded = boundMcpResult(raw, projectSessionGetResult); assert.notEqual(bounded.truncated, true, "projected answer must fit the limit"); const session = bounded.session; @@ -562,15 +628,23 @@ test("session/get projection leaves a small session untouched (mocode #495)", () const raw = { session: { id: "s1", - compaction: { createdAt: "t", details: { generation: 1 } }, + compaction: { + id: "cp1", + createdAt: "t", + summary: "A small summary", + retainedTail: [{ role: "assistant", content: "A retained reply" }], + details: { generation: 1, modifiedFiles: ["src/a.ts"] }, + }, messages: [], }, }; - const projected = projectSessionGetResult(raw); - assert.deepEqual(projected.session.compaction, raw.session.compaction); + assert.deepEqual(boundMcpResult(raw, projectSessionGetResult), raw); // 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" } }); + 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", () => { diff --git a/docs/spec/03-runtime/01-ipc-protocol.md b/docs/spec/03-runtime/01-ipc-protocol.md index 1ca52e571..98d7aa37f 100644 --- a/docs/spec/03-runtime/01-ipc-protocol.md +++ b/docs/spec/03-runtime/01-ipc-protocol.md @@ -2281,13 +2281,14 @@ 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. +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/zh-CN/spec/03-runtime/01-ipc-protocol.md b/docs/zh-CN/spec/03-runtime/01-ipc-protocol.md index 2cc1cefae..c5682c4a1 100644 --- a/docs/zh-CN/spec/03-runtime/01-ipc-protocol.md +++ b/docs/zh-CN/spec/03-runtime/01-ipc-protocol.md @@ -1800,11 +1800,12 @@ Electron 等待主机关闭之前会停止服务,并将清单标记为非活 仍会经过现有 IPC 处理器的校验、主机权限、工作区边界和错误模型。文本负载和 `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`——推过上限,令外部调用方读不到任何转写。 +512 KiB>"}`,因此外部调用方永远不会收到被静默缩短的负载。如果超限答复来自 +`session/get`(`pi_session_get`)且含有 `compaction` 记录,Main 会先将该记录投影为精简身份 +(`createdAt` 与 `details.generation`),再复查大小,然后才返回截断信封。这样,当长会话中 +无界增长的 `ContextCompactionRecord`(`summary` / `retainedTail` / `details.modifiedFiles`) +本身导致超限时,转写仍可完整返回。未超限的答复保留完整 compaction 详情;桌面自己的会话详情 +保持不变。 六个 `session/collaboration/*` 操作仅限第一方插件:它们要求经过认证的插件工具调用上下文, 因此会出现在 `pi.desktop.listOperations` 中并可通过 `pi.desktop.invoke` 调用,但被排除在 From a19a960e4d102d718a2c1ed9808d9d0ba07b7fac Mon Sep 17 00:00:00 2001 From: vastsa Date: Sat, 3 Oct 2026 22:37:30 +0800 Subject: [PATCH 3/3] docs(mcp): record session result contract coverage The test now exercises the authenticated local MCP JSON-RPC boundary for large and small session/get responses. Keep the E2E plan accurate about that coverage and the remaining full Electron-to-Host qualification. --- docs/spec/06-delivery/04-e2e-test-plan.md | 8 ++++---- docs/zh-CN/spec/06-delivery/04-e2e-test-plan.md | 7 +++---- 2 files changed, 7 insertions(+), 8 deletions(-) diff --git a/docs/spec/06-delivery/04-e2e-test-plan.md b/docs/spec/06-delivery/04-e2e-test-plan.md index f40c85713..152acd9cc 100644 --- a/docs/spec/06-delivery/04-e2e-test-plan.md +++ b/docs/spec/06-delivery/04-e2e-test-plan.md @@ -13426,10 +13426,10 @@ are withdrawn with ADR 0165. - **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 +- **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 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 b4776ed0d..ba762f2e7 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 @@ -7648,10 +7648,9 @@ eleven-tool-round desktop paths are verified by - **链接规格**:`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 策略延后 +- **状态**:`apps/desktop/test/mcp-control.test.mjs` 中的本地 MCP Server 合约测试会运行带认证的 + JSON-RPC `tools/call`,分别验证超限和未超限的 `pi_session_get` 答复。完整 Electron 到 Host + 旅程仍属于发布验收。 ## 受信任扩展场景(R7 v1)