@zhushanwen/pi-session-manager 0.1.1
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 +30 -0
- package/index.ts +1 -0
- package/package.json +53 -0
- package/src/__tests__/tool-error-handling.test.ts +99 -0
- package/src/__tests__/tool-execute.test.ts +102 -0
- package/src/__tests__/tool-registration.test.ts +72 -0
- package/src/__tests__/tool-schema.test.ts +115 -0
- package/src/index.ts +232 -0
package/README.md
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# @zhushanwen/pi-session-manager
|
|
2
|
+
|
|
3
|
+
agent-managed session pi extension:把 session 的创建/发送/读取/列表/状态/中止交给 agent 自主管理——agent 通过 6 个工具以子 session 形态并行工作,父 session 统一编排。
|
|
4
|
+
|
|
5
|
+
## 通道契约
|
|
6
|
+
|
|
7
|
+
- 工具调用经 `ctx.ui.select(SESSION_MANAGER_MARKER, [JSON], {timeout: 30s})` 发出;marker 为 `\x00XYZ_SESSION_MANAGER`(NUL 前缀防与普通 select title 冲突,SSOT 在 `@xyz-agent/extension-protocol`)
|
|
8
|
+
- 请求体为嵌套形状 `{ action, params }`(`SessionManagerRequest` 协议类型);**不要扁平化展开**——runtime event-adapter 按 `data.params` 提取,扁平化会导致 params 丢失
|
|
9
|
+
- 应答方是 xyz-agent runtime 的 `SessionManagerHandler`(select value 通道回写 JSON 字符串;取消/超时返回 null)
|
|
10
|
+
|
|
11
|
+
## 工具(6 个 action)
|
|
12
|
+
|
|
13
|
+
| 工具 | action | 说明 |
|
|
14
|
+
|------|--------|------|
|
|
15
|
+
| `create_managed_session` | create | 在指定 cwd 创建子 session(可选 label);服务端注入 `spawnSource: 'agent'` 与父 session id,`.agent.json` sidecar 落盘供重启恢复 |
|
|
16
|
+
| `send_to_session` | send | 向子 session 发送 prompt(异步处理) |
|
|
17
|
+
| `read_session_history` | history | 读子 session 对话历史(可选 tailTurns 截尾部 N 个 turn) |
|
|
18
|
+
| `list_my_sessions` | list | 列出本 agent 管理的 session(可按 spawnSource / parentAgentSessionId 过滤) |
|
|
19
|
+
| `get_session_status` | status | 查询子 session 状态与模型信息 |
|
|
20
|
+
| `abort_session` | abort | 中止运行中的子 session |
|
|
21
|
+
|
|
22
|
+
## 运行要求
|
|
23
|
+
|
|
24
|
+
应答端(runtime handler)在 xyz-agent 桌面应用内;独立 pi CLI 环境无 handler 时工具将等待至 30s 超时并返回 cancelled。
|
|
25
|
+
|
|
26
|
+
## 测试
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
cd extensions/universal/session-manager && npx vitest run
|
|
30
|
+
```
|
package/index.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { default } from "./src/index.ts";
|
package/package.json
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@zhushanwen/pi-session-manager",
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "Pi extension for managing agent-managed sessions — create, send, read history, list, status, abort via ctx.ui.select channel.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "index.ts",
|
|
7
|
+
"pi": {
|
|
8
|
+
"extensions": [
|
|
9
|
+
"./index.ts"
|
|
10
|
+
]
|
|
11
|
+
},
|
|
12
|
+
"xyz-agent": {
|
|
13
|
+
"role": "universal"
|
|
14
|
+
},
|
|
15
|
+
"keywords": [
|
|
16
|
+
"pi-package",
|
|
17
|
+
"pi",
|
|
18
|
+
"pi-coding-agent",
|
|
19
|
+
"extension",
|
|
20
|
+
"session-manager"
|
|
21
|
+
],
|
|
22
|
+
"license": "MIT",
|
|
23
|
+
"files": [
|
|
24
|
+
"index.ts",
|
|
25
|
+
"src/",
|
|
26
|
+
"README.md"
|
|
27
|
+
],
|
|
28
|
+
"dependencies": {
|
|
29
|
+
"@xyz-agent/extension-protocol": "0.6.0",
|
|
30
|
+
"@zhushanwen/pi-extension-logger": "0.2.2"
|
|
31
|
+
},
|
|
32
|
+
"devDependencies": {
|
|
33
|
+
"@types/node": "^24.0.0",
|
|
34
|
+
"@vitest/coverage-v8": "^4.1.9",
|
|
35
|
+
"vitest": "^4.1.8"
|
|
36
|
+
},
|
|
37
|
+
"peerDependencies": {
|
|
38
|
+
"@earendil-works/pi-coding-agent": "*",
|
|
39
|
+
"typebox": "*"
|
|
40
|
+
},
|
|
41
|
+
"peerDependenciesMeta": {
|
|
42
|
+
"@earendil-works/pi-coding-agent": {
|
|
43
|
+
"optional": true
|
|
44
|
+
},
|
|
45
|
+
"typebox": {
|
|
46
|
+
"optional": true
|
|
47
|
+
}
|
|
48
|
+
},
|
|
49
|
+
"scripts": {
|
|
50
|
+
"typecheck": "npx tsc --noEmit",
|
|
51
|
+
"test": "vitest run"
|
|
52
|
+
}
|
|
53
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
// tool-error-handling.test.ts — U5-A4: null/undefined returns → cancelled result; exceptions → caught error result
|
|
2
|
+
|
|
3
|
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
4
|
+
import registerExtension from "../index.ts";
|
|
5
|
+
|
|
6
|
+
function createHarness(selectImpl: (...args: unknown[]) => Promise<unknown>) {
|
|
7
|
+
const registered: Array<{ name: string; execute: Function }> = [];
|
|
8
|
+
const selectMock = vi.fn(selectImpl);
|
|
9
|
+
const pi = {
|
|
10
|
+
registerTool: (tool: { name: string; execute: Function }) => registered.push(tool),
|
|
11
|
+
on: vi.fn(),
|
|
12
|
+
getAllTools: vi.fn(() => []),
|
|
13
|
+
setActiveTools: vi.fn(),
|
|
14
|
+
};
|
|
15
|
+
const ctx = {
|
|
16
|
+
mode: "rpc" as const,
|
|
17
|
+
hasUI: true,
|
|
18
|
+
ui: { select: selectMock },
|
|
19
|
+
};
|
|
20
|
+
registerExtension(pi as never);
|
|
21
|
+
return { registered, selectMock, ctx };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
describe("U5-A4 tool-error-handling", () => {
|
|
25
|
+
it("select returning undefined (user cancel/timeout) → cancelled result, no throw", async () => {
|
|
26
|
+
const { registered, ctx } = createHarness(vi.fn().mockResolvedValue(undefined));
|
|
27
|
+
const tool = registered.find((t) => t.name === "create_managed_session")!;
|
|
28
|
+
const result = await tool.execute("call-1", { cwd: "/tmp" }, undefined, undefined, ctx);
|
|
29
|
+
expect(result.content).toHaveLength(1);
|
|
30
|
+
expect(result.content[0].text).toContain("cancelled");
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("select returning null → cancelled result, no throw", async () => {
|
|
34
|
+
const { registered, ctx } = createHarness(vi.fn().mockResolvedValue(null));
|
|
35
|
+
const tool = registered.find((t) => t.name === "send_to_session")!;
|
|
36
|
+
const result = await tool.execute("call-1", { sessionId: "s1", prompt: "hi" }, undefined, undefined, ctx);
|
|
37
|
+
expect(result.content).toHaveLength(1);
|
|
38
|
+
expect(result.content[0].text).toContain("cancelled");
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("select throwing exception → caught, returns error result, no throw", async () => {
|
|
42
|
+
const { registered, ctx } = createHarness(vi.fn().mockRejectedValue(new Error("channel closed")));
|
|
43
|
+
const tool = registered.find((t) => t.name === "get_session_status")!;
|
|
44
|
+
const result = await tool.execute("call-1", { sessionId: "s1" }, undefined, undefined, ctx);
|
|
45
|
+
expect(result.content).toHaveLength(1);
|
|
46
|
+
expect(result.content[0].text).toContain("cancelled");
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("select throwing non-Error → caught, returns error result, no throw", async () => {
|
|
50
|
+
const { registered, ctx } = createHarness(vi.fn().mockRejectedValue("string error"));
|
|
51
|
+
const tool = registered.find((t) => t.name === "abort_session")!;
|
|
52
|
+
const result = await tool.execute("call-1", { sessionId: "s1" }, undefined, undefined, ctx);
|
|
53
|
+
expect(result.content).toHaveLength(1);
|
|
54
|
+
expect(result.content[0].text).toContain("cancelled");
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("runtime respond 携带 {error} JSON → isError: true(禁止错误成功模式)", async () => {
|
|
58
|
+
const { registered, ctx } = createHarness(
|
|
59
|
+
vi.fn().mockResolvedValue(JSON.stringify({ error: "session unreachable", hint: "check get_session_status" })),
|
|
60
|
+
);
|
|
61
|
+
const tool = registered.find((t) => t.name === "send_to_session")!;
|
|
62
|
+
const result = await tool.execute("call-1", { sessionId: "s1", prompt: "hi" }, undefined, undefined, ctx);
|
|
63
|
+
expect(result.isError).toBe(true);
|
|
64
|
+
expect(result.content[0].text).toContain("session unreachable");
|
|
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
|
+
});
|
|
71
|
+
|
|
72
|
+
it("runtime respond 正常 JSON → 无 isError,details kind=ok", async () => {
|
|
73
|
+
const { registered, ctx } = createHarness(
|
|
74
|
+
vi.fn().mockResolvedValue(JSON.stringify({ queued: true })),
|
|
75
|
+
);
|
|
76
|
+
const tool = registered.find((t) => t.name === "send_to_session")!;
|
|
77
|
+
const result = await tool.execute("call-1", { sessionId: "s1", prompt: "hi" }, undefined, undefined, ctx);
|
|
78
|
+
expect(result.isError).toBeUndefined();
|
|
79
|
+
expect(result.details).toEqual({ kind: "ok", result: { queued: true } });
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("all 6 tools handle null gracefully", async () => {
|
|
83
|
+
const { registered, ctx } = createHarness(vi.fn().mockResolvedValue(null));
|
|
84
|
+
for (const tool of registered) {
|
|
85
|
+
const params = tool.name === "create_managed_session"
|
|
86
|
+
? { cwd: "/tmp" }
|
|
87
|
+
: tool.name === "send_to_session"
|
|
88
|
+
? { sessionId: "s1", prompt: "hi" }
|
|
89
|
+
: tool.name === "read_session_history"
|
|
90
|
+
? { sessionId: "s1" }
|
|
91
|
+
: tool.name === "list_my_sessions"
|
|
92
|
+
? {}
|
|
93
|
+
: { sessionId: "s1" };
|
|
94
|
+
const result = await tool.execute("call-1", params, undefined, undefined, ctx);
|
|
95
|
+
expect(result.content).toHaveLength(1);
|
|
96
|
+
expect(result.content[0].text).toContain("cancelled");
|
|
97
|
+
}
|
|
98
|
+
});
|
|
99
|
+
});
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
// tool-execute.test.ts — U5-A2: each tool's execute calls ctx.ui.select with SESSION_MANAGER_MARKER + JSON payload
|
|
2
|
+
|
|
3
|
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
4
|
+
import { SESSION_MANAGER_MARKER } from "@xyz-agent/extension-protocol";
|
|
5
|
+
import registerExtension from "../index.ts";
|
|
6
|
+
|
|
7
|
+
/** Capture registered tools and provide a mock ctx.ui.select. */
|
|
8
|
+
function createHarness() {
|
|
9
|
+
const registered: Array<{ name: string; execute: Function }> = [];
|
|
10
|
+
const selectMock = vi.fn().mockResolvedValue('{"ok":true}');
|
|
11
|
+
const pi = {
|
|
12
|
+
registerTool: (tool: { name: string; execute: Function }) => registered.push(tool),
|
|
13
|
+
on: vi.fn(),
|
|
14
|
+
getAllTools: vi.fn(() => []),
|
|
15
|
+
setActiveTools: vi.fn(),
|
|
16
|
+
};
|
|
17
|
+
const ctx = {
|
|
18
|
+
mode: "rpc" as const,
|
|
19
|
+
hasUI: true,
|
|
20
|
+
ui: { select: selectMock },
|
|
21
|
+
};
|
|
22
|
+
registerExtension(pi as never);
|
|
23
|
+
return { registered, selectMock, ctx };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
describe("U5-A2 tool-execute", () => {
|
|
27
|
+
let harness: ReturnType<typeof createHarness>;
|
|
28
|
+
|
|
29
|
+
beforeEach(() => {
|
|
30
|
+
harness = createHarness();
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it.each([
|
|
34
|
+
["create_managed_session", "create", { cwd: "/tmp" }],
|
|
35
|
+
["send_to_session", "send", { sessionId: "s1", prompt: "hello" }],
|
|
36
|
+
["read_session_history", "history", { sessionId: "s1" }],
|
|
37
|
+
["list_my_sessions", "list", {}],
|
|
38
|
+
["get_session_status", "status", { sessionId: "s1" }],
|
|
39
|
+
["abort_session", "abort", { sessionId: "s1" }],
|
|
40
|
+
])('tool "%s" calls select with action="%s"', async (toolName, expectedAction, params) => {
|
|
41
|
+
const tool = harness.registered.find((t) => t.name === toolName)!;
|
|
42
|
+
harness.selectMock.mockClear();
|
|
43
|
+
harness.selectMock.mockResolvedValue('{"ok":true}');
|
|
44
|
+
|
|
45
|
+
await tool.execute("call-1", params, undefined, undefined, harness.ctx);
|
|
46
|
+
|
|
47
|
+
expect(harness.selectMock).toHaveBeenCalledTimes(1);
|
|
48
|
+
const [marker, options, opts] = harness.selectMock.mock.calls[0];
|
|
49
|
+
// first arg = SESSION_MANAGER_MARKER
|
|
50
|
+
expect(marker).toBe(SESSION_MANAGER_MARKER);
|
|
51
|
+
// second arg = string array with JSON payload containing action
|
|
52
|
+
expect(Array.isArray(options)).toBe(true);
|
|
53
|
+
expect(options).toHaveLength(1);
|
|
54
|
+
const payload = JSON.parse(options[0]);
|
|
55
|
+
expect(payload.action).toBe(expectedAction);
|
|
56
|
+
// third arg has timeout
|
|
57
|
+
expect(opts).toHaveProperty("timeout");
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("create_managed_session includes cwd and label in payload.params", async () => {
|
|
61
|
+
const tool = harness.registered.find((t) => t.name === "create_managed_session")!;
|
|
62
|
+
harness.selectMock.mockResolvedValue('{"sessionId":"s1"}');
|
|
63
|
+
await tool.execute("call-1", { cwd: "/work", label: "test" }, undefined, undefined, harness.ctx);
|
|
64
|
+
const payload = JSON.parse(harness.selectMock.mock.calls[0][1][0]);
|
|
65
|
+
expect(payload.params.cwd).toBe("/work");
|
|
66
|
+
expect(payload.params.label).toBe("test");
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("send_to_session includes sessionId and prompt in payload.params", async () => {
|
|
70
|
+
const tool = harness.registered.find((t) => t.name === "send_to_session")!;
|
|
71
|
+
harness.selectMock.mockResolvedValue('{"queued":true}');
|
|
72
|
+
await tool.execute("call-1", { sessionId: "s1", prompt: "hi" }, undefined, undefined, harness.ctx);
|
|
73
|
+
const payload = JSON.parse(harness.selectMock.mock.calls[0][1][0]);
|
|
74
|
+
expect(payload.params.sessionId).toBe("s1");
|
|
75
|
+
expect(payload.params.prompt).toBe("hi");
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("read_session_history includes tailTurns in payload.params when provided", async () => {
|
|
79
|
+
const tool = harness.registered.find((t) => t.name === "read_session_history")!;
|
|
80
|
+
harness.selectMock.mockResolvedValue('{"messages":[]}');
|
|
81
|
+
await tool.execute("call-1", { sessionId: "s1", tailTurns: 5 }, undefined, undefined, harness.ctx);
|
|
82
|
+
const payload = JSON.parse(harness.selectMock.mock.calls[0][1][0]);
|
|
83
|
+
expect(payload.params.tailTurns).toBe(5);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("read_session_history omits tailTurns from payload.params when not provided", async () => {
|
|
87
|
+
const tool = harness.registered.find((t) => t.name === "read_session_history")!;
|
|
88
|
+
harness.selectMock.mockResolvedValue('{"messages":[]}');
|
|
89
|
+
await tool.execute("call-1", { sessionId: "s1" }, undefined, undefined, harness.ctx);
|
|
90
|
+
const payload = JSON.parse(harness.selectMock.mock.calls[0][1][0]);
|
|
91
|
+
expect(payload.params.tailTurns).toBeUndefined();
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("select result is returned as text content", async () => {
|
|
95
|
+
const tool = harness.registered.find((t) => t.name === "list_my_sessions")!;
|
|
96
|
+
harness.selectMock.mockResolvedValue('{"sessions":[]}');
|
|
97
|
+
const result = await tool.execute("call-1", {}, undefined, undefined, harness.ctx);
|
|
98
|
+
expect(result.content).toHaveLength(1);
|
|
99
|
+
expect(result.content[0].type).toBe("text");
|
|
100
|
+
expect(result.content[0].text).toBe('{"sessions":[]}');
|
|
101
|
+
});
|
|
102
|
+
});
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// tool-registration.test.ts — U5-A1: 6 tools registered with correct name/description/parameters + SESSION_MANAGER_MARKER import
|
|
2
|
+
|
|
3
|
+
import { describe, it, expect, vi } from "vitest";
|
|
4
|
+
import { SESSION_MANAGER_MARKER } from "@xyz-agent/extension-protocol";
|
|
5
|
+
import registerExtension from "../index.ts";
|
|
6
|
+
|
|
7
|
+
/** Proxy-based mock: capture registerTool calls for assertion. */
|
|
8
|
+
function createMockPi() {
|
|
9
|
+
const registered: Array<{ name: string; description: string; parameters: unknown }> = [];
|
|
10
|
+
const pi = {
|
|
11
|
+
registerTool: (tool: { name: string; description: string; parameters: unknown }) => {
|
|
12
|
+
registered.push(tool);
|
|
13
|
+
},
|
|
14
|
+
on: vi.fn(),
|
|
15
|
+
getAllTools: vi.fn(() => []),
|
|
16
|
+
setActiveTools: vi.fn(),
|
|
17
|
+
};
|
|
18
|
+
return { pi, registered };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const EXPECTED_TOOL_NAMES = [
|
|
22
|
+
"create_managed_session",
|
|
23
|
+
"send_to_session",
|
|
24
|
+
"read_session_history",
|
|
25
|
+
"list_my_sessions",
|
|
26
|
+
"get_session_status",
|
|
27
|
+
"abort_session",
|
|
28
|
+
] as const;
|
|
29
|
+
|
|
30
|
+
describe("U5-A1 tool-registration", () => {
|
|
31
|
+
it("registers exactly 6 tools", () => {
|
|
32
|
+
const { pi, registered } = createMockPi();
|
|
33
|
+
registerExtension(pi as never);
|
|
34
|
+
expect(registered).toHaveLength(6);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it.each(EXPECTED_TOOL_NAMES)('registers tool "%s"', (name) => {
|
|
38
|
+
const { pi, registered } = createMockPi();
|
|
39
|
+
registerExtension(pi as never);
|
|
40
|
+
const tool = registered.find((t) => t.name === name);
|
|
41
|
+
expect(tool).toBeDefined();
|
|
42
|
+
expect(tool!.name).toBe(name);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("every tool has a non-empty description", () => {
|
|
46
|
+
const { pi, registered } = createMockPi();
|
|
47
|
+
registerExtension(pi as never);
|
|
48
|
+
for (const tool of registered) {
|
|
49
|
+
expect(typeof tool.description).toBe("string");
|
|
50
|
+
expect(tool.description.length).toBeGreaterThan(0);
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("every tool has a parameters schema (object type)", () => {
|
|
55
|
+
const { pi, registered } = createMockPi();
|
|
56
|
+
registerExtension(pi as never);
|
|
57
|
+
for (const tool of registered) {
|
|
58
|
+
expect(tool.parameters).toBeDefined();
|
|
59
|
+
const schema = tool.parameters as Record<string, unknown>;
|
|
60
|
+
expect(schema.type).toBe("object");
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("SESSION_MANAGER_MARKER is imported from @xyz-agent/extension-protocol", () => {
|
|
65
|
+
expect(typeof SESSION_MANAGER_MARKER).toBe("string");
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("SESSION_MANAGER_MARKER value is '\\x00XYZ_SESSION_MANAGER' (NUL prefix, 20 chars)", () => {
|
|
69
|
+
expect(SESSION_MANAGER_MARKER).toBe("\x00XYZ_SESSION_MANAGER");
|
|
70
|
+
expect(SESSION_MANAGER_MARKER).toHaveLength(20);
|
|
71
|
+
});
|
|
72
|
+
});
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
// tool-schema.test.ts — U5-A3: verify parameter schemas match spec requirements
|
|
2
|
+
|
|
3
|
+
import { describe, it, expect } from "vitest";
|
|
4
|
+
import registerExtension from "../index.ts";
|
|
5
|
+
|
|
6
|
+
function getRegisteredSchemas() {
|
|
7
|
+
const registered: Array<{ name: string; parameters: Record<string, unknown> }> = [];
|
|
8
|
+
const pi = {
|
|
9
|
+
registerTool: (tool: { name: string; parameters: Record<string, unknown> }) => registered.push(tool),
|
|
10
|
+
on: () => {},
|
|
11
|
+
getAllTools: () => [],
|
|
12
|
+
setActiveTools: () => {},
|
|
13
|
+
};
|
|
14
|
+
registerExtension(pi as never);
|
|
15
|
+
return Object.fromEntries(registered.map((t) => [t.name, t.parameters]));
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
describe("U5-A3 tool-schema", () => {
|
|
19
|
+
const schemas = getRegisteredSchemas();
|
|
20
|
+
|
|
21
|
+
describe("create_managed_session", () => {
|
|
22
|
+
it("requires cwd (string)", () => {
|
|
23
|
+
const s = schemas.create_managed_session;
|
|
24
|
+
expect(s.type).toBe("object");
|
|
25
|
+
const props = s.properties as Record<string, unknown>;
|
|
26
|
+
expect(props.cwd).toBeDefined();
|
|
27
|
+
expect((props.cwd as Record<string, unknown>).type).toBe("string");
|
|
28
|
+
const required = s.required as string[];
|
|
29
|
+
expect(required).toContain("cwd");
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("has optional label (string)", () => {
|
|
33
|
+
const props = schemas.create_managed_session.properties as Record<string, unknown>;
|
|
34
|
+
expect(props.label).toBeDefined();
|
|
35
|
+
expect((props.label as Record<string, unknown>).type).toBe("string");
|
|
36
|
+
const required = schemas.create_managed_session.required as string[];
|
|
37
|
+
expect(required).not.toContain("label");
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("has optional prompt (string) — sd-u5: create 带 prompt 的工具 schema 暴露", () => {
|
|
41
|
+
const props = schemas.create_managed_session.properties as Record<string, unknown>;
|
|
42
|
+
expect(props.prompt).toBeDefined();
|
|
43
|
+
expect((props.prompt as Record<string, unknown>).type).toBe("string");
|
|
44
|
+
const required = schemas.create_managed_session.required as string[];
|
|
45
|
+
expect(required).not.toContain("prompt");
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
describe("send_to_session", () => {
|
|
50
|
+
it("requires sessionId (string) and prompt (string)", () => {
|
|
51
|
+
const s = schemas.send_to_session;
|
|
52
|
+
const props = s.properties as Record<string, unknown>;
|
|
53
|
+
expect(props.sessionId).toBeDefined();
|
|
54
|
+
expect((props.sessionId as Record<string, unknown>).type).toBe("string");
|
|
55
|
+
expect(props.prompt).toBeDefined();
|
|
56
|
+
expect((props.prompt as Record<string, unknown>).type).toBe("string");
|
|
57
|
+
const required = s.required as string[];
|
|
58
|
+
expect(required).toContain("sessionId");
|
|
59
|
+
expect(required).toContain("prompt");
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
describe("read_session_history", () => {
|
|
64
|
+
it("requires sessionId (string)", () => {
|
|
65
|
+
const s = schemas.read_session_history;
|
|
66
|
+
const props = s.properties as Record<string, unknown>;
|
|
67
|
+
expect(props.sessionId).toBeDefined();
|
|
68
|
+
expect((props.sessionId as Record<string, unknown>).type).toBe("string");
|
|
69
|
+
const required = s.required as string[];
|
|
70
|
+
expect(required).toContain("sessionId");
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("has optional tailTurns (number)", () => {
|
|
74
|
+
const props = schemas.read_session_history.properties as Record<string, unknown>;
|
|
75
|
+
expect(props.tailTurns).toBeDefined();
|
|
76
|
+
expect((props.tailTurns as Record<string, unknown>).type).toBe("number");
|
|
77
|
+
const required = schemas.read_session_history.required as string[];
|
|
78
|
+
expect(required).not.toContain("tailTurns");
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
describe("list_my_sessions", () => {
|
|
83
|
+
it("has no required parameters", () => {
|
|
84
|
+
const s = schemas.list_my_sessions;
|
|
85
|
+
expect(s.type).toBe("object");
|
|
86
|
+
const required = s.required as string[] | undefined;
|
|
87
|
+
// No required fields (empty object or no required array)
|
|
88
|
+
if (required) {
|
|
89
|
+
expect(required).toHaveLength(0);
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
describe("get_session_status", () => {
|
|
95
|
+
it("requires sessionId (string)", () => {
|
|
96
|
+
const s = schemas.get_session_status;
|
|
97
|
+
const props = s.properties as Record<string, unknown>;
|
|
98
|
+
expect(props.sessionId).toBeDefined();
|
|
99
|
+
expect((props.sessionId as Record<string, unknown>).type).toBe("string");
|
|
100
|
+
const required = s.required as string[];
|
|
101
|
+
expect(required).toContain("sessionId");
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
describe("abort_session", () => {
|
|
106
|
+
it("requires sessionId (string)", () => {
|
|
107
|
+
const s = schemas.abort_session;
|
|
108
|
+
const props = s.properties as Record<string, unknown>;
|
|
109
|
+
expect(props.sessionId).toBeDefined();
|
|
110
|
+
expect((props.sessionId as Record<string, unknown>).type).toBe("string");
|
|
111
|
+
const required = s.required as string[];
|
|
112
|
+
expect(required).toContain("sessionId");
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
});
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
// src/index.ts — @zhushanwen/pi-session-manager
|
|
2
|
+
// 6 个 session 管理工具,通过 ctx.ui.select(SESSION_MANAGER_MARKER) 通道与 runtime handler 通信。
|
|
3
|
+
|
|
4
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
5
|
+
import { SESSION_MANAGER_MARKER, type SessionManagerAction } from "@xyz-agent/extension-protocol";
|
|
6
|
+
import { getLogger, setPiHandle } from "@zhushanwen/pi-extension-logger";
|
|
7
|
+
import { Type, type Static, type TObject } from "typebox";
|
|
8
|
+
|
|
9
|
+
// 模块级 logger(default export 首行 setPiHandle 注入后自动走 appendEntry 持久化,
|
|
10
|
+
// 注入前/失败降级文件日志——见 extension-logger 三层通道设计)
|
|
11
|
+
const logger = getLogger("session-manager");
|
|
12
|
+
|
|
13
|
+
// ── 参数 Schema ──
|
|
14
|
+
|
|
15
|
+
const CreateManagedSessionParams = Type.Object({
|
|
16
|
+
cwd: Type.String({ description: "Working directory for the new session" }),
|
|
17
|
+
label: Type.Optional(Type.String({ description: "Human-readable label for the session" })),
|
|
18
|
+
prompt: Type.Optional(Type.String({ description: "Initial prompt injected right after creation (atomic create+send)" })),
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
const SendToSessionParams = Type.Object({
|
|
22
|
+
sessionId: Type.String({ description: "Target session ID" }),
|
|
23
|
+
prompt: Type.String({ description: "Message to send to the session" }),
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
const ReadSessionHistoryParams = Type.Object({
|
|
27
|
+
sessionId: Type.String({ description: "Target session ID" }),
|
|
28
|
+
tailTurns: Type.Optional(Type.Number({ description: "Number of recent turns to return (default: all)" })),
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
const ListMySessionsParams = Type.Object({});
|
|
32
|
+
|
|
33
|
+
const GetSessionStatusParams = Type.Object({
|
|
34
|
+
sessionId: Type.String({ description: "Target session ID" }),
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
const AbortSessionParams = Type.Object({
|
|
38
|
+
sessionId: Type.String({ description: "Target session ID to abort" }),
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
// ── select 通道辅助 ──
|
|
42
|
+
|
|
43
|
+
/** select 超时(ms):工具等待 runtime handler respond 的最大时间(read/create 走长链路放宽) */
|
|
44
|
+
const SELECT_TIMEOUT_MS: Record<SessionManagerAction, number> = {
|
|
45
|
+
create: 60_000,
|
|
46
|
+
send: 30_000,
|
|
47
|
+
history: 60_000,
|
|
48
|
+
status: 30_000,
|
|
49
|
+
list: 30_000,
|
|
50
|
+
abort: 30_000,
|
|
51
|
+
};
|
|
52
|
+
|
|
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
|
+
/**
|
|
67
|
+
* 通过 select 通道向 runtime handler 发送 session 管理请求。
|
|
68
|
+
* 返回 handler respond 的 JSON 字符串,用户取消/超时返回 null。
|
|
69
|
+
*/
|
|
70
|
+
async function callSessionManager(
|
|
71
|
+
ctx: ExtensionContext,
|
|
72
|
+
action: SessionManagerAction,
|
|
73
|
+
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
|
|
77
|
+
// 分支按 data.params 提取——若扁平化展开({action, ...params})params 会丢失变 {}。
|
|
78
|
+
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
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* 统一的 execute 包装:调用 select 通道并解析结果。
|
|
98
|
+
* 返回标准 AgentToolResult 形状;select 取消/超时/异常是错误路径,
|
|
99
|
+
* 必须带 isError: true(extension-conventions「禁止错误成功模式」——
|
|
100
|
+
* 调用方 agent 需能区分成功与失败以决定重试/放弃)。
|
|
101
|
+
*/
|
|
102
|
+
async function executeTool(
|
|
103
|
+
ctx: ExtensionContext,
|
|
104
|
+
action: SessionManagerAction,
|
|
105
|
+
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) {
|
|
109
|
+
return {
|
|
110
|
+
isError: true,
|
|
111
|
+
content: [{ type: "text" as const, text: `Session manager ${action}: cancelled or timed out.` }],
|
|
112
|
+
details: { kind: "cancelled" },
|
|
113
|
+
};
|
|
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;
|
|
127
|
+
return {
|
|
128
|
+
isError: true,
|
|
129
|
+
content: [{ type: "text" as const, text }],
|
|
130
|
+
details: { kind: "error", error: err },
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
return {
|
|
134
|
+
content: [{ type: "text" as const, text: raw }],
|
|
135
|
+
details: { kind: "ok", result: parsed },
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// ── Extension 入口 ──
|
|
140
|
+
|
|
141
|
+
/** 单个 session 工具的声明式配置(registerSessionTool 的输入)。 */
|
|
142
|
+
interface SessionToolConfig<S extends TObject> {
|
|
143
|
+
name: string
|
|
144
|
+
label: string
|
|
145
|
+
description: string
|
|
146
|
+
parameters: S
|
|
147
|
+
action: SessionManagerAction
|
|
148
|
+
/** schema params → 协议 params 的映射(undefined 字段由 JSON.stringify 丢弃) */
|
|
149
|
+
toParams: (params: Static<S>) => Record<string, unknown>
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* 注册一个 session 管理工具。6 个工具共用同一 execute 骨架——
|
|
154
|
+
* 统一忽略 signal/onUpdate(session 管理是单次请求-响应,无流式更新),
|
|
155
|
+
* 不 ctx.ui 交互(走 marker select 通道,不弹用户 UI)。
|
|
156
|
+
*/
|
|
157
|
+
function registerSessionTool<S extends TObject>(pi: ExtensionAPI, cfg: SessionToolConfig<S>): void {
|
|
158
|
+
pi.registerTool({
|
|
159
|
+
name: cfg.name,
|
|
160
|
+
label: cfg.label,
|
|
161
|
+
description: cfg.description,
|
|
162
|
+
parameters: cfg.parameters,
|
|
163
|
+
async execute(
|
|
164
|
+
_toolCallId: string,
|
|
165
|
+
params: Static<S>,
|
|
166
|
+
_signal: AbortSignal | undefined,
|
|
167
|
+
_onUpdate: unknown,
|
|
168
|
+
ctx: ExtensionContext,
|
|
169
|
+
) {
|
|
170
|
+
return executeTool(ctx, cfg.action, cfg.toParams(params));
|
|
171
|
+
},
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export default function sessionManagerExtension(pi: ExtensionAPI): void {
|
|
176
|
+
// logger 持久化通道接入(appendEntry custom entry,不进 LLM 上下文)
|
|
177
|
+
setPiHandle(pi);
|
|
178
|
+
|
|
179
|
+
registerSessionTool(pi, {
|
|
180
|
+
name: "create_managed_session",
|
|
181
|
+
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.",
|
|
183
|
+
parameters: CreateManagedSessionParams,
|
|
184
|
+
action: "create",
|
|
185
|
+
toParams: (p) => ({ cwd: p.cwd, label: p.label, prompt: p.prompt }),
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
registerSessionTool(pi, {
|
|
189
|
+
name: "send_to_session",
|
|
190
|
+
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).",
|
|
192
|
+
parameters: SendToSessionParams,
|
|
193
|
+
action: "send",
|
|
194
|
+
toParams: (p) => ({ sessionId: p.sessionId, prompt: p.prompt }),
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
registerSessionTool(pi, {
|
|
198
|
+
name: "read_session_history",
|
|
199
|
+
label: "Read Session History",
|
|
200
|
+
description: "Read the conversation history of a managed session. Optionally limit to the last N turns.",
|
|
201
|
+
parameters: ReadSessionHistoryParams,
|
|
202
|
+
action: "history",
|
|
203
|
+
toParams: (p) => ({ sessionId: p.sessionId, tailTurns: p.tailTurns }),
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
registerSessionTool(pi, {
|
|
207
|
+
name: "list_my_sessions",
|
|
208
|
+
label: "List My Sessions",
|
|
209
|
+
description: "List all sessions managed by the current agent. Returns session IDs, labels, and statuses.",
|
|
210
|
+
parameters: ListMySessionsParams,
|
|
211
|
+
action: "list",
|
|
212
|
+
toParams: () => ({}),
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
registerSessionTool(pi, {
|
|
216
|
+
name: "get_session_status",
|
|
217
|
+
label: "Get Session Status",
|
|
218
|
+
description: "Get the current status of a managed session (running, idle, error, etc.) and its model info.",
|
|
219
|
+
parameters: GetSessionStatusParams,
|
|
220
|
+
action: "status",
|
|
221
|
+
toParams: (p) => ({ sessionId: p.sessionId }),
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
registerSessionTool(pi, {
|
|
225
|
+
name: "abort_session",
|
|
226
|
+
label: "Abort Session",
|
|
227
|
+
description: "Abort a running managed session. The session will stop processing and enter aborted state.",
|
|
228
|
+
parameters: AbortSessionParams,
|
|
229
|
+
action: "abort",
|
|
230
|
+
toParams: (p) => ({ sessionId: p.sessionId }),
|
|
231
|
+
});
|
|
232
|
+
}
|