@zhushanwen/pi-session-manager 0.1.7 → 0.1.9

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 CHANGED
@@ -5,7 +5,7 @@ agent-managed session pi extension:把 session 的创建/发送/读取/列表/
5
5
  ## 通道契约
6
6
 
7
7
  - 工具调用经 `ctx.ui.select(SESSION_MANAGER_MARKER, [JSON], {timeout})` 发出,超时按 action 分档(create/history 60s、其余 30s,SSOT 在 `src/index.ts` 的 `SELECT_TIMEOUT_MS`);marker 为 `\x00XYZ_SESSION_MANAGER`(NUL 前缀防与普通 select title 冲突,SSOT 在 `@xyz-agent/extension-protocol`)
8
- - 请求体为嵌套形状 `{ action, params }`(`SessionManagerRequest` 协议类型);**不要扁平化展开**——runtime event-adapter 按 `data.params` 提取,扁平化会导致 params 丢失
8
+ - 请求体为嵌套形状 `{ action, params }`(形状 SSOT 在 `@xyz-agent/extension-protocol`);**不要扁平化展开**——runtime event-adapter 按 `data.params` 提取,扁平化会导致 params 丢失
9
9
  - 应答方是 xyz-agent runtime 的 `SessionManagerHandler`(select value 通道回写 JSON 字符串;取消/超时返回 null)
10
10
 
11
11
  ## 工具(6 个 action)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zhushanwen/pi-session-manager",
3
- "version": "0.1.7",
3
+ "version": "0.1.9",
4
4
  "description": "Pi extension for managing agent-managed sessions — create, send, read history, list, status, abort via ctx.ui.select channel.",
5
5
  "type": "module",
6
6
  "main": "index.ts",
@@ -26,7 +26,7 @@
26
26
  "README.md"
27
27
  ],
28
28
  "dependencies": {
29
- "@xyz-agent/extension-protocol": "0.9.0",
29
+ "@xyz-agent/extension-protocol": "0.11.0",
30
30
  "@zhushanwen/pi-extension-logger": "0.6.0"
31
31
  },
32
32
  "devDependencies": {
@@ -63,20 +63,15 @@ describe("U5-A4 tool-error-handling", () => {
63
63
  expect(result.isError).toBe(true);
64
64
  expect(result.content[0].text).toContain("session unreachable");
65
65
  expect(result.content[0].text).toContain("hint");
66
- expect(result.details).toEqual({
67
- kind: "error",
68
- error: { error: "session unreachable", hint: "check get_session_status" },
69
- });
70
66
  });
71
67
 
72
- it("runtime respond 正常 JSON → 无 isError,details kind=ok", async () => {
68
+ it("runtime respond 正常 JSON → 无 isError", async () => {
73
69
  const { registered, ctx } = createHarness(
74
70
  vi.fn().mockResolvedValue(JSON.stringify({ queued: true })),
75
71
  );
76
72
  const tool = registered.find((t) => t.name === "send_to_session")!;
77
73
  const result = await tool.execute("call-1", { sessionId: "s1", prompt: "hi" }, undefined, undefined, ctx);
78
74
  expect(result.isError).toBeUndefined();
79
- expect(result.details).toEqual({ kind: "ok", result: { queued: true } });
80
75
  });
81
76
 
82
77
  it("all 6 tools handle null gracefully", async () => {
@@ -0,0 +1,77 @@
1
+ // tool-non-json-response.test.ts — 行为微变①(D8):非 JSON 回包 → isError + 留痕
2
+ //
3
+ // [HISTORICAL] 改前行为(D8 前):executeTool 对回包 try JSON.parse、catch 后
4
+ // parsed=undefined,非 JSON 字符串静默当成功文本返回(content[0].text = raw、无
5
+ // isError)——runtime 协议漂移对 agent 不可辨。D8
6
+ // (docs/architecture/ext-simplify-17-shared-extraction.md §3.3,有意微变)统一为
7
+ // 对齐 plugin-bridge 形态:logger.error 留痕(callMarkerRpc 原语经注入的 log 承担)
8
+ // + isError:true + 提示文本。
9
+
10
+ import { describe, it, expect, vi, beforeEach } from "vitest";
11
+
12
+ const loggerMock = vi.hoisted(() => ({
13
+ error: vi.fn(),
14
+ warn: vi.fn(),
15
+ debug: vi.fn(),
16
+ }));
17
+ vi.mock("@zhushanwen/pi-extension-logger", () => ({
18
+ getLogger: () => loggerMock,
19
+ setPiHandle: vi.fn(),
20
+ }));
21
+
22
+ import registerExtension from "../index.ts";
23
+
24
+ function createHarness(selectImpl: (...args: unknown[]) => Promise<unknown>) {
25
+ const registered: Array<{ name: string; execute: Function }> = [];
26
+ const selectMock = vi.fn(selectImpl);
27
+ const pi = {
28
+ registerTool: (tool: { name: string; execute: Function }) => registered.push(tool),
29
+ on: vi.fn(),
30
+ getAllTools: vi.fn(() => []),
31
+ setActiveTools: vi.fn(),
32
+ };
33
+ const ctx = {
34
+ mode: "rpc" as const,
35
+ hasUI: true,
36
+ ui: { select: selectMock },
37
+ };
38
+ registerExtension(pi as never);
39
+ return { registered, selectMock, ctx };
40
+ }
41
+
42
+ describe("D8 行为微变①:非 JSON 回包 → isError + 留痕", () => {
43
+ beforeEach(() => {
44
+ vi.clearAllMocks();
45
+ });
46
+
47
+ it("非 JSON 回包 → isError:true + 提示文本(不再静默当成功文本返回)", async () => {
48
+ const { registered, ctx } = createHarness(vi.fn().mockResolvedValue("plain text response"));
49
+ const tool = registered.find((t) => t.name === "send_to_session")!;
50
+ const result = await tool.execute("call-1", { sessionId: "s1", prompt: "hi" }, undefined, undefined, ctx);
51
+ expect(result.isError).toBe(true);
52
+ expect(result.content).toHaveLength(1);
53
+ expect(result.content[0].text).toContain("non-JSON");
54
+ // 改前对照:text 曾是 raw 本身('plain text response'),无 isError
55
+ expect(result.content[0].text).not.toBe("plain text response");
56
+ });
57
+
58
+ it("留痕:logger.error 收到 non-JSON 消息 + responseHead(原语经注入 log 承担)", async () => {
59
+ const { registered, ctx } = createHarness(vi.fn().mockResolvedValue("plain text response"));
60
+ const tool = registered.find((t) => t.name === "list_my_sessions")!;
61
+ await tool.execute("call-2", {}, undefined, undefined, ctx);
62
+ expect(loggerMock.error).toHaveBeenCalled();
63
+ const [msg, detail] = loggerMock.error.mock.calls.at(-1)!;
64
+ expect(msg).toContain("[session-manager]");
65
+ expect(msg).toContain("non-JSON");
66
+ expect(detail).toMatchObject({ responseHead: "plain text response" });
67
+ });
68
+
69
+ it("对照:合法 JSON 回包仍走成功路径(raw 透传、无 isError、无留痕)", async () => {
70
+ const { registered, ctx } = createHarness(vi.fn().mockResolvedValue('{"queued":true}'));
71
+ const tool = registered.find((t) => t.name === "send_to_session")!;
72
+ const result = await tool.execute("call-3", { sessionId: "s1", prompt: "hi" }, undefined, undefined, ctx);
73
+ expect(result.isError).toBeUndefined();
74
+ expect(result.content[0].text).toBe('{"queued":true}');
75
+ expect(loggerMock.error).not.toHaveBeenCalled();
76
+ });
77
+ });
package/src/index.ts CHANGED
@@ -2,7 +2,14 @@
2
2
  // 6 个 session 管理工具,通过 ctx.ui.select(SESSION_MANAGER_MARKER) 通道与 runtime handler 通信。
3
3
 
4
4
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
5
- import { SESSION_MANAGER_MARKER, type SessionManagerAction } from "@xyz-agent/extension-protocol";
5
+ import {
6
+ SESSION_MANAGER_MARKER,
7
+ callMarkerRpc,
8
+ formatChannelErrorText,
9
+ isChannelErrorResult,
10
+ type MarkerRpcResult,
11
+ type SessionManagerAction,
12
+ } from "@xyz-agent/extension-protocol";
6
13
  import { getLogger, setPiHandle } from "@zhushanwen/pi-extension-logger";
7
14
  import { Type, type Static, type TObject } from "typebox";
8
15
 
@@ -50,52 +57,38 @@ const SELECT_TIMEOUT_MS: Record<SessionManagerAction, number> = {
50
57
  abort: 30_000,
51
58
  };
52
59
 
53
- /** runtime handler respond 的 JSON 形状(错误闭环:{ error, hint?, sessionId? }) */
54
- interface SessionManagerRawError {
55
- error: string;
56
- hint?: string;
57
- sessionId?: string;
58
- }
59
-
60
- /** 工具 details 的可消费形状(下游消费不再 any) */
61
- type SessionManagerToolDetails =
62
- | { kind: "error"; error: SessionManagerRawError }
63
- | { kind: "ok"; result: unknown }
64
- | { kind: "cancelled" };
65
-
66
60
  /**
67
- * 通过 select 通道向 runtime handler 发送 session 管理请求。
68
- * 返回 handler respond 的 JSON 字符串,用户取消/超时返回 null。
61
+ * 通过 select 通道向 runtime handler 发送 session 管理请求(传输核走 protocol 的
62
+ * callMarkerRpc 原语,D8)。回包为 handler respond 的 JSON 字符串(value 恒 raw);
63
+ * 失败四态(cancelled/timeout/channel-error/non-json)由 executeTool 统一折叠 isError。
64
+ * 通道异常与非 JSON 回包的留痕由原语经注入的 log 承担。
69
65
  */
70
- async function callSessionManager(
66
+ function callSessionManager(
71
67
  ctx: ExtensionContext,
72
68
  action: SessionManagerAction,
73
69
  params: Record<string, unknown>,
74
- ): Promise<string | null> {
75
- // 契约 SSOT:SessionManagerRequest = { action, params }(协议包 extension-protocol 的
76
- // session-manager 模块 types.ts,嵌套 params)。runtime event-adapter 的 marker
70
+ ): Promise<MarkerRpcResult> {
71
+ // 契约 SSOT:请求体 = 嵌套 { action, params } 形状(协议包 @xyz-agent/extension-protocol
72
+ // 的 session-manager 模块)。runtime event-adapter 的 marker
77
73
  // 分支按 data.params 提取——若扁平化展开({action, ...params})params 会丢失变 {}。
78
74
  const payload = JSON.stringify({ action, params });
79
- try {
80
- const value = await ctx.ui.select(
81
- SESSION_MANAGER_MARKER,
82
- [payload],
83
- { timeout: SELECT_TIMEOUT_MS[action] },
84
- );
85
- return value ?? null;
86
- } catch (err) {
87
- // select 通道异常(非用户取消/超时——那两类是 resolve null):折叠为 null 供
88
- // executeTool 统一转 isError,但必须留痕(静默吞 = runtime handler 故障不可排查)
89
- logger.error(`[session-manager] select channel threw for action="${action}"`, {
90
- reason: err instanceof Error ? err.message : String(err),
91
- });
92
- return null;
93
- }
75
+ // 从 ExtensionContext 构造 GuiContext 最小子集(ask-user runRpcInteraction 同款先例):
76
+ // ExtensionContext.ui.custom 泛型签名与 GuiContext.ui.custom 静态不兼容,直接传 ctx
77
+ // 过不了 tsc;callMarkerRpc 只读 ui.select。
78
+ const guiCtx = {
79
+ mode: ctx.mode,
80
+ hasUI: ctx.hasUI,
81
+ ui: { select: ctx.ui.select.bind(ctx.ui) },
82
+ };
83
+ return callMarkerRpc(guiCtx, SESSION_MANAGER_MARKER, payload, {
84
+ timeout: SELECT_TIMEOUT_MS[action],
85
+ log: (msg, detail) => logger.error(`[session-manager] ${msg}`, detail),
86
+ });
94
87
  }
95
88
 
96
89
  /**
97
90
  * 统一的 execute 包装:调用 select 通道并解析结果。
98
- * 返回标准 AgentToolResult 形状;select 取消/超时/异常是错误路径,
91
+ * 返回标准 AgentToolResult 形状;select 取消/超时/异常/非 JSON 回包是错误路径,
99
92
  * 必须带 isError: true(extension-conventions「禁止错误成功模式」——
100
93
  * 调用方 agent 需能区分成功与失败以决定重试/放弃)。
101
94
  */
@@ -103,36 +96,39 @@ async function executeTool(
103
96
  ctx: ExtensionContext,
104
97
  action: SessionManagerAction,
105
98
  params: Record<string, unknown>,
106
- ): Promise<{ isError?: boolean; content: Array<{ type: "text"; text: string }>; details: SessionManagerToolDetails }> {
107
- const raw = await callSessionManager(ctx, action, params);
108
- if (raw === null) {
99
+ ): Promise<{ isError?: boolean; content: Array<{ type: "text"; text: string }>; details: undefined }> {
100
+ const result = await callSessionManager(ctx, action, params);
101
+ if (!result.ok) {
102
+ // 行为微变①(D8,有意——对齐 plugin-bridge 形态):非 JSON 回包从「catch 后
103
+ // parsed=undefined 静默当成功文本返回」改为 isError + 提示文本(留痕由原语
104
+ // 经注入的 logger.error 承担);其余三态维持原 cancelled/timeout 折叠文案。
105
+ const text =
106
+ result.reason === "non-json"
107
+ ? `Session manager ${action}: non-JSON response from runtime (protocol mismatch — redeploy same-version runtime + extension; see extension logs).`
108
+ : `Session manager ${action}: cancelled or timed out.`;
109
109
  return {
110
110
  isError: true,
111
- content: [{ type: "text" as const, text: `Session manager ${action}: cancelled or timed out.` }],
112
- details: { kind: "cancelled" },
111
+ content: [{ type: "text" as const, text }],
112
+ details: undefined,
113
113
  };
114
114
  }
115
- // runtime 错误闭环(respond({error}) 走同一 select 通道)——解析后检测 error 字段,
116
- // 命中即 isError: true(extension-conventions「禁止错误成功模式」:agent 需能区分
117
- // 成功与同步失败以决定重试/放弃,不能靠读 content 文本自行判错)。
118
- let parsed: unknown;
119
- try {
120
- parsed = JSON.parse(raw);
121
- } catch {
122
- parsed = undefined;
123
- }
124
- if (parsed !== null && typeof parsed === "object" && typeof (parsed as SessionManagerRawError).error === "string") {
125
- const err = parsed as SessionManagerRawError;
126
- const text = err.hint ? `${err.error}\nhint: ${err.hint}` : err.error;
115
+ const raw = result.value;
116
+ // 合法性已由原语检测(ok:true ⇒ 同一字符串 JSON.parse 必成功),parse 只为字段检测。
117
+ // runtime 错误闭环(respond({error}) 走同一 select 通道)——检测与文本拼接单源于
118
+ // protocol 的 isChannelErrorResult / formatChannelErrorText(D8):命中即 isError: true
119
+ //(extension-conventions「禁止错误成功模式」:agent 需能区分成功与同步失败以决定
120
+ // 重试/放弃,不能靠读 content 文本自行判错)。
121
+ const parsed: unknown = JSON.parse(raw);
122
+ if (isChannelErrorResult(parsed)) {
127
123
  return {
128
124
  isError: true,
129
- content: [{ type: "text" as const, text }],
130
- details: { kind: "error", error: err },
125
+ content: [{ type: "text" as const, text: formatChannelErrorText(parsed) }],
126
+ details: undefined,
131
127
  };
132
128
  }
133
129
  return {
134
130
  content: [{ type: "text" as const, text: raw }],
135
- details: { kind: "ok", result: parsed },
131
+ details: undefined,
136
132
  };
137
133
  }
138
134
 
@@ -179,7 +175,7 @@ export default function sessionManagerExtension(pi: ExtensionAPI): void {
179
175
  registerSessionTool(pi, {
180
176
  name: "create_managed_session",
181
177
  label: "Create Managed Session",
182
- description: "Create a new agent-managed session in the specified working directory. Optionally provide an initial prompt, which is sent immediately (new sessions are always idle, so it is delivered directly). Returns a session ID and initial status.",
178
+ description: "Create a new agent-managed session in the specified working directory. Optionally provide an initial prompt, which is sent immediately (new sessions are always idle, so it is delivered directly). Returns a session ID and initial status. Requires the xyz-agent desktop runtime; standalone pi CLI will time out.",
183
179
  parameters: CreateManagedSessionParams,
184
180
  action: "create",
185
181
  toParams: (p) => ({ cwd: p.cwd, label: p.label, prompt: p.prompt }),
@@ -188,7 +184,7 @@ export default function sessionManagerExtension(pi: ExtensionAPI): void {
188
184
  registerSessionTool(pi, {
189
185
  name: "send_to_session",
190
186
  label: "Send to Session",
191
- description: "Send a prompt/message to an existing managed session. The message is asynchronously queued: if the target session is busy (generating/compacting/running bash) it is delivered at its next turn boundary, and {queued: true} is returned immediately. On synchronous failure the tool returns an error result (isError) with a hint (check get_session_status, then retry).",
187
+ description: "Send a prompt/message to an existing managed session. The message is asynchronously queued: if the target session is busy (generating/compacting/running bash) it is delivered at its next turn boundary, and {queued: true} is returned immediately. On synchronous failure the tool returns an error result (isError) with a hint (check get_session_status, then retry). Requires the xyz-agent desktop runtime; standalone pi CLI will time out.",
192
188
  parameters: SendToSessionParams,
193
189
  action: "send",
194
190
  toParams: (p) => ({ sessionId: p.sessionId, prompt: p.prompt }),
@@ -197,7 +193,7 @@ export default function sessionManagerExtension(pi: ExtensionAPI): void {
197
193
  registerSessionTool(pi, {
198
194
  name: "read_session_history",
199
195
  label: "Read Session History",
200
- description: "Read the conversation history of a managed session. Optionally limit to the last N turns.",
196
+ description: "Read the conversation history of a managed session. Optionally limit to the last N turns. Requires the xyz-agent desktop runtime; standalone pi CLI will time out.",
201
197
  parameters: ReadSessionHistoryParams,
202
198
  action: "history",
203
199
  toParams: (p) => ({ sessionId: p.sessionId, tailTurns: p.tailTurns }),
@@ -206,7 +202,7 @@ export default function sessionManagerExtension(pi: ExtensionAPI): void {
206
202
  registerSessionTool(pi, {
207
203
  name: "list_my_sessions",
208
204
  label: "List My Sessions",
209
- description: "List all sessions managed by the current agent. Returns session IDs, labels, and statuses.",
205
+ description: "List all sessions managed by the current agent. Returns session IDs, labels, and statuses. Requires the xyz-agent desktop runtime; standalone pi CLI will time out.",
210
206
  parameters: ListMySessionsParams,
211
207
  action: "list",
212
208
  toParams: () => ({}),
@@ -215,7 +211,7 @@ export default function sessionManagerExtension(pi: ExtensionAPI): void {
215
211
  registerSessionTool(pi, {
216
212
  name: "get_session_status",
217
213
  label: "Get Session Status",
218
- description: "Get the current status of a managed session (active, idle, error, etc.) and its model info.",
214
+ description: "Get the current status of a managed session (active, idle, error, etc.) and its model info. Requires the xyz-agent desktop runtime; standalone pi CLI will time out.",
219
215
  parameters: GetSessionStatusParams,
220
216
  action: "status",
221
217
  toParams: (p) => ({ sessionId: p.sessionId }),
@@ -224,7 +220,7 @@ export default function sessionManagerExtension(pi: ExtensionAPI): void {
224
220
  registerSessionTool(pi, {
225
221
  name: "abort_session",
226
222
  label: "Abort Session",
227
- description: "Abort a running managed session. The session stops processing; its final status will be 'stopped'.",
223
+ description: "Abort a running managed session. The session stops processing; its final status will be 'stopped'. Requires the xyz-agent desktop runtime; standalone pi CLI will time out.",
228
224
  parameters: AbortSessionParams,
229
225
  action: "abort",
230
226
  toParams: (p) => ({ sessionId: p.sessionId }),