@zhushanwen/pi-smart-context 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +22 -0
- package/index.ts +1 -0
- package/package.json +54 -0
- package/skills/smart-context-ext-config/SKILL.md +56 -0
- package/src/__tests__/compact-handler.test.ts +141 -0
- package/src/__tests__/pure.test.ts +144 -0
- package/src/__tests__/reminder.test.ts +83 -0
- package/src/__tests__/sdk-contract.test.ts +66 -0
- package/src/__tests__/tool.test.ts +144 -0
- package/src/compact-handler.ts +321 -0
- package/src/index.ts +160 -0
- package/src/llm.ts +139 -0
- package/src/prompts.ts +70 -0
- package/src/pure.ts +311 -0
- package/src/reminder.ts +73 -0
- package/src/tool.ts +206 -0
- package/vitest.config.ts +7 -0
package/README.md
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# @zhushanwen/pi-smart-context
|
|
2
|
+
|
|
3
|
+
智能上下文压缩 pi extension:把 compact 时机交给 agent 自决,压缩生成用双模式接管(same-model KV 缓存命中 / cross-model 廉价模型),3 档阈值提醒不强制。
|
|
4
|
+
|
|
5
|
+
## 功能
|
|
6
|
+
|
|
7
|
+
- **`compact_context` 工具**:agent 在「任务阶段性完成 && 压缩不影响后续 && 上下文超阈值」三条件同时满足时自决调用;带最低档阈值保护
|
|
8
|
+
- **双模式生成接管**(`session_before_compact`,覆盖工具 / `/compact` / 内建 auto 三条路径):
|
|
9
|
+
- same-model:完整原始上下文 + 会话原 system prompt + tools + 末尾追加压缩指令——前缀缓存全命中,成本≈增量指令+输出,且模型看全量上下文(质量上限最高)
|
|
10
|
+
- cross-model:调用 pi 原生 `compact()` 仅换模型与凭证——split-turn / fileOps / previousSummary 原生组装
|
|
11
|
+
- **3 档阈值提醒**(默认 200K/400K/600K):`agent_settled` 越档检查,每档一次、多档合并、压缩后重置,followUp 投递;措辞是数据投递不是指令
|
|
12
|
+
- **排除模型**(精准 `provider/modelId` 匹配):工具拒绝 + 不提醒 + 回落 pi 原生生成;切换跨界时注入一条可用性通知,downshift(切小窗模型将触线)时建议先压缩
|
|
13
|
+
- **健壮性**:摘要收缩校验、max-tokens 截断 fail-closed、接管失败 3 次熔断、transcript 回查指针、压缩后最近文件内容重注入(≤5 文件/50K)、多轮压缩降智提示
|
|
14
|
+
- **subagent 进程**自动静默(`PI_SUBAGENT_ROOT_SESSION_ID` 标记)
|
|
15
|
+
|
|
16
|
+
## 配置
|
|
17
|
+
|
|
18
|
+
`<agentDir>/config/smart-context-ext-config.json`(读时热加载)。schema 与示例见 `skills/smart-context-ext-config/SKILL.md`。xyz-agent 桌面端在设置页(系统 → 智能上下文压缩)可视化配置。
|
|
19
|
+
|
|
20
|
+
## 设计文档
|
|
21
|
+
|
|
22
|
+
`docs/extensions/smart-context/design.md`(xyz-agent 仓库)——决策依据、探针记录、验收场景。
|
package/index.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { default } from "./src/index.ts";
|
package/package.json
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@zhushanwen/pi-smart-context",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"main": "index.ts",
|
|
6
|
+
"xyz-agent": {
|
|
7
|
+
"role": "universal"
|
|
8
|
+
},
|
|
9
|
+
"pi": {
|
|
10
|
+
"extensions": [
|
|
11
|
+
"./index.ts"
|
|
12
|
+
],
|
|
13
|
+
"skills": [
|
|
14
|
+
"./skills"
|
|
15
|
+
]
|
|
16
|
+
},
|
|
17
|
+
"keywords": [
|
|
18
|
+
"pi-package"
|
|
19
|
+
],
|
|
20
|
+
"dependencies": {
|
|
21
|
+
"@zhushanwen/pi-extension-logger": "0.2.2",
|
|
22
|
+
"@zhushanwen/pi-llm-shared": "0.4.0"
|
|
23
|
+
},
|
|
24
|
+
"devDependencies": {
|
|
25
|
+
"@vitest/coverage-v8": "^4.1.9",
|
|
26
|
+
"vitest": "^4.1.8"
|
|
27
|
+
},
|
|
28
|
+
"files": [
|
|
29
|
+
"index.ts",
|
|
30
|
+
"src/**/*.ts",
|
|
31
|
+
"skills/",
|
|
32
|
+
"vitest.config.ts"
|
|
33
|
+
],
|
|
34
|
+
"peerDependencies": {
|
|
35
|
+
"@earendil-works/pi-coding-agent": "^0.84.1",
|
|
36
|
+
"@earendil-works/pi-ai": "^0.84.1",
|
|
37
|
+
"typebox": "*"
|
|
38
|
+
},
|
|
39
|
+
"peerDependenciesMeta": {
|
|
40
|
+
"@earendil-works/pi-coding-agent": {
|
|
41
|
+
"optional": true
|
|
42
|
+
},
|
|
43
|
+
"@earendil-works/pi-ai": {
|
|
44
|
+
"optional": true
|
|
45
|
+
},
|
|
46
|
+
"typebox": {
|
|
47
|
+
"optional": true
|
|
48
|
+
}
|
|
49
|
+
},
|
|
50
|
+
"scripts": {
|
|
51
|
+
"test": "vitest run",
|
|
52
|
+
"test:watch": "vitest"
|
|
53
|
+
}
|
|
54
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: smart-context-ext-config
|
|
3
|
+
description: "配置 @zhushanwen/pi-smart-context(智能上下文压缩:agent 自决 compact_context 工具 + 双模式摘要生成 + 3 档阈值提醒)时加载。含配置文件路径、schema、默认值、双模式说明、排障。触发词:smart-context 配置、压缩配置、compact 配置、上下文压缩、压缩模型、提醒阈值、排除模型。"
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# smart-context 配置指南
|
|
7
|
+
|
|
8
|
+
## 功能概览
|
|
9
|
+
|
|
10
|
+
- `compact_context` 工具:agent 在「任务阶段性完成 && 压缩不影响后续 && 上下文超阈值」时自决调用
|
|
11
|
+
- 双模式摘要生成:压缩模型 = 当前模型 → same-model 模式(KV 缓存命中,成本最低质量最高);不同 → cross-model 模式(廉价模型 + 最小输入)
|
|
12
|
+
- 3 档阈值提醒:越过档位时 agent 收到一次性提示(自行判断,不强制)
|
|
13
|
+
- 排除模型:命中列表的会话模型整体关闭本功能(回落 pi 原生压缩)
|
|
14
|
+
- pi 内建自动压缩的触发线保留为最后防线,其压缩执行也走本扩展逻辑
|
|
15
|
+
|
|
16
|
+
## 配置文件
|
|
17
|
+
|
|
18
|
+
路径:`<agentDir>/config/smart-context-ext-config.json`(`<agentDir>` = `getAgentDir()`,默认 `~/.pi/agent`,xyz-agent 环境 `~/.xyz-agent/pi/agent`;`PI_CODING_AGENT_DIR` 可覆盖)。
|
|
19
|
+
|
|
20
|
+
### Schema
|
|
21
|
+
|
|
22
|
+
| 字段 | 类型 | 默认值 | 说明 |
|
|
23
|
+
|---|---|---|---|
|
|
24
|
+
| `enabled` | boolean | `true` | 总开关 |
|
|
25
|
+
| `compactModel` | `{type:"ref", ref:string}` | `{type:"ref", ref:""}` | 压缩模型(完整 `provider/modelId`)。`ref` 为空 = 跟随当前会话模型(same-model 模式);等于当前模型同样进 same-model 模式 |
|
|
26
|
+
| `reminderThresholds` | `number[]` | `[200000, 400000, 600000]` | 3 档提醒阈值(token 绝对数,升序) |
|
|
27
|
+
| `excludedModels` | `string[]` | `[]` | 排除模型列表,完整 `provider/modelId` 精准等值匹配(如 `"deepseek/deepseek-chat"`),不做前缀匹配 |
|
|
28
|
+
|
|
29
|
+
### 配置示例
|
|
30
|
+
|
|
31
|
+
```json
|
|
32
|
+
{
|
|
33
|
+
"enabled": true,
|
|
34
|
+
"compactModel": { "type": "ref", "ref": "xiaomi-token-plan-cn/mimo-v2.5" },
|
|
35
|
+
"reminderThresholds": [200000, 400000, 600000],
|
|
36
|
+
"excludedModels": ["deepseek/deepseek-chat"]
|
|
37
|
+
}
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## 生效时机
|
|
41
|
+
|
|
42
|
+
配置为读时热加载(mtime 检测):修改保存后**下一次事件(压缩/提醒/工具调用)即生效**,无需重启会话。
|
|
43
|
+
|
|
44
|
+
## 模式选择建议
|
|
45
|
+
|
|
46
|
+
- 当前模型缓存便宜(如 DeepSeek)→ 加入 `excludedModels`,压缩反而更贵
|
|
47
|
+
- 当前模型缓存贵 + 有廉价可用模型 → `compactModel` 配廉价模型(cross-model)
|
|
48
|
+
- 想要最高摘要质量 + 最低成本 → `compactModel` 留空或配成当前模型(same-model,KV 缓存命中)
|
|
49
|
+
|
|
50
|
+
## 排障
|
|
51
|
+
|
|
52
|
+
- 压缩回退当前模型:`compactModel.ref` 指向的模型不可用(无凭证/已删)——换可用模型或留空
|
|
53
|
+
- 工具调用被拒「已禁用」/「已排除」:检查 `enabled` 与 `excludedModels`
|
|
54
|
+
- 提醒未出现:`getContextUsage().tokens` 在压缩后首个响应前为 null,属正常(下轮恢复)
|
|
55
|
+
- 连续接管失败 3 次后不再接管:本会话熔断保护,查看 `XYZ_AGENT_DEBUG=1` 日志定位失败原因
|
|
56
|
+
- 调试:`XYZ_AGENT_DEBUG=1` 后看 `[smart-context]` 前缀日志(`~/.pi/agent/logs/`)
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
|
|
3
|
+
// mock pi SDK:接管路径的单测不触网(same-mode 输入链 + cross-mode 原生组装都替换)
|
|
4
|
+
vi.mock("@earendil-works/pi-coding-agent", () => ({
|
|
5
|
+
buildSessionContext: (entries: unknown[]) => ({ messages: [{ role: "user", content: "history" }] }),
|
|
6
|
+
convertToLlm: (messages: unknown[]) => messages,
|
|
7
|
+
compact: vi.fn(),
|
|
8
|
+
}));
|
|
9
|
+
vi.mock("../llm.js", () => ({
|
|
10
|
+
callSameModelCompaction: vi.fn(),
|
|
11
|
+
projectTools: (tools: unknown[]) => tools.map((t) => ({ name: (t as { name: string }).name })),
|
|
12
|
+
}));
|
|
13
|
+
|
|
14
|
+
import { compact as nativeCompact } from "@earendil-works/pi-coding-agent";
|
|
15
|
+
import { callSameModelCompaction } from "../llm.js";
|
|
16
|
+
import {
|
|
17
|
+
createBeforeCompactHandler,
|
|
18
|
+
createTakeoverState,
|
|
19
|
+
type BeforeCompactLikeEvent,
|
|
20
|
+
} from "../compact-handler.js";
|
|
21
|
+
import { normalizeSmartContextConfig, type SmartContextConfig } from "../pure.js";
|
|
22
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
23
|
+
|
|
24
|
+
function makeEvent(overrides?: Partial<BeforeCompactLikeEvent["preparation"]>): BeforeCompactLikeEvent {
|
|
25
|
+
return {
|
|
26
|
+
type: "session_before_compact",
|
|
27
|
+
preparation: {
|
|
28
|
+
firstKeptEntryId: "kept-1",
|
|
29
|
+
messagesToSummarize: [
|
|
30
|
+
{ role: "user", content: "x".repeat(4_000) },
|
|
31
|
+
{ role: "assistant", content: "y".repeat(4_000) },
|
|
32
|
+
],
|
|
33
|
+
turnPrefixMessages: [],
|
|
34
|
+
isSplitTurn: false,
|
|
35
|
+
tokensBefore: 500_000,
|
|
36
|
+
fileOps: { read: new Set(["/a.ts"]), written: new Set(), edited: new Set() },
|
|
37
|
+
...overrides,
|
|
38
|
+
},
|
|
39
|
+
branchEntries: [],
|
|
40
|
+
customInstructions: undefined,
|
|
41
|
+
reason: "manual",
|
|
42
|
+
willRetry: false,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function makeCtx(): ExtensionContext {
|
|
47
|
+
return {
|
|
48
|
+
model: { provider: "zai", id: "glm" },
|
|
49
|
+
getSystemPrompt: () => "sys",
|
|
50
|
+
sessionManager: { getSessionId: () => "s1", getSessionFile: () => "/tmp/s1.jsonl" },
|
|
51
|
+
modelRegistry: {
|
|
52
|
+
getApiKeyAndHeaders: async () => ({ ok: true, apiKey: "k" }),
|
|
53
|
+
find: () => null,
|
|
54
|
+
},
|
|
55
|
+
} as unknown as ExtensionContext;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function makePi(): ExtensionAPI {
|
|
59
|
+
return { getAllTools: () => [] } as unknown as ExtensionAPI;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const mockedCall = vi.mocked(callSameModelCompaction);
|
|
63
|
+
const mockedNative = vi.mocked(nativeCompact);
|
|
64
|
+
|
|
65
|
+
function makeHandler(config: SmartContextConfig) {
|
|
66
|
+
const state = createTakeoverState();
|
|
67
|
+
const handler = createBeforeCompactHandler(makePi(), () => state, () => config);
|
|
68
|
+
return { handler, state };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
beforeEach(() => {
|
|
72
|
+
vi.clearAllMocks();
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
describe("session_before_compact 接管 handler", () => {
|
|
76
|
+
it("门控未放行(disabled / 排除命中)→ 空返回(pi 原生生成)", async () => {
|
|
77
|
+
const disabled = makeHandler(normalizeSmartContextConfig({ enabled: false }));
|
|
78
|
+
await expect(disabled.handler(makeEvent(), makeCtx())).resolves.toEqual({});
|
|
79
|
+
|
|
80
|
+
const excluded = makeHandler(
|
|
81
|
+
normalizeSmartContextConfig({ excludedModels: ["zai/glm"] }),
|
|
82
|
+
);
|
|
83
|
+
await expect(excluded.handler(makeEvent(), makeCtx())).resolves.toEqual({});
|
|
84
|
+
expect(mockedCall).not.toHaveBeenCalled();
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("same-model 成功 → compaction 带 engine/mode 标记 + preamble + fileOps 清单 + transcript 指针", async () => {
|
|
88
|
+
mockedCall.mockResolvedValue({ ok: true, text: "summary body", usage: { input: 1, output: 2 } });
|
|
89
|
+
const { handler } = makeHandler(normalizeSmartContextConfig({ compactModel: { type: "ref", ref: "" } }));
|
|
90
|
+
const decision = await handler(makeEvent(), makeCtx());
|
|
91
|
+
expect(decision.compaction?.details).toEqual({ engine: "smart-context", mode: "same-model" });
|
|
92
|
+
expect(decision.compaction?.summary).toContain("summary body");
|
|
93
|
+
// D13-9 preamble 在最前;D11-2 fileOps;D13-4 transcript
|
|
94
|
+
expect(decision.compaction?.summary.startsWith("This is an automatically generated checkpoint")).toBe(true);
|
|
95
|
+
expect(decision.compaction?.summary).toContain("<read-files>");
|
|
96
|
+
expect(decision.compaction?.summary).toContain("/tmp/s1.jsonl");
|
|
97
|
+
// cache-key 一致性:调用参数含 systemPrompt + tools(mock 收到的 options)
|
|
98
|
+
expect(mockedCall.mock.calls[0][1].systemPrompt).toBe("sys");
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("max-tokens 截断 fail-closed(D13-2)→ 空返回 + failStreak +1", async () => {
|
|
102
|
+
mockedCall.mockResolvedValue({ ok: true, text: "half...", stopReason: "length" });
|
|
103
|
+
const { handler, state } = makeHandler(normalizeSmartContextConfig({}));
|
|
104
|
+
await expect(handler(makeEvent(), makeCtx())).resolves.toEqual({});
|
|
105
|
+
expect(state.failStreak).toBe(1);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("cross-model:resolveModel 不可用 → 空返回(D7 回退)", async () => {
|
|
109
|
+
const { handler } = makeHandler(
|
|
110
|
+
normalizeSmartContextConfig({ compactModel: { type: "ref", ref: "xiaomi/mimo" } }),
|
|
111
|
+
);
|
|
112
|
+
// modelRegistry.find 未命中 mimo → resolveModel 返回 null → generateCrossMode D7 静默回退
|
|
113
|
+
const ctx = makeCtx();
|
|
114
|
+
await expect(handler(makeEvent(), ctx)).resolves.toEqual({});
|
|
115
|
+
expect(mockedNative).not.toHaveBeenCalled();
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it("收缩校验失败(摘要 ≥ 被压段)→ 拒绝 + 同段记录不重试(D13-1)", async () => {
|
|
119
|
+
mockedCall.mockResolvedValue({ ok: true, text: "z".repeat(10_000) });
|
|
120
|
+
const { handler, state } = makeHandler(normalizeSmartContextConfig({}));
|
|
121
|
+
const event = makeEvent({ messagesToSummarize: [{ role: "user", content: "x".repeat(400) }] });
|
|
122
|
+
await expect(handler(event, makeCtx())).resolves.toEqual({});
|
|
123
|
+
expect(state.inflatedSegments.has("kept-1")).toBe(true);
|
|
124
|
+
// 同段第二次直接跳过(不再调 LLM)
|
|
125
|
+
mockedCall.mockClear();
|
|
126
|
+
await expect(handler(event, makeCtx())).resolves.toEqual({});
|
|
127
|
+
expect(mockedCall).not.toHaveBeenCalled();
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it("连续失败 3 次熔断(D13-3)→ 第 4 次起直接空返回", async () => {
|
|
131
|
+
mockedCall.mockResolvedValue({ ok: false, text: "", error: "boom" });
|
|
132
|
+
const { handler, state } = makeHandler(normalizeSmartContextConfig({}));
|
|
133
|
+
for (let i = 0; i < 3; i++) {
|
|
134
|
+
await expect(handler(makeEvent(), makeCtx())).resolves.toEqual({});
|
|
135
|
+
}
|
|
136
|
+
expect(state.failStreak).toBe(3);
|
|
137
|
+
mockedCall.mockClear();
|
|
138
|
+
await expect(handler(makeEvent(), makeCtx())).resolves.toEqual({});
|
|
139
|
+
expect(mockedCall).not.toHaveBeenCalled(); // 熔断后不再尝试
|
|
140
|
+
});
|
|
141
|
+
});
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
DEFAULT_SMART_CONTEXT_CONFIG,
|
|
5
|
+
buildReinjectSection,
|
|
6
|
+
checkToolThresholdGuard,
|
|
7
|
+
computeFileListsLike,
|
|
8
|
+
estimateTextTokens,
|
|
9
|
+
findCrossedThresholds,
|
|
10
|
+
formatFileOperationsLike,
|
|
11
|
+
formatK,
|
|
12
|
+
getCurrentModelId,
|
|
13
|
+
isGatingActive,
|
|
14
|
+
isSubagentProcess,
|
|
15
|
+
isSummaryInflated,
|
|
16
|
+
normalizeSmartContextConfig,
|
|
17
|
+
pickMode,
|
|
18
|
+
pickReinjectFiles,
|
|
19
|
+
} from "../pure.js";
|
|
20
|
+
|
|
21
|
+
describe("normalizeSmartContextConfig", () => {
|
|
22
|
+
it("非对象输入回退默认值", () => {
|
|
23
|
+
const c = normalizeSmartContextConfig(null);
|
|
24
|
+
expect(c).toEqual(DEFAULT_SMART_CONTEXT_CONFIG);
|
|
25
|
+
expect(c.reminderThresholds).toEqual([200_000, 400_000, 600_000]);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("阈值过滤非正数并升序、截 3 档;空数组回退默认", () => {
|
|
29
|
+
const c = normalizeSmartContextConfig({ reminderThresholds: [300_000, 100_000, -5, "x", 200_000, 999_000] });
|
|
30
|
+
expect(c.reminderThresholds).toEqual([100_000, 200_000, 300_000]);
|
|
31
|
+
|
|
32
|
+
const c2 = normalizeSmartContextConfig({ reminderThresholds: [] });
|
|
33
|
+
expect(c2.reminderThresholds).toEqual([200_000, 400_000, 600_000]);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("excludedModels 只留含 / 的字符串条目并去重(精准匹配要求完整 provider/modelId)", () => {
|
|
37
|
+
const c = normalizeSmartContextConfig({
|
|
38
|
+
excludedModels: ["deepseek/deepseek-chat", "deepseek", "deepseek/deepseek-chat", 42],
|
|
39
|
+
});
|
|
40
|
+
expect(c.excludedModels).toEqual(["deepseek/deepseek-chat"]);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("compactModel 非法形态回退空 ref", () => {
|
|
44
|
+
const c = normalizeSmartContextConfig({ compactModel: { type: "regex", pattern: "x" } });
|
|
45
|
+
expect(c.compactModel).toEqual({ type: "ref", ref: "" });
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
describe("门控与模式判定(D5/D12)", () => {
|
|
50
|
+
it("getCurrentModelId 拼接 provider/modelId,缺失返回空串", () => {
|
|
51
|
+
expect(getCurrentModelId({ provider: "zai", id: "glm" })).toBe("zai/glm");
|
|
52
|
+
expect(getCurrentModelId(undefined)).toBe("");
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("isGatingActive:enabled 且未命中排除才放行", () => {
|
|
56
|
+
const config = normalizeSmartContextConfig({ enabled: true, excludedModels: ["deepseek/deepseek-chat"] });
|
|
57
|
+
expect(isGatingActive(config, "zai/glm")).toBe(true);
|
|
58
|
+
expect(isGatingActive(config, "deepseek/deepseek-chat")).toBe(false);
|
|
59
|
+
// provider 前缀不算命中(精准匹配)
|
|
60
|
+
expect(isGatingActive(config, "deepseek/other-model")).toBe(true);
|
|
61
|
+
const disabled = normalizeSmartContextConfig({ enabled: false });
|
|
62
|
+
expect(isGatingActive(disabled, "zai/glm")).toBe(false);
|
|
63
|
+
expect(isGatingActive(config, "")).toBe(false);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("pickMode:ref 等于当前模型或未配置 → same-model", () => {
|
|
67
|
+
const unset = normalizeSmartContextConfig({ compactModel: { type: "ref", ref: "" } });
|
|
68
|
+
expect(pickMode(unset, "zai/glm")).toBe("same-model");
|
|
69
|
+
|
|
70
|
+
const same = normalizeSmartContextConfig({ compactModel: { type: "ref", ref: "zai/glm" } });
|
|
71
|
+
expect(pickMode(same, "zai/glm")).toBe("same-model");
|
|
72
|
+
|
|
73
|
+
const cross = normalizeSmartContextConfig({ compactModel: { type: "ref", ref: "xiaomi/mimo" } });
|
|
74
|
+
expect(pickMode(cross, "zai/glm")).toBe("cross-model");
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
describe("阈值检查(D3/D6)", () => {
|
|
79
|
+
it("findCrossedThresholds:null tokens 容错为空(R7);已 fired 排除", () => {
|
|
80
|
+
const tiers = [5_000, 10_000, 15_000];
|
|
81
|
+
expect(findCrossedThresholds(tiers, null, new Set())).toEqual([]);
|
|
82
|
+
expect(findCrossedThresholds(tiers, 11_000, new Set([5_000]))).toEqual([10_000]);
|
|
83
|
+
expect(findCrossedThresholds(tiers, 99_000, new Set())).toEqual(tiers);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("checkToolThresholdGuard:低于最低档拒绝并带用量数据;null 拒绝", () => {
|
|
87
|
+
const tiers = [200_000, 400_000, 600_000];
|
|
88
|
+
const guardMessage = checkToolThresholdGuard(tiers, 38_000);
|
|
89
|
+
expect(guardMessage).toMatch(/38K/);
|
|
90
|
+
expect(guardMessage).toMatch(/200K/);
|
|
91
|
+
expect(checkToolThresholdGuard(tiers, 250_000)).toBeNull();
|
|
92
|
+
expect(checkToolThresholdGuard(tiers, null)).toMatch(/用量未知/);
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
describe("摘要后处理纯函数(D11/D13)", () => {
|
|
97
|
+
it("formatK 整数/小数", () => {
|
|
98
|
+
expect(formatK(200_000)).toBe("200K");
|
|
99
|
+
expect(formatK(215_000)).toBe("215K");
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("isSummaryInflated + estimateTextTokens(chars/4)", () => {
|
|
103
|
+
expect(estimateTextTokens("abcd")).toBe(1);
|
|
104
|
+
expect(isSummaryInflated(500, 400)).toBe(true);
|
|
105
|
+
expect(isSummaryInflated(300, 400)).toBe(false);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("computeFileListsLike:只读 = read − modified;排序", () => {
|
|
109
|
+
const { readFiles, modifiedFiles } = computeFileListsLike({
|
|
110
|
+
read: new Set(["b.ts", "a.ts", "c.ts"]),
|
|
111
|
+
written: new Set(["c.ts"]),
|
|
112
|
+
edited: new Set(["d.ts"]),
|
|
113
|
+
});
|
|
114
|
+
expect(readFiles).toEqual(["a.ts", "b.ts"]);
|
|
115
|
+
expect(modifiedFiles).toEqual(["c.ts", "d.ts"]);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it("formatFileOperationsLike 对齐 pi XML tags 格式", () => {
|
|
119
|
+
expect(formatFileOperationsLike(["a.ts"], ["b.ts"])).toBe(
|
|
120
|
+
"\n\n<read-files>\na.ts\n</read-files>\n\n<modified-files>\nb.ts\n</modified-files>",
|
|
121
|
+
);
|
|
122
|
+
expect(formatFileOperationsLike([], [])).toBe("");
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it("pickReinjectFiles:跳过保留段已读,取最近 ≤5(尾部)", () => {
|
|
126
|
+
const reads = ["f1", "f2", "f3", "f4", "f5", "f6", "f7"];
|
|
127
|
+
expect(pickReinjectFiles(reads, new Set(["f2"]))).toEqual(["f3", "f4", "f5", "f6", "f7"]);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it("buildReinjectSection:空内容跳过、超预算截停", () => {
|
|
131
|
+
expect(buildReinjectSection([{ path: "a", content: "" }])).toBe("");
|
|
132
|
+
const long = "x".repeat(6_000);
|
|
133
|
+
const section = buildReinjectSection([{ path: "a", content: long }]);
|
|
134
|
+
expect(section).toContain("### a");
|
|
135
|
+
expect(section).toContain("[... truncated]");
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
describe("subagent 识别(R6)", () => {
|
|
140
|
+
it("PI_SUBAGENT_ROOT_SESSION_ID 存在即 subagent", () => {
|
|
141
|
+
expect(isSubagentProcess({ PI_SUBAGENT_ROOT_SESSION_ID: "s1" })).toBe(true);
|
|
142
|
+
expect(isSubagentProcess({})).toBe(false);
|
|
143
|
+
});
|
|
144
|
+
});
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
buildDegradationHintLine,
|
|
5
|
+
buildDownshiftNotice,
|
|
6
|
+
buildSwitchNotice,
|
|
7
|
+
buildThresholdReminder,
|
|
8
|
+
} from "../reminder.js";
|
|
9
|
+
import { buildSameModelInstruction, buildTranscriptPointer, CHECKPOINT_PREAMBLE } from "../prompts.js";
|
|
10
|
+
|
|
11
|
+
describe("buildThresholdReminder(D3/D4:数据投递不强制)", () => {
|
|
12
|
+
it("含用量数据、档位、三条件自查清单、可忽略出口", () => {
|
|
13
|
+
const msg = buildThresholdReminder([200_000], 215_000, 1_000_000, 0);
|
|
14
|
+
expect(msg).toContain("215K / 1000K");
|
|
15
|
+
expect(msg).toContain("21.5%");
|
|
16
|
+
expect(msg).toContain("200K");
|
|
17
|
+
expect(msg).toContain("compact_context");
|
|
18
|
+
expect(msg).toContain("1. 当前任务的一个阶段已完成");
|
|
19
|
+
expect(msg).toContain("忽略本提示继续工作");
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("多档合并为一条消息", () => {
|
|
23
|
+
const msg = buildThresholdReminder([200_000, 400_000], 430_000, 1_000_000, 0);
|
|
24
|
+
expect(msg).toContain("200K(第 1 档)、400K(第 2 档)");
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("累计压缩 ≥2 次附降智提示(D13-12)", () => {
|
|
28
|
+
const without = buildThresholdReminder([200_000], 215_000, 1_000_000, 1);
|
|
29
|
+
const with_ = buildThresholdReminder([200_000], 215_000, 1_000_000, 2);
|
|
30
|
+
expect(without).not.toContain("compacted multiple times");
|
|
31
|
+
expect(with_).toContain("compacted multiple times");
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
describe("buildSwitchNotice(D5 跨界通知)", () => {
|
|
36
|
+
it("不可用/恢复两态文案含模型 ID 与原因", () => {
|
|
37
|
+
const unavailableNotice = buildSwitchNotice("unavailable", "deepseek/deepseek-chat");
|
|
38
|
+
expect(unavailableNotice).toContain("deepseek/deepseek-chat");
|
|
39
|
+
expect(unavailableNotice).toContain("暂时不可用");
|
|
40
|
+
expect(buildSwitchNotice("available", "zai/glm")).toContain("恢复可用");
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
describe("buildDownshiftNotice(D5 窗口收缩)", () => {
|
|
45
|
+
it("窗口变大或未触线返回 null", () => {
|
|
46
|
+
expect(buildDownshiftNotice(100_000, 200_000, 1_000_000)).toBeNull();
|
|
47
|
+
expect(buildDownshiftNotice(100_000, 1_000_000, 500_000)).toBeNull();
|
|
48
|
+
expect(buildDownshiftNotice(null, 1_000_000, 200_000)).toBeNull();
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("切小窗且 tokens 超内建触发线 → 建议压缩", () => {
|
|
52
|
+
const notice = buildDownshiftNotice(190_000, 1_000_000, 200_000);
|
|
53
|
+
expect(notice).toContain("190K");
|
|
54
|
+
expect(notice).toContain("200K");
|
|
55
|
+
expect(notice).toContain("建议尽快压缩");
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
describe("prompts(D13-6/7/8/9 + D12)", () => {
|
|
60
|
+
it("same-model 指令:首尾 TEXT ONLY 双保险 + 结构化模板 + 先验合并规则", () => {
|
|
61
|
+
const instruction = buildSameModelInstruction();
|
|
62
|
+
expect(instruction.startsWith("CRITICAL: Respond with TEXT ONLY")).toBe(true);
|
|
63
|
+
expect(instruction.endsWith("REMINDER: Respond with TEXT ONLY. Do NOT call any tools. Output only the checkpoint text.")).toBe(true);
|
|
64
|
+
expect(instruction).toContain("## Goal");
|
|
65
|
+
expect(instruction).toContain("## Files and Code");
|
|
66
|
+
expect(instruction).toContain("## Errors and Fixes");
|
|
67
|
+
expect(instruction).toContain("do NOT copy it forward verbatim");
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("custom_instructions 追加 focus 段", () => {
|
|
71
|
+
const instruction = buildSameModelInstruction("保留验证结果");
|
|
72
|
+
expect(instruction).toContain('Additional focus from the calling agent');
|
|
73
|
+
expect(instruction).toContain("保留验证结果");
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("transcript 指针含路径", () => {
|
|
77
|
+
expect(buildTranscriptPointer("/tmp/s.jsonl")).toContain("/tmp/s.jsonl");
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("落回包裹语存在(D13-9)", () => {
|
|
81
|
+
expect(CHECKPOINT_PREAMBLE).toContain("without acknowledging this checkpoint");
|
|
82
|
+
});
|
|
83
|
+
});
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* SDK 契约测试(规范:凡调用 pi.on / pi.registerTool / 读 ctx.* 的代码必须有契约测试覆盖)。
|
|
5
|
+
* 兜底 compact-handler.ts / llm.ts 中跨 SDK 泛型边界的 `as never` 断言——
|
|
6
|
+
* 断言的运行时形状在这里实测(node_modules 实装 @earendil-works/pi-coding-agent@0.84.1)。
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import {
|
|
10
|
+
buildSessionContext,
|
|
11
|
+
compact,
|
|
12
|
+
convertToLlm,
|
|
13
|
+
DEFAULT_COMPACTION_SETTINGS,
|
|
14
|
+
} from "@earendil-works/pi-coding-agent";
|
|
15
|
+
import { completeSimple } from "@earendil-works/pi-ai/compat";
|
|
16
|
+
|
|
17
|
+
describe("pi SDK 契约(compact 接管路径依赖的导出形状)", () => {
|
|
18
|
+
it("compact / buildSessionContext / convertToLlm / DEFAULT_COMPACTION_SETTINGS 为函数/对象导出", () => {
|
|
19
|
+
expect(typeof compact).toBe("function");
|
|
20
|
+
expect(typeof buildSessionContext).toBe("function");
|
|
21
|
+
expect(typeof convertToLlm).toBe("function");
|
|
22
|
+
expect(typeof DEFAULT_COMPACTION_SETTINGS).toBe("object");
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("buildSessionContext(branchEntries) 返回 { messages, entries }(R9 完整上下文数据源)", () => {
|
|
26
|
+
const entries = [
|
|
27
|
+
{ id: "m1", parentId: null, type: "message", timestamp: 1, message: { role: "user", content: "hi", timestamp: 1 } },
|
|
28
|
+
{ id: "c1", parentId: "m1", type: "compaction", timestamp: 2, summary: "s", firstKeptEntryId: "m2", tokensBefore: 10 },
|
|
29
|
+
{ id: "m2", parentId: "c1", type: "message", timestamp: 3, message: { role: "user", content: "kept", timestamp: 3 } },
|
|
30
|
+
];
|
|
31
|
+
const ctx = buildSessionContext(entries as never);
|
|
32
|
+
expect(Array.isArray(ctx.messages)).toBe(true);
|
|
33
|
+
// compaction entry 投影为一条消息 + 保留段消息(旧摘要在前缀中,D12 same-mode 前提)
|
|
34
|
+
expect(ctx.messages.length).toBeGreaterThanOrEqual(2);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("convertToLlm 把 user text message 原样保留(same-mode 输入链不变形)", () => {
|
|
38
|
+
const out = convertToLlm([{ role: "user", content: "hello", timestamp: 1 } as never]);
|
|
39
|
+
expect(out.length).toBe(1);
|
|
40
|
+
expect(out[0].role).toBe("user");
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("completeSimple 为函数导出(same-mode LLM 调用通道)", () => {
|
|
44
|
+
expect(typeof completeSimple).toBe("function");
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("compact() 的 preparation 参数运行时消费字段(messagesToSummarize/previousSummary/fileOps)在函数体内可达——静态签名核对", async () => {
|
|
48
|
+
// 不真正调网:用立即 abort 的 signal 让 compact 提前拒绝,验证参数形状被接受不抛 TypeError
|
|
49
|
+
const controller = new AbortController();
|
|
50
|
+
controller.abort();
|
|
51
|
+
const preparation = {
|
|
52
|
+
firstKeptEntryId: "x",
|
|
53
|
+
messagesToSummarize: [{ role: "user", content: "m", timestamp: 1 }],
|
|
54
|
+
turnPrefixMessages: [],
|
|
55
|
+
isSplitTurn: false,
|
|
56
|
+
tokensBefore: 10,
|
|
57
|
+
previousSummary: undefined,
|
|
58
|
+
fileOps: { read: new Set(), written: new Set(), edited: new Set() },
|
|
59
|
+
settings: DEFAULT_COMPACTION_SETTINGS,
|
|
60
|
+
};
|
|
61
|
+
const model = { id: "m", provider: "p", contextWindow: 1000, maxTokens: 100 } as never;
|
|
62
|
+
await expect(
|
|
63
|
+
compact(preparation as never, model, "key", undefined, undefined, controller.signal),
|
|
64
|
+
).rejects.toThrow();
|
|
65
|
+
});
|
|
66
|
+
});
|