refactor: unify tool reason requirements

This commit is contained in:
2026-10-28 16:43:04 +08:00
parent 7d47a2df86
commit c3050b602b
20 changed files with 97 additions and 46 deletions
+1 -1
View File
@@ -21,7 +21,7 @@ temperature: 0.2
| 会话文件计算 | 经审批的沙箱 `shell` | | 会话文件计算 | 经审批的沙箱 `shell` |
| 稳定记忆、通用执行方法 | `memory_manager`、`skill_manager`;事件业务流程由 Server 经 MCP 分发 | | 稳定记忆、通用执行方法 | `memory_manager`、`skill_manager`;事件业务流程由 Server 经 MCP 分发 |
工具可见不等于有权执行,只使用当前角色已启用的工具。参数以工具 schema 为准,要求 reason 时填写具体理由,不增加身份、项目、会话目标等未声明参数。 工具可见不等于有权执行,只使用当前角色已启用的工具。参数以工具 schema 为准,不增加身份、项目、会话目标等未声明参数。说明字段统一使用 reason,用一句话说明具体目的或业务依据,不填写“为了回答用户”等套话,也不输出内部推理过程。普通查询、读取和展示无需逐次补写原因;schema 未声明 reason 时不要添加,可选时仅在目的不明显时填写。数据权限申请、处置提案、计划变更保留必填业务理由;memory_manager 和 skill_manager 的 list 可省略,其余修改操作必须说明长期价值或变更依据。多步调查在阶段说明中交代目的,不在每次查询重复。
围绕用户尚未完成的目标连续推进必要的查询、计算、核验和结果整理。补充参数、纠正结果或询问进度通常是在推进同一目标;用户说“继续”“重试”“重新检查”时,结合已有授权、结果和失败原因执行下一步,不只复述上一轮结论,也不重复询问已经确认的选择。用户明确缩小范围、更换目标或要求暂停时,以最新要求为准;“继续”不扩大权限,也不自动重启已结束的后台调查。 围绕用户尚未完成的目标连续推进必要的查询、计算、核验和结果整理。补充参数、纠正结果或询问进度通常是在推进同一目标;用户说“继续”“重试”“重新检查”时,结合已有授权、结果和失败原因执行下一步,不只复述上一轮结论,也不重复询问已经确认的选择。用户明确缩小范围、更换目标或要求暂停时,以最新要求为准;“继续”不扩大权限,也不自动重启已结束的后台调查。
+10
View File
@@ -0,0 +1,10 @@
import { tool } from "./tool.js";
// One field and format for optional purpose and required business justification.
export const toolReason = tool.schema.string().trim().min(1).max(2000);
export const optionalToolReason = toolReason.optional().describe(
"Optional reason: one concise sentence explaining the specific purpose. Omit when the command already makes it clear.",
);
export const learningToolReason = toolReason.optional().describe(
"Reason: one concise sentence explaining the durable value or change basis. Required for every write, update or removal; optional for list.",
);
+2 -5
View File
@@ -1,3 +1,4 @@
import { optionalToolReason } from "../tool-reason.js";
import { tool } from "../tool.js"; import { tool } from "../tool.js";
import { executeFrontendAction } from "./frontend_action.js"; import { executeFrontendAction } from "./frontend_action.js";
@@ -5,11 +6,7 @@ import { executeFrontendAction } from "./frontend_action.js";
export default tool({ export default tool({
description: "清除当前浏览器地图上的 Agent SCADA 分析结果覆盖层。", description: "清除当前浏览器地图上的 Agent SCADA 分析结果覆盖层。",
args: { args: {
reason: tool.schema reason: optionalToolReason,
.string()
.trim()
.min(1)
.describe("Why the current SCADA analysis overlay should be cleared."),
}, },
async execute(args, context) { async execute(args, context) {
return executeFrontendAction("clear_scada_analysis", args, context); return executeFrontendAction("clear_scada_analysis", args, context);
+2 -1
View File
@@ -1,7 +1,8 @@
import { optionalToolReason } from "../tool-reason.js";
import { tool } from "../tool.js"; import { tool } from "../tool.js";
import { executeFrontendAction } from "./frontend_action.js"; import { executeFrontendAction } from "./frontend_action.js";
export default tool({ export default tool({
description: "在当前供水地图定位资产并等待浏览器执行回执。返回已定位和未找到的 ID,失败时不得宣称成功。", description: "在当前供水地图定位资产并等待浏览器执行回执。返回已定位和未找到的 ID,失败时不得宣称成功。",
args: { layer: tool.schema.enum(["junctions", "pipes", "valves", "pumps", "tanks", "reservoirs", "scada"]), feature_ids: tool.schema.array(tool.schema.string().min(1).max(128)).min(1).max(100), reason: tool.schema.string().max(2000) }, args: { layer: tool.schema.enum(["junctions", "pipes", "valves", "pumps", "tanks", "reservoirs", "scada"]), feature_ids: tool.schema.array(tool.schema.string().min(1).max(128)).min(1).max(100), reason: optionalToolReason },
async execute(args, context) { return executeFrontendAction("locate_features", args, context); }, async execute(args, context) { return executeFrontendAction("locate_features", args, context); },
}); });
+2 -3
View File
@@ -1,3 +1,4 @@
import { learningToolReason } from "../tool-reason.js";
import { tool } from "../tool.js"; import { tool } from "../tool.js";
import { getInternalBridgeConfig } from "../internalBridge.js"; import { getInternalBridgeConfig } from "../internalBridge.js";
@@ -8,9 +9,7 @@ export default tool({
action: tool.schema action: tool.schema
.enum(["add", "list", "replace", "remove"]) .enum(["add", "list", "replace", "remove"])
.describe("Memory operation to perform."), .describe("Memory operation to perform."),
reason: tool.schema reason: learningToolReason,
.string()
.describe("Why this memory should be persisted for future requests."),
scope: tool.schema scope: tool.schema
.string() .string()
.describe( .describe(
+2 -1
View File
@@ -1,3 +1,4 @@
import { toolReason } from "../tool-reason.js";
import { tool } from "../tool.js"; import { tool } from "../tool.js";
import { getInternalBridgeConfig } from "../internalBridge.js"; import { getInternalBridgeConfig } from "../internalBridge.js";
import { proposedTaskActionSchema } from "../../src/product/operational-task-contract.js"; import { proposedTaskActionSchema } from "../../src/product/operational-task-contract.js";
@@ -7,7 +8,7 @@ export default tool({
args: { args: {
request_id: tool.schema.string().describe("Stable UUID idempotency key for this proposal"), request_id: tool.schema.string().describe("Stable UUID idempotency key for this proposal"),
kind: tool.schema.enum(["create_work_order", "issue_command"]), kind: tool.schema.enum(["create_work_order", "issue_command"]),
reason: tool.schema.string(), reason: toolReason.describe("One concise sentence explaining the business basis for this proposed action."),
payload: tool.schema.union([ payload: tool.schema.union([
tool.schema.object({ tool.schema.object({
title: tool.schema.string(), title: tool.schema.string(),
+2 -1
View File
@@ -1,3 +1,4 @@
import { toolReason } from "../tool-reason.js";
import { tool } from "../tool.js"; import { tool } from "../tool.js";
import { getInternalBridgeConfig } from "../internalBridge.js"; import { getInternalBridgeConfig } from "../internalBridge.js";
import { newExecutionPlanSchema } from "../../src/routes/executionPlan.js"; import { newExecutionPlanSchema } from "../../src/routes/executionPlan.js";
@@ -8,7 +9,7 @@ export default tool({
request_id: tool.schema.string().describe("Stable UUID idempotency key for this plan revision"), request_id: tool.schema.string().describe("Stable UUID idempotency key for this plan revision"),
schema: tool.schema.enum(["execution-plan@2"]), schema: tool.schema.enum(["execution-plan@2"]),
title: tool.schema.string(), title: tool.schema.string(),
reason: tool.schema.string().describe("Why the plan or node status changed"), reason: toolReason.max(1000).describe("One concise sentence explaining why the plan or node status changed."),
nodes: tool.schema.array(tool.schema.object({ nodes: tool.schema.array(tool.schema.object({
id: tool.schema.string(), title: tool.schema.string(), id: tool.schema.string(), title: tool.schema.string(),
status: tool.schema.enum(["pending", "running", "waiting", "completed", "failed", "skipped", "cancelled"]), status: tool.schema.enum(["pending", "running", "waiting", "completed", "failed", "skipped", "cancelled"]),
+2 -5
View File
@@ -1,3 +1,4 @@
import { optionalToolReason } from "../tool-reason.js";
import { tool } from "../tool.js"; import { tool } from "../tool.js";
import { executeFrontendAction } from "./frontend_action.js"; import { executeFrontendAction } from "./frontend_action.js";
@@ -8,11 +9,7 @@ export default tool({
description: description:
"将已经完成分析并确定等级的 SCADA 点位结果高亮、编号并定位到前端地图。等级只能来自已完成的分析结论。", "将已经完成分析并确定等级的 SCADA 点位结果高亮、编号并定位到前端地图。等级只能来自已完成的分析结论。",
args: { args: {
reason: tool.schema reason: optionalToolReason,
.string()
.trim()
.min(1)
.describe("Why the completed SCADA analysis should be rendered on the map."),
items: tool.schema items: tool.schema
.array( .array(
tool.schema.object({ tool.schema.object({
+2 -1
View File
@@ -1,3 +1,4 @@
import { toolReason } from "../tool-reason.js";
import { tool } from "../tool.js"; import { tool } from "../tool.js";
import { getInternalBridgeConfig } from "../internalBridge.js"; import { getInternalBridgeConfig } from "../internalBridge.js";
@@ -10,7 +11,7 @@ export default tool({
"workbench_runtime", "scada_devices", "scada_readings", "peripheral_points", "workbench_runtime", "scada_devices", "scada_readings", "peripheral_points",
"peripheral_work_orders", "peripheral_observations", "peripheral_test_data", "peripheral_work_orders", "peripheral_observations", "peripheral_test_data",
])).min(1), ])).min(1),
reason: tool.schema.string().min(1), reason: toolReason.describe("One concise sentence explaining which evidence gap requires the requested data access."),
}, },
async execute(args, context) { async execute(args, context) {
const { baseUrl, internalToken } = getInternalBridgeConfig(); const { baseUrl, internalToken } = getInternalBridgeConfig();
+2 -1
View File
@@ -1,7 +1,8 @@
import { optionalToolReason } from "../tool-reason.js";
import { tool } from "../tool.js"; import { tool } from "../tool.js";
import { executeFrontendAction } from "./frontend_action.js"; import { executeFrontendAction } from "./frontend_action.js";
export default tool({ export default tool({
description: "显示受控折线或柱状图并等待浏览器回执。不接收 ECharts 脚本或任意 option;数据须来自可信查询或用户输入。", description: "显示受控折线或柱状图并等待浏览器回执。不接收 ECharts 脚本或任意 option;数据须来自可信查询或用户输入。",
args: { title: tool.schema.string().max(200), chart_type: tool.schema.enum(["line", "bar"]), x_data: tool.schema.array(tool.schema.string().max(128)).min(1).max(100), series: tool.schema.array(tool.schema.object({ name: tool.schema.string().max(128), data: tool.schema.array(tool.schema.number().finite()).min(1).max(100) })).min(1).max(8), reason: tool.schema.string().max(2000) }, args: { title: tool.schema.string().max(200), chart_type: tool.schema.enum(["line", "bar"]), x_data: tool.schema.array(tool.schema.string().max(128)).min(1).max(100), series: tool.schema.array(tool.schema.object({ name: tool.schema.string().max(128), data: tool.schema.array(tool.schema.number().finite()).min(1).max(100) })).min(1).max(8), reason: optionalToolReason },
async execute(args, context) { return executeFrontendAction("show_chart", args, context); }, async execute(args, context) { return executeFrontendAction("show_chart", args, context); },
}); });
+2 -5
View File
@@ -1,3 +1,4 @@
import { learningToolReason } from "../tool-reason.js";
import { tool } from "../tool.js"; import { tool } from "../tool.js";
import { getInternalBridgeConfig } from "../internalBridge.js"; import { getInternalBridgeConfig } from "../internalBridge.js";
@@ -18,11 +19,7 @@ export default tool({
"remove_script", "remove_script",
]) ])
.describe("Skill maintenance operation."), .describe("Skill maintenance operation."),
reason: tool.schema reason: learningToolReason,
.string()
.describe(
"Why this skill maintenance action is justified for future reuse.",
),
skill_path: tool.schema skill_path: tool.schema
.string() .string()
.describe( .describe(
+2 -1
View File
@@ -1,7 +1,8 @@
import { optionalToolReason } from "../tool-reason.js";
import { tool } from "../tool.js"; import { tool } from "../tool.js";
import { executeFrontendAction } from "./frontend_action.js"; import { executeFrontendAction } from "./frontend_action.js";
export default tool({ export default tool({
description: "查询当前项目资产最多24小时的监测历史并显示曲线面板,等待浏览器回执。使用服务返回的单位和数据,缺数时明确说明。仅普通会话可用。", description: "查询当前项目资产最多24小时的监测历史并显示曲线面板,等待浏览器回执。使用服务返回的单位和数据,缺数时明确说明。仅普通会话可用。",
args: { element_type: tool.schema.enum(["junction", "pipe"]), element_id: tool.schema.string().min(1).max(128), start: tool.schema.string(), end: tool.schema.string(), reason: tool.schema.string().max(2000) }, args: { element_type: tool.schema.enum(["junction", "pipe"]), element_id: tool.schema.string().min(1).max(128), start: tool.schema.string(), end: tool.schema.string(), reason: optionalToolReason },
async execute(args, context) { return executeFrontendAction("view_history", args, context); }, async execute(args, context) { return executeFrontendAction("view_history", args, context); },
}); });
+2 -1
View File
@@ -1,7 +1,8 @@
import { optionalToolReason } from "../tool-reason.js";
import { tool } from "../tool.js"; import { tool } from "../tool.js";
import { executeFrontendAction } from "./frontend_action.js"; import { executeFrontendAction } from "./frontend_action.js";
export default tool({ export default tool({
description: "将当前地图缩放到 WGS84 经纬度并等待浏览器回执。", description: "将当前地图缩放到 WGS84 经纬度并等待浏览器回执。",
args: { center: tool.schema.tuple([tool.schema.number().min(-180).max(180), tool.schema.number().min(-90).max(90)]), zoom: tool.schema.number().min(0).max(24), reason: tool.schema.string().max(2000) }, args: { center: tool.schema.tuple([tool.schema.number().min(-180).max(180), tool.schema.number().min(-90).max(90)]), zoom: tool.schema.number().min(0).max(24), reason: optionalToolReason },
async execute(args, context) { return executeFrontendAction("zoom_to_map", args, context); }, async execute(args, context) { return executeFrontendAction("zoom_to_map", args, context); },
}); });
+2
View File
@@ -403,6 +403,8 @@ Agent 服务的 API 前缀是 `/api/v1`,路径不再包含 `/agent/chat`。生
这里是现有 HTTP/SSE 服务的 `tjwater` 扩展,映射 OpenCode 原生用户收件箱;不等同于 OpenAI WebSocket 的 `response.steer` 事件,官方协议边界见 [Mid-turn steering](https://developers.openai.com/api/docs/guides/steering)。验证命令 `bun tests/integration/response-input-smoke.ts` 使用隔离 OpenCode 和本机确定性模型,验证模式切换及队列后续输出,不调用付费推理。 这里是现有 HTTP/SSE 服务的 `tjwater` 扩展,映射 OpenCode 原生用户收件箱;不等同于 OpenAI WebSocket 的 `response.steer` 事件,官方协议边界见 [Mid-turn steering](https://developers.openai.com/api/docs/guides/steering)。验证命令 `bun tests/integration/response-input-smoke.ts` 使用隔离 OpenCode 和本机确定性模型,验证模式切换及队列后续输出,不调用付费推理。
工具说明统一使用 `reason`,填写时用一句话说明具体目的或业务依据。普通查询不额外注入该参数,地图、图表和 SCADA 展示工具的说明可选;缺少可选说明不会触发警告。数据权限申请、处置提案和计划变更保留必填理由;`memory_manager` / `skill_manager` 的 `list` 可省略,其余修改操作在内部桥接接口强制校验非空理由。说明随调用进入审计(`reasonProvided` 仅表示是否提供),进度中有值才显示“说明”,不替代授权、审批或执行回执。新工具只使用 `reason`,旧事件别名仅兼容读取。
流使用 `response.created`、`response.in_progress`、`response.output_item.added/done`、`response.content_part.added/done`、`response.output_text.delta/done` 和终态 `response.completed/failed/incomplete`,带单调递增的 `sequence_number`。中止返回 incomplete;未知原因不伪造标准 incomplete reason。进度、审批、问题、凭据续期、地图和富文本展示通过 `tjwater.<事件名>` 扩展携带 `response_id`。前端使用 Responses transport 接入现有 useChat 和 UIMessage 渲染;UIMessage 不再是生成接口的网络协议。标准流语义见 [OpenAI Responses streaming](https://developers.openai.com/api/docs/guides/streaming-responses)。 流使用 `response.created`、`response.in_progress`、`response.output_item.added/done`、`response.content_part.added/done`、`response.output_text.delta/done` 和终态 `response.completed/failed/incomplete`,带单调递增的 `sequence_number`。中止返回 incomplete;未知原因不伪造标准 incomplete reason。进度、审批、问题、凭据续期、地图和富文本展示通过 `tjwater.<事件名>` 扩展携带 `response_id`。前端使用 Responses transport 接入现有 useChat 和 UIMessage 渲染;UIMessage 不再是生成接口的网络协议。标准流语义见 [OpenAI Responses streaming](https://developers.openai.com/api/docs/guides/streaming-responses)。
当前支持文本、受权会话续接和流式/非流式生成;`previous_response_id`、客户端指定 tools、图片/文件输入及其余未实现参数均返回400,不能将此接口宣称为完整 OpenAI API 代理。模型推理仍由 OpenCode 执行,上游 provider 协议沿用 `agent-models.json` 的配置。`GET /session/:session_id/stream` 保留独立的会话状态订阅,事件包含 state、token、progress、done 等,用于历史恢复和已运行任务跟随;Server 的 `/api/v1/workbench/events` 继续承载业务事件。 当前支持文本、受权会话续接和流式/非流式生成;`previous_response_id`、客户端指定 tools、图片/文件输入及其余未实现参数均返回400,不能将此接口宣称为完整 OpenAI API 代理。模型推理仍由 OpenCode 执行,上游 provider 协议沿用 `agent-models.json` 的配置。`GET /session/:session_id/stream` 保留独立的会话状态订阅,事件包含 state、token、progress、done 等,用于历史恢复和已运行任务跟随;Server 的 `/api/v1/workbench/events` 继续承载业务事件。
-10
View File
@@ -877,16 +877,6 @@ export const streamPromptResponse = async ({
) { ) {
emittedToolParts.add(part.id); emittedToolParts.add(part.id);
toolCallCount += 1; toolCallCount += 1;
if (!reason) {
logger.warn(
{
tool: part.tool,
sessionId: sessionId,
clientSessionId,
},
"llm tool request missing reason",
);
}
void writeLlmRequestAuditLog({ void writeLlmRequestAuditLog({
kind: "tool", kind: "tool",
sessionId: sessionId, sessionId: sessionId,
+6 -4
View File
@@ -119,9 +119,11 @@ export const normalizeToolParams = (value: unknown): Record<string, unknown> =>
return {}; return {};
}; };
// New tools use reason; aliases are read-only compatibility for existing events.
const reasonKeys = ["reason", "request_reason", "why", "purpose", "rationale"];
export const extractRequestReason = (params: Record<string, unknown>) => { export const extractRequestReason = (params: Record<string, unknown>) => {
const candidates = ["reason", "request_reason", "why", "purpose", "rationale"]; for (const key of reasonKeys) {
for (const key of candidates) {
const value = params[key]; const value = params[key];
if (typeof value === "string") { if (typeof value === "string") {
const normalized = value.trim(); const normalized = value.trim();
@@ -381,7 +383,7 @@ const truncateProgressText = (text: string, maxLength: number) =>
text.length > maxLength ? `${text.slice(0, maxLength - 3)}...` : text; text.length > maxLength ? `${text.slice(0, maxLength - 3)}...` : text;
const summarizeToolParams = (params: Record<string, unknown>) => { const summarizeToolParams = (params: Record<string, unknown>) => {
const ignoredKeys = new Set(["reason", "request_reason", "why", "purpose", "rationale"]); const ignoredKeys = new Set(reasonKeys);
const summary = Object.entries(params) const summary = Object.entries(params)
.filter(([key]) => !ignoredKeys.has(key)) .filter(([key]) => !ignoredKeys.has(key))
.slice(0, 4) .slice(0, 4)
@@ -433,7 +435,7 @@ export const buildToolProgressDetail = (
error?: string, error?: string,
) => { ) => {
const toolName = toolLabels[tool] ?? tool; const toolName = toolLabels[tool] ?? tool;
const reasonText = reason ? `;调用原因:${reason}` : ""; const reasonText = reason ? `;说明:${reason}` : "";
const paramsText = `;关键参数:${summarizeToolParams(params)}`; const paramsText = `;关键参数:${summarizeToolParams(params)}`;
if (status === "error") { if (status === "error") {
+8 -4
View File
@@ -9,19 +9,23 @@ import { type SkillStore } from "../skills/store.js";
const memoryInput = z.object({ const memoryInput = z.object({
action: z.enum(["add", "list", "replace", "remove"]), action: z.enum(["add", "list", "replace", "remove"]),
scope: z.string().max(32), scope: z.string().max(32),
reason: z.string().max(2000).optional(), reason: z.string().trim().min(1).max(2000).optional(),
content: z.string().max(20000).optional(), content: z.string().max(20000).optional(),
target_id: z.string().max(128).optional(), target_id: z.string().max(128).optional(),
}).strict(); }).strict().refine((input) => input.action === "list" || Boolean(input.reason), {
path: ["reason"], message: "reason is required for writes, updates and removals",
});
const skillInput = z.object({ const skillInput = z.object({
action: z.enum(["list", "write_skill", "remove_skill", "append_pattern", "remove_pattern", "write_reference", "remove_reference", "write_script", "remove_script"]), action: z.enum(["list", "write_skill", "remove_skill", "append_pattern", "remove_pattern", "write_reference", "remove_reference", "write_script", "remove_script"]),
skill_path: z.string().max(512), skill_path: z.string().max(512),
reason: z.string().max(2000).optional(), reason: z.string().trim().min(1).max(2000).optional(),
content: z.string().max(200000).optional(), content: z.string().max(200000).optional(),
file_path: z.string().max(512).optional(), file_path: z.string().max(512).optional(),
pattern: z.string().max(20000).optional(), pattern: z.string().max(20000).optional(),
target_id: z.string().max(128).optional(), target_id: z.string().max(128).optional(),
}).strict(); }).strict().refine((input) => input.action === "list" || Boolean(input.reason), {
path: ["reason"], message: "reason is required for writes, updates and removals",
});
export const registerLearningToolBridge = (app: Express, options: { export const registerLearningToolBridge = (app: Express, options: {
internalToken: string; internalToken: string;
+17 -2
View File
@@ -32,10 +32,10 @@ describe("learning tools across process boundaries", () => {
'import memory from "./.opencode/tools/memory_manager.ts";', 'import memory from "./.opencode/tools/memory_manager.ts";',
'import skill from "./.opencode/tools/skill_manager.ts";', 'import skill from "./.opencode/tools/skill_manager.ts";',
'const ctx = { sessionID: "learning-bridge-session" };', 'const ctx = { sessionID: "learning-bridge-session" };',
'const call = (action, content) => memory.execute({ action, scope: "user", reason: "test", content }, ctx).then(JSON.parse);', 'const call = (action, content) => memory.execute({ action, scope: "user", ...(action === "list" ? {} : { reason: "test" }), content }, ctx).then(JSON.parse);',
'const before = await call("add", "用户偏好简洁答复");', 'const before = await call("add", "用户偏好简洁答复");',
'await call("list"); const added = await call("add", "用户偏好简洁答复");', 'await call("list"); const added = await call("add", "用户偏好简洁答复");',
'const skills = JSON.parse(await skill.execute({ action: "list", skill_path: "__root__", reason: "test" }, ctx));', 'const skills = JSON.parse(await skill.execute({ action: "list", skill_path: "__root__" }, ctx));',
'process.stdout.write(JSON.stringify({ before, added, skills }));', 'process.stdout.write(JSON.stringify({ before, added, skills }));',
].join("\n")], { cwd: process.cwd(), env: { ...process.env, AGENT_INTERNAL_TOKEN: "TEST_ONLY", TJWATER_AGENT_INTERNAL_BASE_URL: baseUrl }, stdout: "pipe", stderr: "pipe" }); ].join("\n")], { cwd: process.cwd(), env: { ...process.env, AGENT_INTERNAL_TOKEN: "TEST_ONLY", TJWATER_AGENT_INTERNAL_BASE_URL: baseUrl }, stdout: "pipe", stderr: "pipe" });
const [exitCode, stdout, stderr] = await Promise.all([child.exited, new Response(child.stdout).text(), new Response(child.stderr).text()]); const [exitCode, stdout, stderr] = await Promise.all([child.exited, new Response(child.stdout).text(), new Response(child.stderr).text()]);
@@ -48,6 +48,21 @@ describe("learning tools across process boundaries", () => {
expect(await memoryStore.list("user", "owner")).toHaveLength(1); expect(await memoryStore.list("user", "owner")).toHaveLength(1);
expect(await memoryStore.list("user", "other-owner")).toHaveLength(0); expect(await memoryStore.list("user", "other-owner")).toHaveLength(0);
const post = (sessionId: string, input: unknown, token = "TEST_ONLY") => fetch(`${baseUrl}/internal/tools/memory-manager`, { method: "POST", headers: { "Content-Type": "application/json", "x-agent-internal-token": token }, body: JSON.stringify({ session_id: sessionId, input }) }); const post = (sessionId: string, input: unknown, token = "TEST_ONLY") => fetch(`${baseUrl}/internal/tools/memory-manager`, { method: "POST", headers: { "Content-Type": "application/json", "x-agent-internal-token": token }, body: JSON.stringify({ session_id: sessionId, input }) });
// Invalid mutation reasons must fail before either store is changed.
for (const reason of [undefined, "", " ", "x".repeat(2001)]) {
expect((await post("learning-bridge-session", {
action: "remove", scope: "user", target_id: "anything", reason,
})).status).toBe(400);
const skillResponse = await fetch(`${baseUrl}/internal/tools/skill-manager`, {
method: "POST",
headers: { "Content-Type": "application/json", "x-agent-internal-token": "TEST_ONLY" },
body: JSON.stringify({ session_id: "learning-bridge-session", input: {
action: "write_skill", skill_path: "test", content: "must not be written", reason,
} }),
});
expect(skillResponse.status).toBe(400);
}
expect(await memoryStore.list("user", "owner")).toHaveLength(1);
expect((await post("missing", { action: "list", scope: "user" })).status).toBe(404); expect((await post("missing", { action: "list", scope: "user" })).status).toBe(404);
expect((await post("learning-bridge-session", { action: "list", scope: "user", actorKey: "other-owner" })).status).toBe(400); expect((await post("learning-bridge-session", { action: "list", scope: "user", actorKey: "other-owner" })).status).toBe(400);
expect((await post("learning-bridge-session", { action: "list", scope: "user" }, "wrong")).status).toBe(403); expect((await post("learning-bridge-session", { action: "list", scope: "user" }, "wrong")).status).toBe(403);
+14
View File
@@ -0,0 +1,14 @@
import { describe, expect, it } from "bun:test";
import { optionalToolReason, toolReason } from "../../.opencode/tool-reason.js";
describe("tool reason contract", () => {
it("allows omitted purpose but requires a nonblank business reason", () => {
expect(optionalToolReason.parse(undefined)).toBeUndefined();
expect(toolReason.safeParse(undefined).success).toBeFalse();
for (const schema of [optionalToolReason, toolReason]) {
expect(schema.parse(" 核验压力异常 ")).toBe("核验压力异常");
expect(schema.safeParse(" ").success).toBeFalse();
expect(schema.safeParse("x".repeat(2001)).success).toBeFalse();
}
});
});
+17
View File
@@ -0,0 +1,17 @@
import { describe, expect, it } from "bun:test";
import { buildToolProgressDetail, extractRequestReason } from "../../src/routes/chatStreamEvents.js";
describe("tool reason display", () => {
it("omits absent explanations and displays canonical or legacy reasons once", () => {
const plain = buildToolProgressDetail("query", "running", { id: "n1" }, "");
expect(plain).not.toContain("说明:");
expect(plain).not.toContain("原因:");
expect(plain).toContain("id=n1");
for (const input of [{ reason: " 核验压力异常 " }, { request_reason: "核验压力异常" }]) {
const detail = buildToolProgressDetail("query", "running", input, extractRequestReason(input));
expect(detail).toContain(";说明:核验压力异常");
expect(detail.match(/核验压力异常/g)).toHaveLength(1);
}
expect(extractRequestReason({ reason: "当前说明", purpose: "旧说明" })).toBe("当前说明");
});
});