Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 56 additions & 1 deletion apps/desktop/electron/main/mcp-control.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>;
const session = root.session;
if (!session || typeof session !== "object" || Array.isArray(session)) return value;
const compaction = (session as Record<string, unknown>).compaction;
if (!compaction || typeof compaction !== "object" || Array.isArray(compaction)) return value;

const source = compaction as Record<string, unknown>;
const bounded: Record<string, unknown> = {};
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<string, unknown>).generation;
if (generation !== undefined) bounded.details = { generation };
}

return { ...root, session: { ...(session as Record<string, unknown>), compaction: bounded } };
}

function errorInfo(error: unknown): { code: string; message: string; details?: unknown } {
const candidate = error as {
code?: unknown;
Expand Down Expand Up @@ -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) }],
Expand Down
67 changes: 67 additions & 0 deletions apps/desktop/test/mcp-control.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ const {
isLoopbackBindHost,
mcpControlRendererEvent,
negotiateMcpProtocolVersion,
projectSessionGetResult,
stripSecretMaterial,
tokensEqual,
} = await import("../electron/main/mcp-control.ts");
Expand Down Expand Up @@ -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,
Expand Down
12 changes: 11 additions & 1 deletion docs/spec/03-runtime/01-ipc-protocol.md
Original file line number Diff line number Diff line change
Expand Up @@ -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: "<the first
512 KiB of the JSON>"}`, 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
Expand Down
24 changes: 24 additions & 0 deletions docs/spec/06-delivery/04-e2e-test-plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down Expand Up @@ -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`,
Expand Down
8 changes: 7 additions & 1 deletion docs/zh-CN/spec/03-runtime/01-ipc-protocol.md
Original file line number Diff line number Diff line change
Expand Up @@ -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: "<JSON 前
512 KiB>"}`,因此外部调用方永远不会收到被静默缩短的负载。`session/get`
(`pi_session_get`)在设限前还会把会话的 `compaction` 记录投影为精简身份(`createdAt`
与 `details.generation`):长会话的 `ContextCompactionRecord`(`summary` /
`retainedTail` / `details.modifiedFiles`)会无界增长,否则会把整包答复——包括
`messages`——推过上限,令外部调用方读不到任何转写。

六个 `session/collaboration/*` 操作仅限第一方插件:它们要求经过认证的插件工具调用上下文,
因此会出现在 `pi.desktop.listOperations` 中并可通过 `pi.desktop.invoke` 调用,但被排除在
Expand Down
21 changes: 21 additions & 0 deletions docs/zh-CN/spec/06-delivery/04-e2e-test-plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down Expand Up @@ -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` 的验收目标;无头
Expand Down
Loading