@zhushanwen/pi-todo 0.7.0 → 0.7.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/package.json +1 -1
- package/src/__tests__/schema.test.ts +45 -33
- package/src/__tests__/tool-detectors.test.ts +15 -1
- package/src/tool.ts +41 -72
package/package.json
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
1
|
-
// Schema
|
|
2
|
-
//
|
|
3
|
-
// 验证:缺失必填、多余字段、已删除的 action 都在 schema 层被拒绝,不依赖运行时 throw。
|
|
1
|
+
// Schema 顶层合规回归(OpenAI function calling:parameters 顶层必须是 type:"object",
|
|
2
|
+
// 顶层 union 会被严格网关 400 拒绝整个会话启动)。
|
|
4
3
|
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
4
|
+
// 扁平化后 TodoParams 是单一 Type.Object + action 字段级 union(参考 scheduler 的
|
|
5
|
+
// ScheduleControlParams)。语义变更:所有非 action 字段都是 Optional,缺失必填
|
|
6
|
+
// (如 {action:'add'} 缺 texts、delete 缺 ids)不再被 schema 拒绝——改由 handler 运行时
|
|
7
|
+
// 校验(见 tool-detectors.test.ts)。schema 仍强约束:action 枚举、status 枚举、
|
|
8
|
+
// additionalProperties:false(拒绝未知字段)。
|
|
9
|
+
//
|
|
10
|
+
// 选 Value.Check 而非 ajv:typebox 自带 Value 校验器与其 schema 语义一致。
|
|
8
11
|
|
|
9
12
|
import { describe, expect, it } from "vitest";
|
|
10
13
|
|
|
@@ -12,9 +15,21 @@ import { Value } from "typebox/value";
|
|
|
12
15
|
|
|
13
16
|
import { TodoParams } from "../tool";
|
|
14
17
|
|
|
15
|
-
describe("TodoParams
|
|
18
|
+
describe("TodoParams 扁平 schema(顶层 type:object 合规)", () => {
|
|
19
|
+
describe("顶层合规(OpenAI function calling)", () => {
|
|
20
|
+
it("type === object(非顶层 union)", () => {
|
|
21
|
+
expect(TodoParams.type).toBe("object");
|
|
22
|
+
});
|
|
23
|
+
it("无顶层 anyOf(discriminated union 已消除)", () => {
|
|
24
|
+
expect(TodoParams.anyOf).toBeUndefined();
|
|
25
|
+
});
|
|
26
|
+
it("additionalProperties: false", () => {
|
|
27
|
+
expect(TodoParams.additionalProperties).toBe(false);
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
|
|
16
31
|
describe("合法 payload 通过", () => {
|
|
17
|
-
it("list
|
|
32
|
+
it("list", () => {
|
|
18
33
|
expect(Value.Check(TodoParams, { action: "list" })).toBe(true);
|
|
19
34
|
});
|
|
20
35
|
it("add + texts", () => {
|
|
@@ -31,34 +46,18 @@ describe("TodoParams discriminated union schema", () => {
|
|
|
31
46
|
});
|
|
32
47
|
});
|
|
33
48
|
|
|
34
|
-
describe("
|
|
35
|
-
it("
|
|
36
|
-
expect(Value.Check(TodoParams, {
|
|
37
|
-
});
|
|
38
|
-
it("update 缺 id 且缺 updates", () => {
|
|
39
|
-
expect(Value.Check(TodoParams, { action: "update" })).toBe(false);
|
|
40
|
-
});
|
|
41
|
-
it("delete 缺 ids", () => {
|
|
42
|
-
expect(Value.Check(TodoParams, { action: "delete" })).toBe(false);
|
|
49
|
+
describe("action 枚举强约束", () => {
|
|
50
|
+
it("缺 action 被拒绝", () => {
|
|
51
|
+
expect(Value.Check(TodoParams, {})).toBe(false);
|
|
43
52
|
});
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
describe("TC3: 已删除的 clear action 被拒绝", () => {
|
|
47
|
-
it("clear 不在 action 枚举内", () => {
|
|
53
|
+
it("未知 action(clear 不在 union 内)被拒绝", () => {
|
|
48
54
|
expect(Value.Check(TodoParams, { action: "clear" })).toBe(false);
|
|
49
55
|
});
|
|
50
56
|
});
|
|
51
57
|
|
|
52
|
-
describe("
|
|
53
|
-
it("
|
|
54
|
-
|
|
55
|
-
expect(Value.Check(TodoParams, { action: "add", texts: ["y"], text: "x" })).toBe(false);
|
|
56
|
-
});
|
|
57
|
-
it("list 携带多余 texts → 拒绝", () => {
|
|
58
|
-
expect(Value.Check(TodoParams, { action: "list", texts: ["x"] })).toBe(false);
|
|
59
|
-
});
|
|
60
|
-
it("update 单条携带多余 ids → 拒绝", () => {
|
|
61
|
-
expect(Value.Check(TodoParams, { action: "update", id: 1, ids: [1] })).toBe(false);
|
|
58
|
+
describe("额外字段被拒绝(additionalProperties:false)", () => {
|
|
59
|
+
it("list 携带未知字段 foo → 拒绝", () => {
|
|
60
|
+
expect(Value.Check(TodoParams, { action: "list", foo: 1 })).toBe(false);
|
|
62
61
|
});
|
|
63
62
|
});
|
|
64
63
|
|
|
@@ -66,11 +65,24 @@ describe("TodoParams discriminated union schema", () => {
|
|
|
66
65
|
it("合法三态 status 通过", () => {
|
|
67
66
|
expect(Value.Check(TodoParams, { action: "update", id: 1, status: "completed" })).toBe(true);
|
|
68
67
|
});
|
|
69
|
-
it("
|
|
68
|
+
it("cancelled 不在 VALID_STATUSES → 拒绝", () => {
|
|
70
69
|
expect(Value.Check(TodoParams, { action: "update", id: 1, status: "cancelled" })).toBe(false);
|
|
71
70
|
});
|
|
72
|
-
it("非法 status
|
|
71
|
+
it("非法 status → 拒绝", () => {
|
|
73
72
|
expect(Value.Check(TodoParams, { action: "update", id: 1, status: "banana" })).toBe(false);
|
|
74
73
|
});
|
|
75
74
|
});
|
|
75
|
+
|
|
76
|
+
describe("缺失必填 / 双形陷阱降级为 handler 运行时校验", () => {
|
|
77
|
+
// 扁平化后 {action:"add"} 缺 texts 不再被 schema 拒绝(texts 是 Optional)。
|
|
78
|
+
// 必填报错改由 handler 运行时校验,见 tool-detectors.test.ts。
|
|
79
|
+
it("{action:'add'} 缺 texts → schema 放行(handler 校验)", () => {
|
|
80
|
+
expect(Value.Check(TodoParams, { action: "add" })).toBe(true);
|
|
81
|
+
});
|
|
82
|
+
// 双形陷阱(add 同时传 text+texts)从 schema 层降级为运行时 handler 检测,
|
|
83
|
+
// 见 tool-detectors.test.ts。
|
|
84
|
+
it("{action:'add', texts:['y'], text:'x'} 双形 → schema 放行(handler 检测)", () => {
|
|
85
|
+
expect(Value.Check(TodoParams, { action: "add", texts: ["y"], text: "x" })).toBe(true);
|
|
86
|
+
});
|
|
87
|
+
});
|
|
76
88
|
});
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
import { describe, expect, it } from "vitest";
|
|
15
15
|
|
|
16
16
|
import { createTodoSessionState } from "../state";
|
|
17
|
-
import { handleAdd, handleDelete } from "../tool";
|
|
17
|
+
import { handleAdd, handleDelete, handleSingleUpdate } from "../tool";
|
|
18
18
|
|
|
19
19
|
describe("handleAdd — text/texts dual-form detection", () => {
|
|
20
20
|
it("triggers dual-form error when singular 'text' used instead of 'texts'", () => {
|
|
@@ -66,3 +66,17 @@ describe("handleDelete — id/ids dual-form detection", () => {
|
|
|
66
66
|
expect(() => handleDelete(state, { action: "delete", ids: [1] })).not.toThrow();
|
|
67
67
|
});
|
|
68
68
|
});
|
|
69
|
+
|
|
70
|
+
describe("handleSingleUpdate — id/status/text required guards", () => {
|
|
71
|
+
it("throws 'requires id' when id missing", () => {
|
|
72
|
+
const state = createTodoSessionState();
|
|
73
|
+
expect(() => handleSingleUpdate(state, { action: "update" })).toThrow(/requires id/);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("throws 'at least status or text' when id given but status+text missing", () => {
|
|
77
|
+
const state = createTodoSessionState();
|
|
78
|
+
expect(() => handleSingleUpdate(state, { action: "update", id: 1 })).toThrow(
|
|
79
|
+
/at least status or text/,
|
|
80
|
+
);
|
|
81
|
+
});
|
|
82
|
+
});
|
package/src/tool.ts
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Todo tool 注册 + execute dispatcher + 4 个 action handler。
|
|
3
3
|
*
|
|
4
|
-
* Schema 设计(
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
4
|
+
* Schema 设计(OpenAI 兼容):TodoParams 为扁平 Type.Object(顶层 type:"object",
|
|
5
|
+
* 满足 OpenAI function calling 规范——顶层 union 会被严格网关 400 拒绝整个会话启动)。
|
|
6
|
+
* action 字段是字面量 union(list/add/update/delete),其余字段全 Optional;必填校验
|
|
7
|
+
* (add 缺 texts、delete 缺 ids、双形陷阱 text/texts、id/ids)由 handler 运行时承担
|
|
8
|
+
* (见 tool-detectors.test.ts)。范式参考 scheduler 的 ScheduleControlParams;设计
|
|
9
|
+
* 文档见 docs/extensions/tool-schema-openai-compat.md。
|
|
9
10
|
*/
|
|
10
11
|
|
|
11
12
|
import { StringEnum } from "@earendil-works/pi-ai";
|
|
@@ -25,75 +26,43 @@ import {
|
|
|
25
26
|
import { renderTodoResult } from "./render";
|
|
26
27
|
import type { TodoSessionState } from "./state";
|
|
27
28
|
|
|
28
|
-
// ──
|
|
29
|
-
//
|
|
30
|
-
//
|
|
31
|
-
//
|
|
32
|
-
//
|
|
33
|
-
|
|
34
|
-
export interface TodoActionParams {
|
|
35
|
-
action: string;
|
|
36
|
-
text?: string;
|
|
37
|
-
id?: number;
|
|
38
|
-
texts?: string[];
|
|
39
|
-
ids?: number[];
|
|
40
|
-
status?: string;
|
|
41
|
-
updates?: Array<{ id: number; status?: string; text?: string }>;
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
// ── TodoParams schema(discriminated union by action)──────────
|
|
29
|
+
// ── TodoParams schema(扁平 Type.Object,OpenAI 兼容)──────────
|
|
30
|
+
// 顶层必须是 type:"object"(OpenAI function calling 规范——顶层 union 会被严格
|
|
31
|
+
// 网关 400 拒绝)。action 字段是字面量 union;其余字段全 Optional,必填校验交给
|
|
32
|
+
// handler(见 tool-detectors.test.ts)。TodoParamsT 由 schema 派生,handler 签名
|
|
33
|
+
// 统一用它——双形陷阱检测在全 optional 类型上语义不变,且能消除 execute 里的 cast。
|
|
45
34
|
|
|
46
35
|
const StatusSchema = StringEnum(VALID_STATUSES);
|
|
47
36
|
|
|
48
|
-
const
|
|
49
|
-
{ action: Type.Literal("list") },
|
|
50
|
-
{ additionalProperties: false },
|
|
51
|
-
);
|
|
52
|
-
const AddParams = Type.Object(
|
|
37
|
+
export const TodoParams = Type.Object(
|
|
53
38
|
{
|
|
54
|
-
action: Type.
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
);
|
|
59
|
-
const UpdateSingleParams = Type.Object(
|
|
60
|
-
{
|
|
61
|
-
action: Type.Literal("update"),
|
|
62
|
-
id: Type.Number({ description: "要更新的 todo id" }),
|
|
63
|
-
status: Type.Optional(StatusSchema),
|
|
39
|
+
action: Type.Union(
|
|
40
|
+
[Type.Literal("list"), Type.Literal("add"), Type.Literal("update"), Type.Literal("delete")],
|
|
41
|
+
{ description: "list | add | update | delete" },
|
|
42
|
+
),
|
|
64
43
|
text: Type.Optional(Type.String({ description: "新文本(trim 后不可为空)" })),
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
)
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
44
|
+
texts: Type.Optional(Type.Array(Type.String(), { description: "待添加的 todo 文本数组" })),
|
|
45
|
+
id: Type.Optional(Type.Number({ description: "要更新的 todo id" })),
|
|
46
|
+
ids: Type.Optional(Type.Array(Type.Number(), { description: "要删除的 todo id 数组" })),
|
|
47
|
+
status: Type.Optional(StatusSchema),
|
|
48
|
+
updates: Type.Optional(
|
|
49
|
+
Type.Array(
|
|
50
|
+
Type.Object(
|
|
51
|
+
{
|
|
52
|
+
id: Type.Number({ description: "要更新的 todo id" }),
|
|
53
|
+
status: Type.Optional(StatusSchema),
|
|
54
|
+
text: Type.Optional(Type.String({ description: "新文本(trim 后不可为空)" })),
|
|
55
|
+
},
|
|
56
|
+
{ additionalProperties: false },
|
|
57
|
+
),
|
|
58
|
+
{ description: "批量更新数组(优先于单条 id/status/text)" },
|
|
59
|
+
),
|
|
78
60
|
),
|
|
79
61
|
},
|
|
80
62
|
{ additionalProperties: false },
|
|
81
63
|
);
|
|
82
|
-
const DeleteParams = Type.Object(
|
|
83
|
-
{
|
|
84
|
-
action: Type.Literal("delete"),
|
|
85
|
-
ids: Type.Array(Type.Number(), { description: "要删除的 todo id 数组" }),
|
|
86
|
-
},
|
|
87
|
-
{ additionalProperties: false },
|
|
88
|
-
);
|
|
89
64
|
|
|
90
|
-
export
|
|
91
|
-
ListParams,
|
|
92
|
-
AddParams,
|
|
93
|
-
UpdateSingleParams,
|
|
94
|
-
UpdateBatchParams,
|
|
95
|
-
DeleteParams,
|
|
96
|
-
]);
|
|
65
|
+
export type TodoParamsT = Static<typeof TodoParams>;
|
|
97
66
|
|
|
98
67
|
// ── 4 个 action handler ──────────────────────────────
|
|
99
68
|
// 错误处理约定(见 CLAUDE.md「Tool 设计」):handler 失败直接 throw,
|
|
@@ -107,7 +76,7 @@ function handleList(state: TodoSessionState): string {
|
|
|
107
76
|
}
|
|
108
77
|
|
|
109
78
|
/** add action — 失败抛错。export 供 behavioral 测试(text/texts 双形陷阱检测)。 */
|
|
110
|
-
export function handleAdd(state: TodoSessionState, params:
|
|
79
|
+
export function handleAdd(state: TodoSessionState, params: TodoParamsT): string {
|
|
111
80
|
// 双形陷阱:同时传 text 和 texts → throw(TC7)
|
|
112
81
|
if (params.text !== undefined && params.texts !== undefined) {
|
|
113
82
|
throw new Error('add only accepts texts array; do not also pass singular "text"');
|
|
@@ -131,7 +100,7 @@ export function handleAdd(state: TodoSessionState, params: TodoActionParams): st
|
|
|
131
100
|
}
|
|
132
101
|
|
|
133
102
|
/** update action: batch — 失败抛错 */
|
|
134
|
-
function handleBatchUpdate(state: TodoSessionState, params:
|
|
103
|
+
function handleBatchUpdate(state: TodoSessionState, params: TodoParamsT): string {
|
|
135
104
|
const r = updateTodos(state.todos, params.updates ?? []);
|
|
136
105
|
if (r.error) throw new Error(r.resultText);
|
|
137
106
|
state.todos = r.updatedTodos;
|
|
@@ -139,7 +108,7 @@ function handleBatchUpdate(state: TodoSessionState, params: TodoActionParams): s
|
|
|
139
108
|
}
|
|
140
109
|
|
|
141
110
|
/** update action: single — 失败抛错 */
|
|
142
|
-
export function handleSingleUpdate(state: TodoSessionState, params:
|
|
111
|
+
export function handleSingleUpdate(state: TodoSessionState, params: TodoParamsT): string {
|
|
143
112
|
if (params.id === undefined)
|
|
144
113
|
throw new Error(
|
|
145
114
|
'update requires id parameter. Correct: {"action":"update","id":<n>,"status":"in_progress"}',
|
|
@@ -171,14 +140,14 @@ export function handleSingleUpdate(state: TodoSessionState, params: TodoActionPa
|
|
|
171
140
|
}
|
|
172
141
|
|
|
173
142
|
/** update action: dispatcher — batch 优先于 single */
|
|
174
|
-
function handleUpdate(state: TodoSessionState, params:
|
|
143
|
+
function handleUpdate(state: TodoSessionState, params: TodoParamsT): string {
|
|
175
144
|
if (params.updates && params.updates.length > 0) return handleBatchUpdate(state, params);
|
|
176
145
|
return handleSingleUpdate(state, params);
|
|
177
146
|
}
|
|
178
147
|
|
|
179
148
|
/** delete action — 失败抛错;部分 id 缺失则整体拒绝(原子性)。
|
|
180
149
|
* export 供 behavioral 测试(id/ids 双形陷阱检测)。 */
|
|
181
|
-
export function handleDelete(state: TodoSessionState, params:
|
|
150
|
+
export function handleDelete(state: TodoSessionState, params: TodoParamsT): string {
|
|
182
151
|
if (!params.ids || params.ids.length === 0) {
|
|
183
152
|
// 双形陷阱:弱模型 delete 时误用单数 id(那是 update 的字段)
|
|
184
153
|
if (params.id !== undefined) {
|
|
@@ -209,7 +178,7 @@ export function handleDelete(state: TodoSessionState, params: TodoActionParams):
|
|
|
209
178
|
// ── Dispatcher ───────────────────────────────────────
|
|
210
179
|
|
|
211
180
|
function executeTodoAction(
|
|
212
|
-
params:
|
|
181
|
+
params: TodoParamsT,
|
|
213
182
|
state: TodoSessionState,
|
|
214
183
|
ctx: ExtensionContext,
|
|
215
184
|
refreshDisplay: (ctx: ExtensionContext) => void,
|
|
@@ -296,9 +265,9 @@ export function registerTodoTool(
|
|
|
296
265
|
executionMode: "sequential",
|
|
297
266
|
parameters: TodoParams,
|
|
298
267
|
|
|
299
|
-
async execute(_toolCallId: string, params:
|
|
268
|
+
async execute(_toolCallId: string, params: TodoParamsT, signal: AbortSignal | undefined, _onUpdate: unknown, ctx: ExtensionContext) {
|
|
300
269
|
if (signal?.aborted) throw new Error("Todo call aborted by signal.");
|
|
301
|
-
return executeTodoAction(params
|
|
270
|
+
return executeTodoAction(params, state, ctx, refreshDisplay);
|
|
302
271
|
},
|
|
303
272
|
|
|
304
273
|
renderCall(args: Record<string, unknown>, theme: Theme, _context?: unknown) {
|