diff --git a/src/extensions/paimon/compact_abort_patch.ts b/src/extensions/paimon/compact_abort_patch.ts new file mode 100644 index 0000000..4d28cc6 --- /dev/null +++ b/src/extensions/paimon/compact_abort_patch.ts @@ -0,0 +1,61 @@ +/** + * Compaction Abort Patch — 截获 AgentSession 实例以调用 abortCompaction() + * + * ## 背景 + * pi SDK 的 Extension API 只暴露了通用的 `ctx.abort()`(中止 agent streaming), + * 而上下文压缩使用独立的 `session.abortCompaction()` 方法取消。 + * Extension context 未暴露该方法,导致 paimon 无法在 compact 期间提供终止能力。 + * + * ## 原理 + * Monkey-patch `AgentSession.prototype.compact`: + * 1. compact 被调用时保存 `this`(AgentSession 实例)引用 + * 2. compact 结束后清除引用 + * 3. 外部调用 `abortCompaction()` 时,转发到保存的 session 实例 + * + * ## 兼容性 + * 依赖 `AgentSession` 从 `@earendil-works/pi-coding-agent` 公开导出, + * 以及 `compact` / `abortCompaction` 方法签名不变。 + * 如 patch 失败,compact 取消功能降级为不可用(不影响其他功能)。 + */ + +import { AgentSession } from "@earendil-works/pi-coding-agent"; + +// ─── 内部状态 ───────────────────────────────────────────────── + +let _session: any = null; +let _patched = false; + +// ─── 对外 API ───────────────────────────────────────────────── + +/** 安装 patch。应在 extension 加载时调用一次。返回是否成功。 */ +export function install(): boolean { + if (_patched) return true; + try { + const proto = AgentSession.prototype as any; + const origCompact = proto.compact; + if (typeof origCompact !== "function") return false; + + proto.compact = async function (...args: any[]) { + _session = this; + try { + return await origCompact.apply(this, args); + } finally { + _session = null; + } + }; + + _patched = true; + return true; + } catch { + return false; + } +} + +/** 尝试终止正在进行的 compaction。返回是否成功调用。 */ +export function abortCompaction(): boolean { + if (_session && typeof _session.abortCompaction === "function") { + _session.abortCompaction(); + return true; + } + return false; +} diff --git a/src/extensions/paimon/index.ts b/src/extensions/paimon/index.ts index a189db0..7c30ed1 100644 --- a/src/extensions/paimon/index.ts +++ b/src/extensions/paimon/index.ts @@ -20,12 +20,16 @@ import type { import { HubClient } from "./client"; import { serializeEvent, FORWARDED_EVENTS } from "./serializer"; import * as sessionControlPatch from "./session_control_patch"; +import * as compactionAbortPatch from "./compact_abort_patch"; import { querySessionList, emptySessionListMessage } from "./session_list"; // Hub spawn 实例时注入的一次性 token(仅页面创建的实例有)。 // 注册时回传给 Hub,用于将 spawn 请求与注册成功的实例对应起来。 const SPAWN_TOKEN = process.env.PAIMON_SPAWN_TOKEN; +// 跟踪 compaction 状态(兜底用) +let compacting = false; + export default function (pi: ExtensionAPI) { // pi extension 连接本机 Edge(不再直连 Hub) const port = parseInt( @@ -36,11 +40,11 @@ export default function (pi: ExtensionAPI) { let registered = false; // 保存最新的 ctx 引用,用于响应 get_history let currentCtx: ExtensionContext | null = null; - // 跟踪 compaction 状态(兜底用) - let compacting = false; // 安装 session 控制 patch(截获 newSession/switchSession 函数引用) sessionControlPatch.install(); + // 安装 compaction abort patch(截获 session 实例以支持取消压缩) + compactionAbortPatch.install(); const currentHostname = hostname(); @@ -316,8 +320,13 @@ function handleHubMessage( pi.sendUserMessage(msg.payload.message, { deliverAs: "steer" }); break; case "abort": { - const ctx = getCurrentCtx(); - ctx?.abort(); + if (compacting) { + // compacting 状态下取消压缩 + compactionAbortPatch.abortCompaction(); + } else { + const ctx = getCurrentCtx(); + ctx?.abort(); + } break; } case "set_model": { diff --git a/src/web/src/components/InstanceView/Composer.test.tsx b/src/web/src/components/InstanceView/Composer.test.tsx index 6520d0e..bd473c3 100644 --- a/src/web/src/components/InstanceView/Composer.test.tsx +++ b/src/web/src/components/InstanceView/Composer.test.tsx @@ -51,7 +51,7 @@ describe("ComposerStatusIndicator", () => { expect(getComposerButtonMode("streaming")).toBe("stop"); }); - test("shows send button during compacting", () => { - expect(getComposerButtonMode("compacting")).toBe("send"); + test("shows stop button during compacting to allow abort", () => { + expect(getComposerButtonMode("compacting")).toBe("stop"); }); }); diff --git a/src/web/src/components/InstanceView/utils.ts b/src/web/src/components/InstanceView/utils.ts index 7331707..10ed434 100644 --- a/src/web/src/components/InstanceView/utils.ts +++ b/src/web/src/components/InstanceView/utils.ts @@ -1,7 +1,6 @@ // InstanceView 纯计算工具函数 import type { InstanceStatus } from "../../../../protocol/types"; -import { isStreaming as isStatusStreaming } from "../../utils/status"; const BOTTOM_FOLLOW_EPSILON = 0.5; @@ -40,7 +39,10 @@ export function calculatePrependScrollTop({ } export function getComposerButtonMode(instanceStatus?: InstanceStatus) { - return isStatusStreaming(instanceStatus) ? "stop" : "send"; + // streaming 和 compacting 状态都显示 stop 按钮,允许用户终止 + return instanceStatus === "streaming" || instanceStatus === "compacting" + ? "stop" + : "send"; } export function getSafeScrollTop(rawScrollTop: number) {