fix: keep approval batching internal
CI / Client installer (macos-latest) (push) Canceled after 0s
CI / Client installer (ubuntu-latest) (push) Canceled after 0s
CI / Client installer (windows-latest) (push) Canceled after 0s
CI / test (22) (push) Canceled after 0s
CI / test (24) (push) Canceled after 0s
CI / release-smoke (push) Canceled after 0s
CI / Client installer (macos-latest) (push) Canceled after 0s
CI / Client installer (ubuntu-latest) (push) Canceled after 0s
CI / Client installer (windows-latest) (push) Canceled after 0s
CI / test (22) (push) Canceled after 0s
CI / test (24) (push) Canceled after 0s
CI / release-smoke (push) Canceled after 0s
This commit is contained in:
@@ -17,10 +17,10 @@ On first use or expired authorization, call `approval_login`. The tool does not
|
||||
|
||||
## Review and process
|
||||
|
||||
1. Call `approval_list_todos` first. Use its `approvalId` values for one or some items. When the user explicitly asks to process the entire returned result set and `batchPolicy.selectionAvailable=true`, prefer its `selectionId`; it is bound to that exact live set and the gateway refuses the write if the set changes.
|
||||
1. Call `approval_list_todos` first. Treat `approvalId`, `selectionId`, `targetId`, reference availability, call limits, and batching as internal tool details: never mention or explain them to the user. When the user only asks to review approvals, report only relevant business information and do not proactively discuss how writes would be performed. Internally use `approvalId` for one or some items; when the user explicitly asks to process the entire returned result set and a `selectionId` is present, prefer it because the gateway binds it to that exact live set.
|
||||
2. For ordinary review call `approval_get_detail` with `view=summary`; for expenses use `view=expense`. Use filtered `raw` paging only when a required field is missing.
|
||||
3. Approve and reject do not require per-item `approval_get_actions` calls: the gateway revalidates every item immediately before writing. `allowed=true` means the pending-task preflight passed; `guaranteed=false` means DingTalk may still enforce a signature or another business rule at execution. Only before return, call `approval_get_actions` and copy a returned `targetId` exactly.
|
||||
4. Call `approval_process_batch` only after the user explicitly requests a write action. One call accepts at most 20 items. For an entire eligible listed group pass `selectionId` and an empty `items` list; for a subset pass its `approvalId` items. Never combine both. If one confirmed list contains more than 20 requested items, split only those original `approvalId` values into groups of at most 20; do not relist between groups or mix newly appeared todos into the authorization. The user's request must cover every group, but do not add a redundant confirmation when it already clearly does.
|
||||
4. Call `approval_process_batch` only after the user explicitly requests a write action. One call accepts at most 20 items. For an entire eligible listed group pass `selectionId` and an empty `items` list; for a subset pass its `approvalId` items. Never combine both. If one confirmed list contains more than 20 requested items, split only those original `approvalId` values into internal groups of at most 20; do not relist between groups or mix newly appeared todos into the authorization. The user's request must cover every group, but do not add a redundant confirmation when it already clearly does. Never tell the user about the 20-item limit, internal grouping, or reference fields; simply carry out the authorized business action and report the business results.
|
||||
5. The gateway validates every `approvalId` and `targetId` before the first write. If any reference is invalid, the result is `preflight_failed`, `writesStarted=false`, and the entire call writes zero items. Relist, show the refreshed targets, and obtain confirmation again; never compensate or retry automatically.
|
||||
6. Report every item result from the compact tool text. `failed` means the operation was rejected and did not take effect; do not retry unless the user explicitly asks after reviewing the reason. `succeeded` means the node was submitted; `completed` means the current user has no remaining node. `outcome_uncertain` means a write was submitted but not confirmed: never retry it automatically; wait and list todos again. If follow-up is required, list todos again. If a DingTalk template requires handwriting or another client-only action, send its client link to the user instead of retrying.
|
||||
|
||||
|
||||
+1
-1
@@ -424,7 +424,7 @@ export function createBridgeMcpServer(bridge: RemoteBridge): McpServer {
|
||||
annotations: { readOnlyHint: true, destructiveHint: false },
|
||||
}, async (args) => bridge.call("approval_get_actions", args));
|
||||
server.registerTool("approval_process_batch", {
|
||||
title: "处理审批", description: `仅在用户明确授权后处理本人待办;一次最多 ${APPROVAL_BATCH_LIMIT} 条。整组使用selectionId,部分项目使用items;写前校验整批引用,任一无效则零写入,不自动重试。`, inputSchema: {
|
||||
title: "处理审批", description: "仅在用户明确授权后处理本人待办;在授权范围内按Schema容量内部调用,不向用户解释引用、容量或分批方式。写前校验整批引用,任一无效则零写入,不自动重试。", inputSchema: {
|
||||
action: z.enum(["approve", "reject", "return"]), remark: z.string().max(1000).default(""),
|
||||
selectionId: z.string().min(20).max(4096).optional(),
|
||||
items: z.array(z.object({ approvalId: z.string().min(20).max(4096), targetId: z.string().min(20).max(4096).optional() }))
|
||||
|
||||
+3
-2
@@ -32,7 +32,8 @@ const MCP_SESSION_MAX_PER_USER = 8;
|
||||
const MCP_SESSION_MAX_TOTAL = 1000;
|
||||
|
||||
export const APPROVAL_MCP_INSTRUCTIONS = [
|
||||
`先调用 approval_list_todos 获取当前待办。处理单张或部分待办时使用 approvalId;用户明确要求处理本次查询返回的整组时优先使用 selectionId。每次最多处理 ${APPROVAL_BATCH_LIMIT} 条;超过时可使用同一次原始列表中的 approvalId 分组,但每组都必须符合用户授权范围。`,
|
||||
"先调用 approval_list_todos 获取当前待办。用户只是在查看时,仅回答标题、申请人、金额、时间等业务信息,不主动讨论审批接口、批处理方式或索取写操作授权。用户明确要求处理后,在其授权范围内内部选择整组引用或自动分批;技术分批不得造成重复确认。",
|
||||
"approvalId、selectionId、targetId、单次条数限制、分批方式以及内部引用是否返回均属于 Agent 与 MCP 的内部调用细节,绝不向用户展示、解释或要求用户操作。面向用户只说明目标审批、业务动作、成功结果和需要用户决策的业务失败;不要说引用是否签发、是否失效、是否存在一键全选或如何分组。",
|
||||
"查询本人处理过或本人发起的审批时调用 approval_list_history;默认最近30天,可用 startDate/endDate 指定最长120天的时间窗口。用其只读 approvalId 获取详情;历史引用不能用于 approval_get_actions 或 approval_process_batch,也不会返回 selectionId。",
|
||||
"费用复核优先调用 approval_get_detail(view=expense);summary 用于一般详情,raw 仅用于分页补充字段。",
|
||||
"用于审批动作的 approvalId 和 selectionId 必须来自 approval_list_todos;approval_list_history 返回的 approvalId 只能读取详情。同意/拒绝无需逐张调用 approval_get_actions,网关会在写入前实时复核。只有退回必须先调用 approval_get_actions 并使用其 targetId,禁止自行构造。",
|
||||
@@ -126,7 +127,7 @@ export function createApprovalMcpServer(
|
||||
|
||||
server.registerTool("approval_process_batch", {
|
||||
title: "处理审批",
|
||||
description: `仅在用户明确要求时调用,写操作不二次确认。一次最多 ${APPROVAL_BATCH_LIMIT} 条;处理整组查询结果时传selectionId,处理单张或部分待办时传items。网关先校验整批引用,任一无效则零写入;不会自动补偿或重试。同意/拒绝无需逐张预检;退回必须逐张传approval_get_actions返回的targetId。succeeded表示节点提交成功,completed表示无需继续审批,followUpRequired表示仍有本人后续节点,outcome_uncertain禁止自动重试。`,
|
||||
description: "仅在用户明确要求时调用,写操作不二次确认。在用户已授权的业务范围内按Schema容量内部处理,调用容量、引用和分批方式不得转述给用户。网关先校验整批引用,任一无效则零写入;不会自动补偿或重试。同意/拒绝无需逐张预检;退回必须使用approval_get_actions返回的目标引用。succeeded表示节点提交成功,completed表示无需继续审批,followUpRequired表示仍有本人后续节点,outcome_uncertain禁止自动重试。",
|
||||
inputSchema: {
|
||||
action: z.enum(["approve", "reject", "return"]),
|
||||
remark: z.string().max(1000).default(""),
|
||||
|
||||
@@ -260,14 +260,7 @@ export class UnifiedApprovalService {
|
||||
digest: this.approvalSelectionDigest(user, returnedItems),
|
||||
exp: expiresInMinutes(15),
|
||||
} satisfies ApprovalSelectionReference),
|
||||
selectionHint: "selectionId 精确绑定本次列表结果;仅在用户明确要求处理整组时使用,执行前会重新实时核对。",
|
||||
} : issueSelection ? { selectionUnavailableReason } : {}),
|
||||
batchPolicy: {
|
||||
maxItemsPerCall: APPROVAL_BATCH_LIMIT,
|
||||
selectionAvailable,
|
||||
...(!selectionAvailable ? { selectionUnavailableReason } : {}),
|
||||
overflowGuidance: `一次最多处理 ${APPROVAL_BATCH_LIMIT} 条。超过时可从同一次列表结果中选取 approvalId 分组提交;每组仍须经用户明确授权。`,
|
||||
},
|
||||
} : {}),
|
||||
sourceStatus,
|
||||
diagnostics: { realtime: true, persistedBusinessData: false },
|
||||
};
|
||||
|
||||
+1
-1
@@ -193,7 +193,7 @@ test("MCP approval_login returns both portable text and a standard resource link
|
||||
assert.equal((history.inputSchema as any).properties.endDate.default, "");
|
||||
const batch = tools.tools.find((tool) => tool.name === "approval_process_batch")!;
|
||||
assert.equal((batch.inputSchema as any).properties.items.maxItems, 20);
|
||||
assert.match(batch.description ?? "", /最多 20 条.*零写入/s);
|
||||
assert.match(batch.description ?? "", /Schema容量.*不向用户解释.*任一无效则零写入/s);
|
||||
} finally {
|
||||
await client.close();
|
||||
await server.close();
|
||||
|
||||
@@ -43,7 +43,8 @@ test("MCP initialization teaches the workflow while tools/list stays compact", a
|
||||
assert.match(client.getInstructions() ?? "", /非必填字段.*是否补充/s);
|
||||
assert.match(client.getInstructions() ?? "", /出差申请.*外出.*业务招待申请.*差旅费用报销.*业务招待费用报销/s);
|
||||
assert.match(client.getInstructions() ?? "", /revokeId.*approval_revoke_submission/);
|
||||
assert.match(client.getInstructions() ?? "", /最多(?:处理 )?20 条.*零写入.*禁止自动.*重试/s);
|
||||
assert.match(client.getInstructions() ?? "", /单次条数限制.*内部调用细节.*绝不向用户.*一键全选.*如何分组/s);
|
||||
assert.match(client.getInstructions() ?? "", /用户只是在查看.*业务信息.*不主动讨论.*自动分批.*不得造成重复确认/s);
|
||||
assert.match(client.getInstructions() ?? "", /近30天.*allowDuplicateInvoices=true/s);
|
||||
|
||||
const tools = await client.listTools();
|
||||
@@ -73,7 +74,7 @@ test("MCP initialization teaches the workflow while tools/list stays compact", a
|
||||
assert.match(historySchema.properties.startDate.description, /最近30天/);
|
||||
assert.match(descriptions.approval_process_batch ?? "", /用户明确.*不二次确认/);
|
||||
assert.match(descriptions.approval_process_batch ?? "", /succeeded.*completed.*followUpRequired/);
|
||||
assert.match(descriptions.approval_process_batch ?? "", /最多 20 条.*任一无效.*零写入/s);
|
||||
assert.match(descriptions.approval_process_batch ?? "", /Schema容量.*不得转述给用户.*任一无效.*零写入/s);
|
||||
assert.match(descriptions.approval_prepare_submission ?? "", /明确确认|确认/);
|
||||
assert.match(descriptions.approval_get_submission_schema ?? "", /必填字段齐全.*可选字段/);
|
||||
assert.match(descriptions.approval_get_submission_options ?? "", /承担部门.*城市编码/);
|
||||
|
||||
+11
-9
@@ -209,7 +209,7 @@ test("selectionId processes exactly the confirmed live result set and rejects dr
|
||||
assert.equal(writes, 2);
|
||||
});
|
||||
|
||||
test("todo lists explicitly describe the fixed 20-item batch policy", async () => {
|
||||
test("todo lists keep transport batch policy internal", async () => {
|
||||
const user = { userId: "user-1" };
|
||||
const manyDing = {
|
||||
async listMyTodos() {
|
||||
@@ -219,21 +219,23 @@ test("todo lists explicitly describe the fixed 20-item batch policy", async () =
|
||||
},
|
||||
} as unknown as ApprovalService;
|
||||
const overflow = await new UnifiedApprovalService({ ...config(), batchLimit: 99 }, manyDing).listTodos(user, "dingtalk", 30);
|
||||
assert.deepEqual(overflow.batchPolicy, {
|
||||
maxItemsPerCall: 20,
|
||||
selectionAvailable: false,
|
||||
selectionUnavailableReason: "result_exceeds_batch_limit",
|
||||
overflowGuidance: "一次最多处理 20 条。超过时可从同一次列表结果中选取 approvalId 分组提交;每组仍须经用户明确授权。",
|
||||
});
|
||||
assert.equal(overflow.returned, 21);
|
||||
assert.equal("selectionId" in overflow, false);
|
||||
assert.equal("selectionUnavailableReason" in overflow, false);
|
||||
assert.equal("selectionHint" in overflow, false);
|
||||
assert.equal("batchPolicy" in overflow, false);
|
||||
|
||||
const emptyDing = { async listMyTodos() { return { items: [] }; } } as unknown as ApprovalService;
|
||||
const empty = await new UnifiedApprovalService(config(), emptyDing).listTodos(user, "dingtalk", 10);
|
||||
assert.equal(empty.batchPolicy.selectionUnavailableReason, "empty_result");
|
||||
assert.equal("selectionId" in empty, false);
|
||||
assert.equal("selectionUnavailableReason" in empty, false);
|
||||
assert.equal("batchPolicy" in empty, false);
|
||||
|
||||
const xftUnavailable = { async listMyTodos() { throw new Error("XFT unavailable"); } };
|
||||
const partial = await new UnifiedApprovalService(config(), manyDing, xftUnavailable as never).listTodos(user, "all", 1);
|
||||
assert.equal(partial.batchPolicy.selectionUnavailableReason, "requested_source_unhealthy");
|
||||
assert.equal("selectionId" in partial, false);
|
||||
assert.equal("selectionUnavailableReason" in partial, false);
|
||||
assert.equal("batchPolicy" in partial, false);
|
||||
});
|
||||
|
||||
test("combined todo lists are deterministic regardless of source response timing", async () => {
|
||||
|
||||
Reference in New Issue
Block a user