@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zhushanwen/pi-todo",
3
- "version": "0.7.0",
3
+ "version": "0.7.1",
4
4
  "description": "AI-driven todo list for Pi — stateful task management with session persistence and /todos command.",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -1,10 +1,13 @@
1
- // Schema 强约束回归(T4/TC3/TC4):TodoParams discriminated union(按 action),
2
- // 每个分支只声明自己的参数且 additionalProperties:false。用 typebox Value.Check
3
- // 验证:缺失必填、多余字段、已删除的 action 都在 schema 层被拒绝,不依赖运行时 throw。
1
+ // Schema 顶层合规回归(OpenAI function calling:parameters 顶层必须是 type:"object",
2
+ // 顶层 union 会被严格网关 400 拒绝整个会话启动)。
4
3
  //
5
- // Value.Check 而非 ajv:typebox 自带 Value 校验器与其 schema 语义一致;
6
- // spike 确认 Value.Check 与 plain ajv 对本 schema 的拒绝结论一致(ajv 的
7
- // discriminator:true 选项会编译失败,故不依赖该选项)。
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 discriminated union schema", () => {
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("TC4: 缺失必填被 schema 拒绝", () => {
35
- it("add texts", () => {
36
- expect(Value.Check(TodoParams, { action: "add" })).toBe(false);
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("额外属性被拒绝(additionalProperties:false)", () => {
53
- it("TC7: add 同时传 text+texts(text 是多余字段)→ 拒绝", () => {
54
- // schema 层拒绝双形陷阱;handler 层另有 defense-in-depth throw(见 tool-detectors)
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("TC2: cancelled 不再合法", () => {
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 设计(T4):TodoParams discriminated union(按 action 区分),每个分支
5
- * 只声明自己的参数且 additionalProperties:false。这样缺失必填(如 {action:'add'}
6
- * texts)在 schema 层就被拒绝,不依赖运行时 handler throw。实测 typebox Value.Check
7
- * 与 ajvplain,不开 discriminator 选项)均正确拒绝;故不使用 discriminator keyword
8
- * (typebox 输出 anyOf,ajv discriminator 选项要求 oneOf 会编译失败)。
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
- // ── Action 参数类型(运行时)──────────────────────────
29
- // 刻意保持为宽松 interface(全部字段可选)而非 strict discriminated union
30
- // handler 需要检测「双形陷阱」(add 同时传 text+texts 等错误输入),schema 层虽已用
31
- // additionalProperties:false 拒绝,但 handler 作为 defense-in-depth 仍需能访问/判断
32
- // 这些字段。类型严格性由 TodoParams schema(discriminated union)承担。
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 ListParams = Type.Object(
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.Literal("add"),
55
- texts: Type.Array(Type.String(), { description: "待添加的 todo 文本数组" }),
56
- },
57
- { additionalProperties: false },
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
- { additionalProperties: false },
67
- );
68
- const UpdateBatchParams = Type.Object(
69
- {
70
- action: Type.Literal("update"),
71
- updates: Type.Array(
72
- Type.Object({
73
- id: Type.Number({ description: "要更新的 todo id" }),
74
- status: Type.Optional(StatusSchema),
75
- text: Type.Optional(Type.String({ description: "新文本(trim 后不可为空)" })),
76
- }),
77
- { description: "批量更新数组(优先于单条 id/status/text)" },
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 const TodoParams = Type.Union([
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: TodoActionParams): string {
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: TodoActionParams): string {
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: TodoActionParams): string {
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: TodoActionParams): string {
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: TodoActionParams): string {
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: TodoActionParams,
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: Static<typeof TodoParams>, signal: AbortSignal | undefined, _onUpdate: unknown, ctx: ExtensionContext) {
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 as TodoActionParams, state, ctx, refreshDisplay);
270
+ return executeTodoAction(params, state, ctx, refreshDisplay);
302
271
  },
303
272
 
304
273
  renderCall(args: Record<string, unknown>, theme: Theme, _context?: unknown) {