@zhushanwen/pi-unified-hooks 0.1.1 → 0.1.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zhushanwen/pi-unified-hooks",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "description": "Unified hooks extension - collect scattered hooks in one place for easy maintenance",
5
5
  "main": "index.ts",
6
6
  "type": "module",
@@ -55,6 +55,7 @@ describe("setupToolErrorHandler", () => {
55
55
  expect(pi.appendEntry).toHaveBeenCalledWith("unified-hooks:tool-error", {
56
56
  toolName: "read",
57
57
  toolCallId: "call-42",
58
+ errorText: null,
58
59
  });
59
60
  });
60
61
 
@@ -152,8 +153,8 @@ describe("setupToolErrorHandler", () => {
152
153
  const calls = pi.appendEntry.mock.calls.map((c) => c[1]);
153
154
  expect(calls).toEqual(
154
155
  expect.arrayContaining([
155
- { toolName: "read", toolCallId: "e1" },
156
- { toolName: "bash", toolCallId: "e2" },
156
+ { toolName: "read", toolCallId: "e1", errorText: null },
157
+ { toolName: "bash", toolCallId: "e2", errorText: null },
157
158
  ]),
158
159
  );
159
160
  });
@@ -175,7 +176,69 @@ describe("setupToolErrorHandler", () => {
175
176
  expect(pi.appendEntry).toHaveBeenCalledWith("unified-hooks:tool-error", {
176
177
  toolName: "bash",
177
178
  toolCallId: "h1",
179
+ errorText: null,
178
180
  });
179
181
  warnSpy.mockRestore();
180
182
  });
183
+
184
+ // --- errorText 提取(核心新增能力)---
185
+
186
+ it("从 result.content[0].text 提取错误文本并拼到 warning(如 'hub disposed')", async () => {
187
+ // [HISTORICAL] subagent execute throw 时 Pi 把 error.message 塞进 result.content[0].text。
188
+ // 旧实现只打 "(callId=xxx)" 无详情,AI 看不到真实原因(如 hub disposed)只能盲猜。
189
+ const pi = createMockPi();
190
+ const { ctx, notify } = createMockCtx();
191
+
192
+ setupToolErrorHandler(pi as unknown as ExtensionAPI);
193
+ const handler = pi.on.mock.calls[0]![1] as (event: unknown, ctx: HookContext) => Promise<void>;
194
+
195
+ await handler(
196
+ {
197
+ isError: true,
198
+ toolName: "subagent",
199
+ toolCallId: "call-disposed",
200
+ result: { content: [{ type: "text", text: "hub disposed" }] },
201
+ },
202
+ ctx,
203
+ );
204
+
205
+ expect(notify).toHaveBeenCalledWith(
206
+ "[unified-hooks] subagent error (callId=call-disposed): hub disposed",
207
+ "warning",
208
+ );
209
+ expect(pi.appendEntry).toHaveBeenCalledWith("unified-hooks:tool-error", {
210
+ toolName: "subagent",
211
+ toolCallId: "call-disposed",
212
+ errorText: "hub disposed",
213
+ });
214
+ });
215
+
216
+ it("result 缺失或无 content 时降级到无详情(不崩)", async () => {
217
+ const pi = createMockPi();
218
+ const { ctx, notify } = createMockCtx();
219
+
220
+ setupToolErrorHandler(pi as unknown as ExtensionAPI);
221
+ const handler = pi.on.mock.calls[0]![1] as (event: unknown, ctx: HookContext) => Promise<void>;
222
+
223
+ // result 为 undefined(某些 headless 路径)
224
+ await handler({ isError: true, toolName: "bash", toolCallId: "x1" }, ctx);
225
+ // result.content 为空数组
226
+ await handler(
227
+ { isError: true, toolName: "bash", toolCallId: "x2", result: { content: [] } },
228
+ ctx,
229
+ );
230
+ // result 不是对象
231
+ await handler(
232
+ { isError: true, toolName: "bash", toolCallId: "x3", result: "oops" },
233
+ ctx,
234
+ );
235
+
236
+ // 三次都降级为无详情后缀
237
+ expect(notify.mock.calls[0]![0]).toBe("[unified-hooks] bash error (callId=x1)");
238
+ expect(notify.mock.calls[1]![0]).toBe("[unified-hooks] bash error (callId=x2)");
239
+ expect(notify.mock.calls[2]![0]).toBe("[unified-hooks] bash error (callId=x3)");
240
+ pi.appendEntry.mock.calls.forEach((c) => {
241
+ expect(c[1]).toHaveProperty("errorText", null);
242
+ });
243
+ });
181
244
  });
@@ -11,11 +11,16 @@ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
11
11
  * Subset of `ToolExecutionEndEvent` fields used by this hook.
12
12
  * Local interface because the SDK's full event type is not re-exported
13
13
  * by the CI ambient type stubs in `shared/types/mariozechner/index.d.ts`.
14
+ *
15
+ * `result` 形如 `{ content: Array<{ type: "text", text: string }>, isError: boolean }`
16
+ * (Pi 框架在 tool execute throw 时塞入 error content)。SDK 事件结构里没有独立
17
+ * errorMessage 字段——错误文本只能从 result.content 里取。
14
18
  */
15
19
  interface ToolExecutionEndLikeEvent {
16
20
  isError: boolean;
17
21
  toolName: string;
18
22
  toolCallId: string;
23
+ result?: unknown;
19
24
  }
20
25
 
21
26
  /** ExtensionContext 的最小子集,仅声明 unified-hooks 内部用到的 ui 字段。 */
@@ -27,11 +32,52 @@ export interface HookContext {
27
32
  };
28
33
  }
29
34
 
35
+ /**
36
+ * 从 tool 执行结果里提取错误文本。
37
+ *
38
+ * Pi 框架在 tool execute 抛错时,构造 `{ content: [{ type: "text", text }] }`
39
+ * 塞进 result.content[0].text。不同 tool / 框架版本可能格式略有差异,这里防御性
40
+ * 取多种结构,取不到就返回 undefined(调用方降级,不阻断)。
41
+ */
42
+ function extractErrorText(result: unknown): string | undefined {
43
+ // 常见结构:{ content: [{ type: "text", text: "..." }] }
44
+ const contentArr = getContentArray(result);
45
+ if (contentArr) {
46
+ for (const item of contentArr) {
47
+ const text = getStringProperty(item, "text");
48
+ if (text) return text;
49
+ }
50
+ }
51
+
52
+ // 兜底:某些工具直接塞 { error: "..." }
53
+ return getStringProperty(result, "error");
54
+ }
55
+
56
+ /** 若 result.content 是数组则返回它,否则 undefined。 */
57
+ function getContentArray(result: unknown): unknown[] | undefined {
58
+ if (typeof result !== "object" || result === null) return undefined;
59
+ const content = (result as Record<string, unknown>).content;
60
+ return Array.isArray(content) ? content : undefined;
61
+ }
62
+
63
+ /** 类型守卫:返回 obj[key] 当它是非空 string,否则 undefined。 */
64
+ function getStringProperty(obj: unknown, key: string): string | undefined {
65
+ if (typeof obj !== "object" || obj === null) return undefined;
66
+ const val = (obj as Record<string, unknown>)[key];
67
+ return typeof val === "string" && val.length > 0 ? val : undefined;
68
+ }
69
+
30
70
  export function setupToolErrorHandler(pi: ExtensionAPI): void {
31
71
  pi.on("tool_execution_end", async (event: unknown, ctx: HookContext) => {
32
72
  const e = event as ToolExecutionEndLikeEvent;
33
73
  if (!e.isError) return;
34
- const msg = `[unified-hooks] ${e.toolName} error (callId=${e.toolCallId})`;
74
+
75
+ // 提取错误文本:tool execute throw 时 Pi 把 error.message 塞进 result.content。
76
+ // SDK 事件无 errorMessage 字段,只能从这里捞;拿不到也不阻断(降级到无详情)。
77
+ const errorText = extractErrorText(e.result);
78
+ const detail = errorText ? `: ${errorText}` : "";
79
+ const msg = `[unified-hooks] ${e.toolName} error (callId=${e.toolCallId})${detail}`;
80
+
35
81
  // ctx.ui.notify 走 TUI 通知区,不越过 alternate screen 污染 input。
36
82
  // console.warn 会写 raw stderr,在 TUI 下泄漏到 input 区。
37
83
  // headless / RPC 会话 ctx.ui 可能为 undefined——降级到 console.warn 保证不 NPE。
@@ -41,9 +87,11 @@ export function setupToolErrorHandler(pi: ExtensionAPI): void {
41
87
  console.warn(msg);
42
88
  }
43
89
  // appendEntry 持久化到 session entries,供事后排查(无 UI、不泄漏)。
90
+ // errorText 一起存上——事后排查能看到真实原因(如 "hub disposed")。
44
91
  pi.appendEntry("unified-hooks:tool-error", {
45
92
  toolName: e.toolName,
46
93
  toolCallId: e.toolCallId,
94
+ errorText: errorText ?? null,
47
95
  });
48
96
  });
49
97
  }