@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 }
|
|
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.
|
|
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.
|
|
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
|
|
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 {
|
|
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
|
-
*
|
|
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
|
-
|
|
66
|
+
function callSessionManager(
|
|
71
67
|
ctx: ExtensionContext,
|
|
72
68
|
action: SessionManagerAction,
|
|
73
69
|
params: Record<string, unknown>,
|
|
74
|
-
): Promise<
|
|
75
|
-
// 契约 SSOT
|
|
76
|
-
// session-manager
|
|
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
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
logger.error(`[session-manager]
|
|
90
|
-
|
|
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:
|
|
107
|
-
const
|
|
108
|
-
if (
|
|
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
|
|
112
|
-
details:
|
|
111
|
+
content: [{ type: "text" as const, text }],
|
|
112
|
+
details: undefined,
|
|
113
113
|
};
|
|
114
114
|
}
|
|
115
|
-
|
|
116
|
-
//
|
|
117
|
-
//
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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:
|
|
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:
|
|
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 }),
|