@bachi/pi-coder 1.0.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/CHANGELOG.md +24 -0
- package/LICENSE +21 -0
- package/README.md +162 -0
- package/config/AGENTS.md +100 -0
- package/config/pi-statusline.json +140 -0
- package/config/settings.json +38 -0
- package/config/web-search.json +5 -0
- package/docs/README.md +14 -0
- package/docs/configuration.md +123 -0
- package/docs/development.md +177 -0
- package/docs/extensions.md +292 -0
- package/docs/handbook.zh.md +432 -0
- package/docs/installation.md +124 -0
- package/docs/themes.md +107 -0
- package/extensions/ask-user-question/answers.test.ts +104 -0
- package/extensions/ask-user-question/answers.ts +72 -0
- package/extensions/ask-user-question/dialog.test.ts +180 -0
- package/extensions/ask-user-question/dialog.ts +102 -0
- package/extensions/ask-user-question/index.ts +253 -0
- package/extensions/ask-user-question/model.test.ts +275 -0
- package/extensions/ask-user-question/model.ts +259 -0
- package/extensions/ask-user-question/schema.ts +49 -0
- package/extensions/ask-user-question/types.ts +86 -0
- package/extensions/ask-user-question/validate.test.ts +183 -0
- package/extensions/ask-user-question/validate.ts +110 -0
- package/extensions/ask-user-question/view.ts +262 -0
- package/extensions/auto-default-model/default-model.test.ts +268 -0
- package/extensions/auto-default-model/index.ts +87 -0
- package/extensions/bash-command-collapse.ts +1476 -0
- package/extensions/below-editor-after-statusline.ts +118 -0
- package/extensions/clear-command.ts +29 -0
- package/extensions/cwd-statusline.ts +39 -0
- package/extensions/exit-command.ts +59 -0
- package/extensions/fenceless-code-block/index.test.ts +208 -0
- package/extensions/fenceless-code-block/index.ts +28 -0
- package/extensions/fenceless-code-block/render.test.ts +177 -0
- package/extensions/fenceless-code-block/render.ts +142 -0
- package/extensions/folder-history.ts +197 -0
- package/extensions/init-command.ts +163 -0
- package/extensions/prompt-editor/bash-prompt.test.ts +94 -0
- package/extensions/prompt-editor/bash-prompt.ts +59 -0
- package/extensions/prompt-editor/render.test.ts +283 -0
- package/extensions/prompt-editor.ts +212 -0
- package/extensions/read-path-collapse.ts +474 -0
- package/extensions/recap/index.test.ts +348 -0
- package/extensions/recap/index.ts +462 -0
- package/extensions/recap/subagents.test.ts +144 -0
- package/extensions/recap/subagents.ts +128 -0
- package/extensions/rewind/README.md +229 -0
- package/extensions/rewind/checkpoints.test.ts +560 -0
- package/extensions/rewind/checkpoints.ts +820 -0
- package/extensions/rewind/flow.test.ts +756 -0
- package/extensions/rewind/flow.ts +362 -0
- package/extensions/rewind/index.ts +400 -0
- package/extensions/rewind/picker.ts +135 -0
- package/extensions/rewind/viewport.test.ts +76 -0
- package/extensions/rewind/viewport.ts +48 -0
- package/extensions/simple-task/gap.test.ts +147 -0
- package/extensions/simple-task/gap.ts +122 -0
- package/extensions/simple-task/index.ts +439 -0
- package/extensions/simple-task/types.ts +53 -0
- package/extensions/simple-task/widget.ts +86 -0
- package/extensions/startup-logo/header-guard.test.ts +274 -0
- package/extensions/startup-logo/header-guard.ts +166 -0
- package/extensions/startup-logo/index.test.ts +305 -0
- package/extensions/startup-logo/index.ts +194 -0
- package/extensions/startup-logo/loaded-sections.test.ts +257 -0
- package/extensions/startup-logo/loaded-sections.ts +267 -0
- package/extensions/startup-logo/logo.test.ts +124 -0
- package/extensions/startup-logo/logo.ts +124 -0
- package/extensions/statusline/footer-guard.test.ts +273 -0
- package/extensions/statusline/footer-guard.ts +171 -0
- package/extensions/statusline/git.test.ts +174 -0
- package/extensions/statusline/git.ts +142 -0
- package/extensions/statusline/index.ts +294 -0
- package/extensions/statusline/line.test.ts +316 -0
- package/extensions/statusline/line.ts +201 -0
- package/extensions/subagent-log-guard/filter.test.ts +85 -0
- package/extensions/subagent-log-guard/filter.ts +32 -0
- package/extensions/subagent-log-guard/index.ts +112 -0
- package/extensions/theme-command.ts +263 -0
- package/extensions/thinking-collapse/window.test.ts +321 -0
- package/extensions/thinking-collapse/window.ts +354 -0
- package/extensions/thinking-collapse.ts +60 -0
- package/extensions/tool-diff/title-row.test.ts +254 -0
- package/extensions/tool-diff/title-row.ts +191 -0
- package/extensions/tool-diff.ts +1276 -0
- package/extensions/working-indicator/bash-spinner.test.ts +135 -0
- package/extensions/working-indicator/bash-spinner.ts +114 -0
- package/extensions/working-indicator/index.test.ts +579 -0
- package/extensions/working-indicator/index.ts +940 -0
- package/extensions/working-indicator/spinner-frames.test.ts +219 -0
- package/extensions/working-indicator/spinner-frames.ts +156 -0
- package/extensions/working-indicator/summary-request.test.ts +195 -0
- package/extensions/working-indicator/summary-request.ts +207 -0
- package/extensions/working-indicator/working-summary.test.ts +499 -0
- package/extensions/working-indicator/working-summary.ts +375 -0
- package/package.json +71 -0
- package/themes/ayu.json +97 -0
- package/themes/catppuccin.json +103 -0
- package/themes/summer-night.json +87 -0
|
@@ -0,0 +1,579 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for working-indicator/index.ts — 「长提示词异步请模型压成一句话」这条链路。
|
|
3
|
+
*
|
|
4
|
+
* Run with: node --test clients/pi/extensions/working-indicator/index.test.ts
|
|
5
|
+
*
|
|
6
|
+
* pi 自己的扩展加载器真的加载 `index.ts`(所以 `./summary-request.ts` 的 import、
|
|
7
|
+
* 事件注册面都在覆盖范围内),假的是扩展外面的一切:ctx(录制 `setWorkingMessage`
|
|
8
|
+
* 与 `modelRegistry.complete`)和事件总线的另一端。模型调用是手动 resolve 的
|
|
9
|
+
* promise,于是「旧请求回来时新提示词已经就位」这类时序能被精确摆出来。
|
|
10
|
+
*
|
|
11
|
+
* 终端宽度:`process.stdout.columns` 在测试进程里是 undefined,`terminalWidth()` 退回
|
|
12
|
+
* 80;于是可用宽度 = min(40 - 2, 76 - 34 - 1 - 2) = 38 列,触发线是 45.6 列(默认倍数 1.2)。
|
|
13
|
+
* 用例里的长提示词都远超它、短提示词都远低于它。
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import assert from "node:assert/strict";
|
|
17
|
+
import fs from "node:fs";
|
|
18
|
+
import os from "node:os";
|
|
19
|
+
import path from "node:path";
|
|
20
|
+
import test from "node:test";
|
|
21
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
22
|
+
|
|
23
|
+
import {
|
|
24
|
+
DEFAULT_FRAMES_PER_COLOR,
|
|
25
|
+
SPINNER_COLOR_TOKENS,
|
|
26
|
+
SPINNER_FRAMES,
|
|
27
|
+
SPINNER_INTERVAL_MS,
|
|
28
|
+
} from "./spinner-frames.ts";
|
|
29
|
+
|
|
30
|
+
const EXTENSION_PATH = path.join(path.dirname(fileURLToPath(import.meta.url)), "index.ts");
|
|
31
|
+
const SKIP = "找不到本机 pi 的库入口(装过 pi 才有)";
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* pi 的库入口(非 CLI):bundle 是 `pi` 实际跑的形态,dist 是 node 构建形态。
|
|
35
|
+
*
|
|
36
|
+
* 先从 `pi` 可执行文件反查**真正的安装位置**(pnpm/npm 的 shim 脚本里留有
|
|
37
|
+
* `# cmd-shim-target=<绝对路径>`;npm 在 Unix 上则是符号链接,两种都试),再退回
|
|
38
|
+
* `~/.pi/agent/npm` 那份副本 —— 后者是**扩展包的安装根**,`pi update --extensions` 会用
|
|
39
|
+
* `--config.auto-install-peers=false` 把自动装进去的 `@earendil-works/pi-*` 同伴包剪掉,
|
|
40
|
+
* 于是那份副本只剩空壳(文件都在、import 报 `ERR_MODULE_NOT_FOUND`;2026-09 升
|
|
41
|
+
* pi-subagents 0.68.0 时实测踩到)。
|
|
42
|
+
*
|
|
43
|
+
* 判定方式是**能不能真 import**,不是路径存不存在 —— 只有真 import 一次才分得清空壳。
|
|
44
|
+
* 全都不行就返回 undefined,调用方整体 skip(不假装通过)。
|
|
45
|
+
*/
|
|
46
|
+
async function findPiLibraryEntry(): Promise<string | undefined> {
|
|
47
|
+
const candidates: string[] = [];
|
|
48
|
+
if (process.env.PI_TEST_PI_ENTRY) candidates.push(process.env.PI_TEST_PI_ENTRY);
|
|
49
|
+
|
|
50
|
+
for (const dir of (process.env.PATH ?? "").split(path.delimiter)) {
|
|
51
|
+
if (!dir) continue;
|
|
52
|
+
const shimPath = path.join(dir, "pi");
|
|
53
|
+
try {
|
|
54
|
+
// npm 的 shim 是符号链接
|
|
55
|
+
const real = fs.realpathSync(shimPath);
|
|
56
|
+
if (real !== shimPath) candidates.push(path.join(path.dirname(real), "index.js"));
|
|
57
|
+
} catch {
|
|
58
|
+
// 不是符号链接 / 不存在:看下面的 shim 脚本
|
|
59
|
+
}
|
|
60
|
+
try {
|
|
61
|
+
const match = /^# cmd-shim-target=(.+)$/m.exec(fs.readFileSync(shimPath, "utf8"));
|
|
62
|
+
if (match?.[1]) candidates.push(path.join(path.dirname(match[1].trim()), "index.js"));
|
|
63
|
+
} catch {
|
|
64
|
+
// 读不到这个 shim:跳过
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const packageDir = path.join(os.homedir(), ".pi/agent/npm/node_modules/@earendil-works/pi-coding-agent");
|
|
69
|
+
candidates.push(path.join(packageDir, "dist/bundle/index.js"), path.join(packageDir, "dist/index.js"));
|
|
70
|
+
|
|
71
|
+
for (const candidate of candidates) {
|
|
72
|
+
if (!fs.existsSync(candidate)) continue;
|
|
73
|
+
try {
|
|
74
|
+
await import(pathToFileURL(candidate).href);
|
|
75
|
+
return candidate;
|
|
76
|
+
} catch {
|
|
77
|
+
// 空壳副本:换下一个候选
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return undefined;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const piEntry = await findPiLibraryEntry();
|
|
84
|
+
const skip = piEntry === undefined ? SKIP : false;
|
|
85
|
+
|
|
86
|
+
type Handler = (event: unknown, ctx: unknown) => Promise<unknown> | unknown;
|
|
87
|
+
|
|
88
|
+
interface TestBus {
|
|
89
|
+
emit(channel: string, data: unknown): void;
|
|
90
|
+
on(channel: string, handler: (data: unknown) => void): () => void;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function createTestBus(): TestBus {
|
|
94
|
+
const handlers = new Map<string, Set<(data: unknown) => void>>();
|
|
95
|
+
return {
|
|
96
|
+
on(channel, handler) {
|
|
97
|
+
const set = handlers.get(channel) ?? new Set<(data: unknown) => void>();
|
|
98
|
+
handlers.set(channel, set);
|
|
99
|
+
set.add(handler);
|
|
100
|
+
return () => {
|
|
101
|
+
set.delete(handler);
|
|
102
|
+
};
|
|
103
|
+
},
|
|
104
|
+
emit(channel, data) {
|
|
105
|
+
for (const handler of [...(handlers.get(channel) ?? [])]) handler(data);
|
|
106
|
+
},
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** 一次 `modelRegistry.complete` 调用的记录。 */
|
|
111
|
+
interface CompleteCall {
|
|
112
|
+
/** 请求提示词(user 消息里的文本)。 */
|
|
113
|
+
prompt: string;
|
|
114
|
+
maxTokens: number | undefined;
|
|
115
|
+
temperature: number | undefined;
|
|
116
|
+
/** 手动放行这次调用。 */
|
|
117
|
+
resolve: (text: string) => void;
|
|
118
|
+
/** 让这次调用失败(模型报错 / 超时)。 */
|
|
119
|
+
reject: (error: Error) => void;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
interface Recorder {
|
|
123
|
+
completes: CompleteCall[];
|
|
124
|
+
/** 每次 `setWorkingMessage(<string>)` 记一条。 */
|
|
125
|
+
workingMessages: string[];
|
|
126
|
+
/** 无参 `setWorkingMessage()`(回合结束恢复默认)次数。 */
|
|
127
|
+
resets: number;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
interface ContextOptions {
|
|
131
|
+
/** 主题 `fg` 实现;缺省恒等(保持既有布局用例的宽度口径)。 */
|
|
132
|
+
themeFg?: (color: string, text: string) => string;
|
|
133
|
+
/** 每次 `setWorkingIndicator` 记一条(`undefined` = 无参调用,即恢复 pi 默认帧)。 */
|
|
134
|
+
onIndicator?: (options: { frames?: string[]; intervalMs?: number } | undefined) => void;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** 取请求上下文里的 user 文本(请求体形状由 `summary-request.ts` 决定)。 */
|
|
138
|
+
function promptTextOf(context: unknown): string {
|
|
139
|
+
const messages = (context as { messages?: Array<{ content?: Array<{ text?: string }> }> }).messages ?? [];
|
|
140
|
+
return messages.map((message) => message.content?.map((block) => block.text ?? "").join("") ?? "").join("");
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function createContext(recorder: Recorder, options: ContextOptions = {}): unknown {
|
|
144
|
+
return {
|
|
145
|
+
mode: "tui",
|
|
146
|
+
hasUI: true,
|
|
147
|
+
model: { provider: "test", id: "test-model" },
|
|
148
|
+
modelRegistry: {
|
|
149
|
+
find: () => undefined,
|
|
150
|
+
getApiKeyAndHeaders: async () => ({ ok: true, apiKey: "test", headers: {} }),
|
|
151
|
+
complete: (_model: unknown, context: unknown, options: { maxTokens?: number; temperature?: number }) =>
|
|
152
|
+
new Promise((resolve, reject) => {
|
|
153
|
+
recorder.completes.push({
|
|
154
|
+
prompt: promptTextOf(context),
|
|
155
|
+
maxTokens: options?.maxTokens,
|
|
156
|
+
temperature: options?.temperature,
|
|
157
|
+
resolve: (text: string) => resolve({ content: [{ type: "text", text }] }),
|
|
158
|
+
reject,
|
|
159
|
+
});
|
|
160
|
+
}),
|
|
161
|
+
},
|
|
162
|
+
ui: {
|
|
163
|
+
theme: { fg: options.themeFg ?? ((_color: string, text: string) => text) },
|
|
164
|
+
setWorkingIndicator: (indicator?: { frames?: string[]; intervalMs?: number }) =>
|
|
165
|
+
options.onIndicator?.(indicator),
|
|
166
|
+
setWorkingMessage: (message?: string) => {
|
|
167
|
+
if (message === undefined) recorder.resets += 1;
|
|
168
|
+
else recorder.workingMessages.push(message);
|
|
169
|
+
},
|
|
170
|
+
},
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function makeWorkspace(): { agentDir: string; projectDir: string; cleanup: () => void } {
|
|
175
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), "pi-working-indicator-"));
|
|
176
|
+
const agentDir = path.join(root, "agent");
|
|
177
|
+
const projectDir = path.join(root, "project");
|
|
178
|
+
fs.mkdirSync(agentDir);
|
|
179
|
+
fs.mkdirSync(projectDir);
|
|
180
|
+
return {
|
|
181
|
+
agentDir,
|
|
182
|
+
projectDir,
|
|
183
|
+
cleanup: () => fs.rmSync(root, { recursive: true, force: true }),
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
interface LoadedExtension {
|
|
188
|
+
handlers: Map<string, Handler[]>;
|
|
189
|
+
errors: Array<{ path: string; error: string }>;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** 用 pi 自己的加载器加载本扩展(`./summary-request.ts` 等 import 也一起验证)。 */
|
|
193
|
+
async function loadExtension(workspace: { agentDir: string; projectDir: string }): Promise<LoadedExtension> {
|
|
194
|
+
const pi = (await import(pathToFileURL(piEntry as string).href)) as {
|
|
195
|
+
discoverAndLoadExtensions: (
|
|
196
|
+
configuredPaths: string[],
|
|
197
|
+
cwd: string,
|
|
198
|
+
agentDir?: string,
|
|
199
|
+
eventBus?: unknown,
|
|
200
|
+
) => Promise<{ extensions: LoadedExtension[]; errors: Array<{ path: string; error: string }> }>;
|
|
201
|
+
};
|
|
202
|
+
const loaded = await pi.discoverAndLoadExtensions(
|
|
203
|
+
[EXTENSION_PATH],
|
|
204
|
+
workspace.projectDir,
|
|
205
|
+
workspace.agentDir,
|
|
206
|
+
createTestBus(),
|
|
207
|
+
);
|
|
208
|
+
assert.deepEqual(loaded.errors, [], "pi 的扩展加载器不应该报错");
|
|
209
|
+
assert.equal(loaded.extensions.length, 1);
|
|
210
|
+
const extension = loaded.extensions[0];
|
|
211
|
+
assert.ok(extension);
|
|
212
|
+
return extension;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function handlersOf(extension: LoadedExtension): {
|
|
216
|
+
input: Handler;
|
|
217
|
+
agentStart: Handler;
|
|
218
|
+
agentSettled: Handler;
|
|
219
|
+
shutdown: Handler;
|
|
220
|
+
} {
|
|
221
|
+
const input = extension.handlers.get("input")?.[0];
|
|
222
|
+
const agentStart = extension.handlers.get("agent_start")?.[0];
|
|
223
|
+
const agentSettled = extension.handlers.get("agent_settled")?.[0];
|
|
224
|
+
const shutdown = extension.handlers.get("session_shutdown")?.[0];
|
|
225
|
+
assert.ok(input, "应该注册了 input");
|
|
226
|
+
assert.ok(agentStart, "应该注册了 agent_start");
|
|
227
|
+
assert.ok(agentSettled, "应该注册了 agent_settled");
|
|
228
|
+
assert.ok(shutdown, "应该注册了 session_shutdown");
|
|
229
|
+
return { input, agentStart, agentSettled, shutdown };
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** 等谓词成立;超时把「在等什么」一并报出来。 */
|
|
233
|
+
async function waitFor(predicate: () => boolean, what: string, timeoutMs = 5_000): Promise<void> {
|
|
234
|
+
const deadline = Date.now() + timeoutMs;
|
|
235
|
+
while (Date.now() < deadline) {
|
|
236
|
+
if (predicate()) return;
|
|
237
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
238
|
+
}
|
|
239
|
+
throw new Error(`等不到:${what}(超时 ${timeoutMs}ms)`);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const delay = (ms: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms));
|
|
243
|
+
|
|
244
|
+
/** 远超 76 列触发线的多行中文提示词。 */
|
|
245
|
+
const LONG_PROMPT = [
|
|
246
|
+
"帮我优化一下 working 行尾的提示词摘要",
|
|
247
|
+
"现在长提示词被生硬地截断,看不出在干什么",
|
|
248
|
+
"希望超过可显示宽度时让模型压成一句话",
|
|
249
|
+
"而且这个请求要和主任务并行,不要互相影响",
|
|
250
|
+
].join("\n");
|
|
251
|
+
|
|
252
|
+
test("长提示词异步请求一次摘要,回来后替换行尾原文;请求不阻塞主回合", { skip, timeout: 30_000 }, async () => {
|
|
253
|
+
const workspace = makeWorkspace();
|
|
254
|
+
try {
|
|
255
|
+
const extension = await loadExtension(workspace);
|
|
256
|
+
const { input, agentStart, agentSettled, shutdown } = handlersOf(extension);
|
|
257
|
+
const recorder: Recorder = { completes: [], workingMessages: [], resets: 0 };
|
|
258
|
+
const ctx = createContext(recorder);
|
|
259
|
+
|
|
260
|
+
await input({ text: LONG_PROMPT, source: "interactive" }, ctx);
|
|
261
|
+
await agentStart({}, ctx);
|
|
262
|
+
|
|
263
|
+
assert.equal(recorder.completes.length, 1, "长提示词应该发一次摘要请求");
|
|
264
|
+
const call = recorder.completes[0];
|
|
265
|
+
assert.ok(call);
|
|
266
|
+
// 请求里带上了算好的目标长度(38 列可用 × 0.9 = 34)与列宽口径。
|
|
267
|
+
assert.match(call.prompt, /34 display columns/);
|
|
268
|
+
assert.match(call.prompt, /CJK\/full-width character counts as 2 columns/);
|
|
269
|
+
// 压平后的原文整段都在(多行 → 一句)。
|
|
270
|
+
assert.ok(call.prompt.includes("帮我优化一下 working 行尾的提示词摘要"));
|
|
271
|
+
// 不传 temperature:本机这些路由在 0 温度下会退化成停不下来的长思考(实测)。
|
|
272
|
+
assert.equal(call.temperature, undefined);
|
|
273
|
+
// maxTokens 必须容下强制 thinking(网关给这些路由写死了 thinking: enabled,关不掉)。
|
|
274
|
+
assert.ok((call.maxTokens ?? 0) >= 512, `maxTokens 应给 thinking 留足份额,实际 ${call.maxTokens}`);
|
|
275
|
+
|
|
276
|
+
// 摘要还没回来:先显示的是截断后的原文。
|
|
277
|
+
const before = recorder.workingMessages.at(-1) ?? "";
|
|
278
|
+
assert.match(before, /✦ /);
|
|
279
|
+
assert.ok(!before.includes("模型压缩"), "模型还没回包时不该凭空出现摘要");
|
|
280
|
+
|
|
281
|
+
call.resolve("把行尾长提示词交给模型压成一句话");
|
|
282
|
+
|
|
283
|
+
await waitFor(
|
|
284
|
+
() => (recorder.workingMessages.at(-1) ?? "").includes("把行尾长提示词交给模型压成一句话"),
|
|
285
|
+
"摘要替换成模型返回的一句话",
|
|
286
|
+
);
|
|
287
|
+
const after = recorder.workingMessages.at(-1) ?? "";
|
|
288
|
+
assert.ok(after.includes("✦ "), "摘要仍带 `✦ ` 前缀");
|
|
289
|
+
assert.ok(!after.includes("希望超过可显示宽度时"), "摘要应该顶掉原文,而不是两者并存");
|
|
290
|
+
|
|
291
|
+
await agentSettled({}, ctx);
|
|
292
|
+
assert.equal(recorder.resets, 1, "回合结束应恢复 pi 默认文案");
|
|
293
|
+
await shutdown({}, ctx);
|
|
294
|
+
} finally {
|
|
295
|
+
workspace.cleanup();
|
|
296
|
+
}
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
test("请求不阻塞 input:模型永不回包时 input handler 也立刻返回", { skip, timeout: 30_000 }, async () => {
|
|
300
|
+
const workspace = makeWorkspace();
|
|
301
|
+
try {
|
|
302
|
+
const extension = await loadExtension(workspace);
|
|
303
|
+
const { input, agentStart, shutdown } = handlersOf(extension);
|
|
304
|
+
const recorder: Recorder = { completes: [], workingMessages: [], resets: 0 };
|
|
305
|
+
const ctx = createContext(recorder);
|
|
306
|
+
const startedAt = Date.now();
|
|
307
|
+
await input({ text: LONG_PROMPT, source: "interactive" }, ctx);
|
|
308
|
+
await agentStart({}, ctx);
|
|
309
|
+
const elapsed = Date.now() - startedAt;
|
|
310
|
+
assert.equal(recorder.completes.length, 1, "请求已发出");
|
|
311
|
+
assert.ok(elapsed < 500, `input handler 不能等模型(耗时 ${elapsed}ms)`);
|
|
312
|
+
|
|
313
|
+
// 请求一直挂着也不影响后续事件:回合照常结束。
|
|
314
|
+
await handlersOf(extension).agentSettled({}, ctx);
|
|
315
|
+
await shutdown({}, ctx);
|
|
316
|
+
} finally {
|
|
317
|
+
workspace.cleanup();
|
|
318
|
+
}
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
test("短提示词不请求;同一条消息重复 input 也不重复请求", { skip, timeout: 30_000 }, async () => {
|
|
322
|
+
const workspace = makeWorkspace();
|
|
323
|
+
try {
|
|
324
|
+
const extension = await loadExtension(workspace);
|
|
325
|
+
const { input, agentStart, shutdown } = handlersOf(extension);
|
|
326
|
+
const recorder: Recorder = { completes: [], workingMessages: [], resets: 0 };
|
|
327
|
+
const ctx = createContext(recorder);
|
|
328
|
+
await input({ text: "hi", source: "interactive" }, ctx);
|
|
329
|
+
await agentStart({}, ctx);
|
|
330
|
+
await delay(50);
|
|
331
|
+
assert.equal(recorder.completes.length, 0, "短提示词整段放得下,不该请求");
|
|
332
|
+
|
|
333
|
+
await input({ text: LONG_PROMPT, source: "interactive" }, ctx);
|
|
334
|
+
await waitFor(() => recorder.completes.length === 1, "第一次长提示词请求");
|
|
335
|
+
// 扩展命令的 sendUserMessage 会让同一条消息走两次 input。
|
|
336
|
+
await input({ text: LONG_PROMPT, source: "extension" }, ctx);
|
|
337
|
+
await delay(50);
|
|
338
|
+
assert.equal(recorder.completes.length, 1, "同一条文本重复触发不该重新请求");
|
|
339
|
+
|
|
340
|
+
// 换一条真正不同的长提示词才会发第二次。
|
|
341
|
+
await input({ text: `${LONG_PROMPT}(第二版)`, source: "interactive" }, ctx);
|
|
342
|
+
await waitFor(() => recorder.completes.length === 2, "新提示词的第二次请求");
|
|
343
|
+
assert.ok(recorder.completes[1]?.prompt.includes("(第二版)"));
|
|
344
|
+
|
|
345
|
+
// 会话被替换后,同一条文本必须重新请求(去重键不能跨会话留着)。
|
|
346
|
+
await shutdown({}, ctx);
|
|
347
|
+
await input({ text: LONG_PROMPT, source: "interactive" }, ctx);
|
|
348
|
+
await waitFor(() => recorder.completes.length === 3, "新会话里的同一条提示词");
|
|
349
|
+
} finally {
|
|
350
|
+
workspace.cleanup();
|
|
351
|
+
}
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
test("新提示词作废旧请求:旧摘要回来也不会顶掉新的", { skip, timeout: 30_000 }, async () => {
|
|
355
|
+
const workspace = makeWorkspace();
|
|
356
|
+
try {
|
|
357
|
+
const extension = await loadExtension(workspace);
|
|
358
|
+
const { input, agentStart, shutdown } = handlersOf(extension);
|
|
359
|
+
const recorder: Recorder = { completes: [], workingMessages: [], resets: 0 };
|
|
360
|
+
const ctx = createContext(recorder);
|
|
361
|
+
await input({ text: LONG_PROMPT, source: "interactive" }, ctx);
|
|
362
|
+
await agentStart({}, ctx);
|
|
363
|
+
await waitFor(() => recorder.completes.length === 1, "第一条提示词的请求");
|
|
364
|
+
await input({ text: `${LONG_PROMPT} 改成模型压缩`, source: "interactive" }, ctx);
|
|
365
|
+
await waitFor(() => recorder.completes.length === 2, "第二条提示词的请求");
|
|
366
|
+
|
|
367
|
+
// 旧请求后回来:必须被丢弃(abort + 序号两道护栏)。
|
|
368
|
+
recorder.completes[0]?.resolve("第一条的摘要");
|
|
369
|
+
await delay(50);
|
|
370
|
+
assert.ok(
|
|
371
|
+
!recorder.workingMessages.some((message) => message.includes("第一条的摘要")),
|
|
372
|
+
"过期摘要绝不能显示",
|
|
373
|
+
);
|
|
374
|
+
|
|
375
|
+
recorder.completes[1]?.resolve("第二条的摘要");
|
|
376
|
+
await waitFor(
|
|
377
|
+
() => (recorder.workingMessages.at(-1) ?? "").includes("第二条的摘要"),
|
|
378
|
+
"当前提示词的摘要",
|
|
379
|
+
);
|
|
380
|
+
|
|
381
|
+
await shutdown({}, ctx);
|
|
382
|
+
} finally {
|
|
383
|
+
workspace.cleanup();
|
|
384
|
+
}
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
test("摘要超长只截断、不重试:每个提示词始终只请求一次", { skip, timeout: 30_000 }, async () => {
|
|
388
|
+
const workspace = makeWorkspace();
|
|
389
|
+
try {
|
|
390
|
+
const extension = await loadExtension(workspace);
|
|
391
|
+
const { input, agentStart, shutdown } = handlersOf(extension);
|
|
392
|
+
const recorder: Recorder = { completes: [], workingMessages: [], resets: 0 };
|
|
393
|
+
const ctx = createContext(recorder);
|
|
394
|
+
await input({ text: LONG_PROMPT, source: "interactive" }, ctx);
|
|
395
|
+
await agentStart({}, ctx);
|
|
396
|
+
await waitFor(() => recorder.completes.length === 1, "摘要请求");
|
|
397
|
+
const overlong = "这是一段远超可用宽度的模型摘要".repeat(20);
|
|
398
|
+
recorder.completes[0]?.resolve(overlong);
|
|
399
|
+
|
|
400
|
+
await waitFor(
|
|
401
|
+
() => (recorder.workingMessages.at(-1) ?? "").includes("…"),
|
|
402
|
+
"超长摘要被截断补省略号",
|
|
403
|
+
);
|
|
404
|
+
const message = recorder.workingMessages.at(-1) ?? "";
|
|
405
|
+
assert.ok(!message.includes("这是一段远超可用宽度的模型摘要这是一段"), "超长摘要应被截掉大半");
|
|
406
|
+
// 等过一个 1s tick,确认没有任何「第二次请求」。
|
|
407
|
+
await delay(1_200);
|
|
408
|
+
assert.equal(recorder.completes.length, 1, "回来的摘要再长也不重试");
|
|
409
|
+
|
|
410
|
+
await shutdown({}, ctx);
|
|
411
|
+
} finally {
|
|
412
|
+
workspace.cleanup();
|
|
413
|
+
}
|
|
414
|
+
});
|
|
415
|
+
|
|
416
|
+
test("模型报错 / 不支持的上下文里静默退回截断后的原文", { skip, timeout: 30_000 }, async () => {
|
|
417
|
+
const workspace = makeWorkspace();
|
|
418
|
+
try {
|
|
419
|
+
const extension = await loadExtension(workspace);
|
|
420
|
+
const { input, agentStart, shutdown } = handlersOf(extension);
|
|
421
|
+
const recorder: Recorder = { completes: [], workingMessages: [], resets: 0 };
|
|
422
|
+
const ctx = createContext(recorder);
|
|
423
|
+
await input({ text: LONG_PROMPT, source: "interactive" }, ctx);
|
|
424
|
+
await agentStart({}, ctx);
|
|
425
|
+
await waitFor(() => recorder.completes.length === 1, "摘要请求");
|
|
426
|
+
recorder.completes[0]?.reject(new Error("boom"));
|
|
427
|
+
|
|
428
|
+
await delay(100);
|
|
429
|
+
const message = recorder.workingMessages.at(-1) ?? "";
|
|
430
|
+
assert.ok(message.includes("✦ "), "失败后仍显示摘要段(这里是截断后的原文)");
|
|
431
|
+
assert.ok(message.includes("帮我优化"), "原文前缀还在");
|
|
432
|
+
assert.equal(recorder.completes.length, 1, "失败不重试");
|
|
433
|
+
|
|
434
|
+
await shutdown({}, ctx);
|
|
435
|
+
} finally {
|
|
436
|
+
workspace.cleanup();
|
|
437
|
+
}
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
// ---------------------------------------------------------------------------
|
|
441
|
+
// spinner 幻彩帧(`spinner-frames.ts` + index.ts 的三个安装时机)
|
|
442
|
+
// ---------------------------------------------------------------------------
|
|
443
|
+
|
|
444
|
+
/** 七色假主题;`colors` 就地改就是「换肤」(`theme.fg` 的 live proxy 替身)。 */
|
|
445
|
+
function createMutableTheme(): { colors: Record<string, string>; fg: (color: string, text: string) => string } {
|
|
446
|
+
const colors: Record<string, string> = Object.fromEntries(
|
|
447
|
+
SPINNER_COLOR_TOKENS.map((token, index) => [token, `<c${index}>`]),
|
|
448
|
+
);
|
|
449
|
+
return { colors, fg: (color, text) => `${colors[color] ?? `<${color}>`}${text}` };
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/** 一次 `setWorkingIndicator` 调用的记录(`undefined` = 无参调用,即恢复 pi 默认帧)。 */
|
|
453
|
+
type IndicatorCall = { frames?: string[]; intervalMs?: number } | undefined;
|
|
454
|
+
|
|
455
|
+
test("回合开始装上幻彩帧表:十帧盲文 × 七色、间隔 80ms、每色 19 帧(≈1.5s)", { skip, timeout: 30_000 }, async () => {
|
|
456
|
+
const workspace = makeWorkspace();
|
|
457
|
+
try {
|
|
458
|
+
const extension = await loadExtension(workspace);
|
|
459
|
+
const { agentStart, shutdown } = handlersOf(extension);
|
|
460
|
+
const recorder: Recorder = { completes: [], workingMessages: [], resets: 0 };
|
|
461
|
+
const theme = createMutableTheme();
|
|
462
|
+
const calls: IndicatorCall[] = [];
|
|
463
|
+
const ctx = createContext(recorder, { themeFg: theme.fg, onIndicator: (o) => calls.push(o) });
|
|
464
|
+
|
|
465
|
+
await agentStart({}, ctx);
|
|
466
|
+
|
|
467
|
+
const installed = calls.at(-1);
|
|
468
|
+
assert.ok(installed, "agent_start 应该装一次 indicator");
|
|
469
|
+
assert.equal(installed.intervalMs, SPINNER_INTERVAL_MS);
|
|
470
|
+
const frames = installed.frames as string[];
|
|
471
|
+
// 帧表周期 = lcm(10, 19 × 7) = 1330:既是一轮调色板的整数倍,也是盲文圈长的整数倍(回绕不跳色)。
|
|
472
|
+
assert.equal(frames.length, 1330);
|
|
473
|
+
assert.equal(frames.length % (DEFAULT_FRAMES_PER_COLOR * SPINNER_COLOR_TOKENS.length), 0);
|
|
474
|
+
assert.equal(frames.length % SPINNER_FRAMES.length, 0);
|
|
475
|
+
for (const [index, frame] of frames.entries()) {
|
|
476
|
+
assert.equal(frame.at(-1), SPINNER_FRAMES[index % SPINNER_FRAMES.length]);
|
|
477
|
+
assert.equal(
|
|
478
|
+
frame.slice(0, -1),
|
|
479
|
+
theme.colors[
|
|
480
|
+
SPINNER_COLOR_TOKENS[
|
|
481
|
+
Math.floor(index / DEFAULT_FRAMES_PER_COLOR) % SPINNER_COLOR_TOKENS.length
|
|
482
|
+
] as string
|
|
483
|
+
],
|
|
484
|
+
);
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
await shutdown({}, ctx);
|
|
488
|
+
} finally {
|
|
489
|
+
workspace.cleanup();
|
|
490
|
+
}
|
|
491
|
+
});
|
|
492
|
+
|
|
493
|
+
test("换主题后一秒内重装帧表:指纹没变不重装、变了才重装", { skip, timeout: 30_000 }, async () => {
|
|
494
|
+
const workspace = makeWorkspace();
|
|
495
|
+
try {
|
|
496
|
+
const extension = await loadExtension(workspace);
|
|
497
|
+
const { agentStart, shutdown } = handlersOf(extension);
|
|
498
|
+
const messageUpdate = extension.handlers.get("message_update")?.[0];
|
|
499
|
+
assert.ok(messageUpdate, "应该注册了 message_update");
|
|
500
|
+
const recorder: Recorder = { completes: [], workingMessages: [], resets: 0 };
|
|
501
|
+
const theme = createMutableTheme();
|
|
502
|
+
const calls: IndicatorCall[] = [];
|
|
503
|
+
const ctx = createContext(recorder, { themeFg: theme.fg, onIndicator: (o) => calls.push(o) });
|
|
504
|
+
|
|
505
|
+
await agentStart({}, ctx);
|
|
506
|
+
assert.equal(calls.length, 1, "回合开始装一次");
|
|
507
|
+
|
|
508
|
+
// 主题没变:每个流式 delta 都跑的 refresh 不该反复重装(重装会复位动画相位)。
|
|
509
|
+
await messageUpdate({ assistantMessageEvent: { type: "text_delta", delta: "hi" } }, ctx);
|
|
510
|
+
await messageUpdate({ assistantMessageEvent: { type: "text_delta", delta: "!" } }, ctx);
|
|
511
|
+
assert.equal(calls.length, 1, "指纹没变就不该重装");
|
|
512
|
+
|
|
513
|
+
// 换肤:下一次 refresh 现读到的新颜色让指纹变化 → 重装。
|
|
514
|
+
theme.colors.accent = "<new-accent>";
|
|
515
|
+
await messageUpdate({ assistantMessageEvent: { type: "text_delta", delta: "?" } }, ctx);
|
|
516
|
+
assert.equal(calls.length, 2, "换肤后应该重装一次");
|
|
517
|
+
const refreshed = calls[1]?.frames?.[0] as string;
|
|
518
|
+
assert.ok(refreshed.startsWith("<new-accent>"), `新帧表应该用新主题的颜色,实际 ${refreshed}`);
|
|
519
|
+
|
|
520
|
+
// 再刷新也不会重复装。
|
|
521
|
+
await messageUpdate({ assistantMessageEvent: { type: "text_delta", delta: "?" } }, ctx);
|
|
522
|
+
assert.equal(calls.length, 2, "同主题下不该再装");
|
|
523
|
+
|
|
524
|
+
await shutdown({}, ctx);
|
|
525
|
+
} finally {
|
|
526
|
+
workspace.cleanup();
|
|
527
|
+
}
|
|
528
|
+
});
|
|
529
|
+
|
|
530
|
+
test("问卷结束时补装彩帧(ask-user-question 的冻结会把它换成 pi 默认帧)", { skip, timeout: 30_000 }, async () => {
|
|
531
|
+
const workspace = makeWorkspace();
|
|
532
|
+
try {
|
|
533
|
+
const extension = await loadExtension(workspace);
|
|
534
|
+
const { agentStart, shutdown } = handlersOf(extension);
|
|
535
|
+
const toolEnd = extension.handlers.get("tool_execution_end")?.[0];
|
|
536
|
+
assert.ok(toolEnd, "应该注册了 tool_execution_end");
|
|
537
|
+
const recorder: Recorder = { completes: [], workingMessages: [], resets: 0 };
|
|
538
|
+
const theme = createMutableTheme();
|
|
539
|
+
const calls: IndicatorCall[] = [];
|
|
540
|
+
const ctx = createContext(recorder, { themeFg: theme.fg, onIndicator: (o) => calls.push(o) });
|
|
541
|
+
|
|
542
|
+
await agentStart({}, ctx);
|
|
543
|
+
const afterStart = calls.length;
|
|
544
|
+
|
|
545
|
+
// 别的工具结束不碰 indicator(每次工具结束都重装会把动画相位按工具调用切碎)。
|
|
546
|
+
await toolEnd({ toolCallId: "1", toolName: "bash" }, ctx);
|
|
547
|
+
assert.equal(calls.length, afterStart, "非问卷工具不该重装");
|
|
548
|
+
|
|
549
|
+
// 问卷结束:补装一次彩帧(questionnaire 的 finally 已经无参恢复成 pi 默认帧)。
|
|
550
|
+
await toolEnd({ toolCallId: "2", toolName: "ask_user_question" }, ctx);
|
|
551
|
+
assert.equal(calls.length, afterStart + 1, "问卷结束应该补装");
|
|
552
|
+
assert.equal((calls.at(-1)?.frames as string[]).length, 1330);
|
|
553
|
+
|
|
554
|
+
await shutdown({}, ctx);
|
|
555
|
+
} finally {
|
|
556
|
+
workspace.cleanup();
|
|
557
|
+
}
|
|
558
|
+
});
|
|
559
|
+
|
|
560
|
+
test("单色主题(NO_COLOR / 恒等 fg)不下发帧表,而是无参交回 pi 默认 spinner", { skip, timeout: 30_000 }, async () => {
|
|
561
|
+
const workspace = makeWorkspace();
|
|
562
|
+
try {
|
|
563
|
+
const extension = await loadExtension(workspace);
|
|
564
|
+
const { agentStart, shutdown } = handlersOf(extension);
|
|
565
|
+
const recorder: Recorder = { completes: [], workingMessages: [], resets: 0 };
|
|
566
|
+
const calls: IndicatorCall[] = [];
|
|
567
|
+
// 不传 themeFg:既有 fake ctx 的恒等主题,七个色槽取出来是同一个颜色。
|
|
568
|
+
const ctx = createContext(recorder, { onIndicator: (o) => calls.push(o) });
|
|
569
|
+
|
|
570
|
+
await agentStart({}, ctx);
|
|
571
|
+
|
|
572
|
+
assert.equal(calls.length, 1, "仍然要交回一次控制权");
|
|
573
|
+
assert.equal(calls[0], undefined, "单色主题应恢复 pi 默认帧而不是装一张假动画");
|
|
574
|
+
|
|
575
|
+
await shutdown({}, ctx);
|
|
576
|
+
} finally {
|
|
577
|
+
workspace.cleanup();
|
|
578
|
+
}
|
|
579
|
+
});
|