fix: harden report tools and guide investigation presentation
This commit is contained in:
@@ -8,6 +8,18 @@ const report = { title: "调查报告", current_conclusion: "无法确定原因"
|
||||
scope: "投诉时段", impact: "无法判断异常来源", required_data: "该时段采样结果", evidence_refs: ["query-1"], blocking: true }] };
|
||||
|
||||
describe("new investigation report writes", () => {
|
||||
it("accepts class-attached Mermaid declarations and still rejects missing or partial node IDs", () => {
|
||||
const diagram = { title: "状态图", mermaid: "flowchart TD\nQUERY:::running --> REVIEW:::pending",
|
||||
node_refs: [{ node_id: "QUERY", label: "核查", status: "running" },
|
||||
{ node_id: "REVIEW", label: "复核", status: "pending" }] };
|
||||
expect(taskPublicationDiagramSchema.safeParse(diagram).success).toBe(true);
|
||||
expect(taskReportSchema.safeParse({ ...report, diagram }).success).toBe(true);
|
||||
for (const node_id of ["missing", "QUER"]) {
|
||||
const invalid = { ...diagram, node_refs: [{ ...diagram.node_refs[0], node_id }] };
|
||||
expect(taskPublicationDiagramSchema.safeParse(invalid).success).toBe(false);
|
||||
expect(taskReportSchema.safeParse({ ...report, diagram: invalid }).success).toBe(false);
|
||||
}
|
||||
});
|
||||
it("requires explicit outcome and gap assessment", () => {
|
||||
expect(taskReportSchema.parse(report).outcome).toBe("insufficient_data");
|
||||
for (const field of ["stage", "outcome", "data_gaps"]) {
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
import { describe, expect, it, spyOn } from "bun:test";
|
||||
import { tool } from "../../.opencode/tool.js";
|
||||
import publishReport from "../../.opencode/tools/publish_task_report.js";
|
||||
import { taskReportSchema } from "../../src/product/investigation-report.js";
|
||||
|
||||
const native = tool.schema.object(publishReport.args);
|
||||
const requestId = "60000000-0000-4000-8000-000000000001";
|
||||
const report = {
|
||||
title: "候选隔离方案", current_conclusion: "已取得候选与隔离分析", conclusion_boundary: "候选尚待现场核验",
|
||||
stage: "final", outcome: "concluded", evidence_refs: ["query-1"],
|
||||
recommendations: ["核验阀门可用性"], pending_items: ["等待现场回执"], data_gaps: [],
|
||||
};
|
||||
const block = { block_id: "summary", fallback_text: "已取得有边界的方案。", spec: {
|
||||
root: "text", elements: { text: { type: "Text", children: [], props: { text: "已取得有边界的方案。", tone: "neutral" } } },
|
||||
} };
|
||||
const gap = { gap_id: "sample", scope: "投诉时段", missing_data: "实际采样", required_data: "现场采样结果",
|
||||
impact: "无法形成必要判断", evidence_refs: ["query-1"], blocking: true };
|
||||
|
||||
describe("report publication contract", () => {
|
||||
it("advertises outcome-specific constraints without requiring host-owned fields", () => {
|
||||
const schema = tool.schema.toJSONSchema(native, { io: "input" });
|
||||
const reportSchema = schema.properties!.report as { anyOf: Array<{ properties: Record<string, { const?: string }>; required?: string[] }> };
|
||||
const concluded = reportSchema.anyOf.find((variant) => variant.properties.outcome.const === "concluded")!;
|
||||
const insufficient = reportSchema.anyOf.find((variant) => variant.properties.outcome.const === "insufficient_data")!;
|
||||
expect(concluded.required).not.toContain("stop_reason");
|
||||
expect(insufficient.required).toContain("stop_reason");
|
||||
expect(insufficient.properties.stage.const).toBe("final");
|
||||
});
|
||||
it("supplies fixed protocol fields and accepts the known misplaced version tag without forwarding it", () => {
|
||||
const input = { ...report, ui_blocks: [{ version: "answer-ui@1", block, evidence_refs: ["query-1"] }] };
|
||||
const args = native.parse({ request_id: requestId, report: input, evidence: [] });
|
||||
const canonical = taskReportSchema.parse(args.report);
|
||||
expect(canonical.stop_reason).toBeNull();
|
||||
expect(canonical.ui_blocks?.[0].block.version).toBe("answer-ui@1");
|
||||
expect(canonical.ui_blocks?.[0]).not.toHaveProperty("version");
|
||||
expect(taskReportSchema.parse(input)).toEqual(canonical);
|
||||
for (const ui_blocks of [
|
||||
[{ block: { ...block, version: "unsupported" }, evidence_refs: ["query-1"] }],
|
||||
[{ version: "unsupported", block, evidence_refs: ["query-1"] }],
|
||||
]) {
|
||||
expect(native.safeParse({ request_id: requestId, report: { ...report, ui_blocks }, evidence: [] }).success).toBe(false);
|
||||
expect(taskReportSchema.safeParse({ ...report, ui_blocks }).success).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it("exposes the same outcome conditions at native and host boundaries", () => {
|
||||
const insufficient = { ...report, outcome: "insufficient_data", recommendations: [], pending_items: [],
|
||||
stop_reason: "无法取得关键采样", data_gaps: [gap] };
|
||||
for (const [input, accepted] of [
|
||||
[report, true], [insufficient, true],
|
||||
[{ ...report, stop_reason: "本轮已结束" }, false],
|
||||
[{ ...report, data_gaps: [gap] }, false],
|
||||
[{ ...insufficient, stage: "interim" }, false],
|
||||
[{ ...insufficient, stop_reason: null }, false],
|
||||
[{ ...insufficient, recommendations: ["继续采样"] }, false],
|
||||
[{ ...insufficient, pending_items: ["等人"] }, false],
|
||||
[{ ...insufficient, data_gaps: [{ ...gap, blocking: false }] }, false],
|
||||
[{ ...insufficient, data_gaps: [{ ...gap, evidence_refs: [] }] }, false],
|
||||
] as const) {
|
||||
expect(native.safeParse({ request_id: requestId, report: input, evidence: [] }).success).toBe(accepted);
|
||||
expect(taskReportSchema.safeParse(input).success).toBe(accepted);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects a contradictory report before issuing any HTTP request", async () => {
|
||||
const originalToken = process.env.AGENT_INTERNAL_TOKEN;
|
||||
process.env.AGENT_INTERNAL_TOKEN = "test-tool-token";
|
||||
const fetch = spyOn(globalThis, "fetch").mockRejectedValue(new Error("Unexpected HTTP request"));
|
||||
try {
|
||||
await expect(publishReport.execute({ request_id: requestId, evidence: [],
|
||||
report: { ...report, stop_reason: "本轮已结束" },
|
||||
} as never, { sessionID: "test" })).rejects.toThrow("已形成结论");
|
||||
expect(fetch).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
fetch.mockRestore();
|
||||
if (originalToken === undefined) delete process.env.AGENT_INTERNAL_TOKEN;
|
||||
else process.env.AGENT_INTERNAL_TOKEN = originalToken;
|
||||
}
|
||||
});
|
||||
|
||||
it("sends only canonical report fields to the bridge", async () => {
|
||||
const originalToken = process.env.AGENT_INTERNAL_TOKEN;
|
||||
process.env.AGENT_INTERNAL_TOKEN = "test-tool-token";
|
||||
const fetch = spyOn(globalThis, "fetch").mockResolvedValue(new Response('{"report_revision":1}'));
|
||||
try {
|
||||
const input = { request_id: requestId, evidence: [], report: { ...report,
|
||||
ui_blocks: [{ version: "answer-ui@1", block, evidence_refs: ["query-1"] }],
|
||||
} };
|
||||
await publishReport.execute(input as never, { sessionID: "test" });
|
||||
const body = JSON.parse(String(fetch.mock.calls[0]?.[1]?.body));
|
||||
expect(body.report.stop_reason).toBeNull();
|
||||
expect(body.report.ui_blocks[0].block.version).toBe("answer-ui@1");
|
||||
expect(body.report.ui_blocks[0]).not.toHaveProperty("version");
|
||||
expect(body.report.current_conclusion).toBe(report.current_conclusion);
|
||||
} finally {
|
||||
fetch.mockRestore();
|
||||
if (originalToken === undefined) delete process.env.AGENT_INTERNAL_TOKEN;
|
||||
else process.env.AGENT_INTERNAL_TOKEN = originalToken;
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -7,11 +7,26 @@ describe("OpenCode 2 permissions", () => {
|
||||
it("keeps one-shot recommendations separate from the business chat protocol and denies all tools", async () => {
|
||||
const config = serviceOpencodeConfig({});
|
||||
const generator = config.agents["prompt-suggestions"];
|
||||
expect(generator.system).toBe(await readFile(".opencode/agents/prompt-suggestions.md", "utf8"));
|
||||
const source = await readFile(".opencode/agents/prompt-suggestions.md", "utf8");
|
||||
expect(generator.system).toBe(source.split("\n---\n")[1].trimStart());
|
||||
expect(generator.system).not.toBe(config.agents.instruction.system);
|
||||
expect(generator.model).toBe(config.agents.instruction.model);
|
||||
expect(generator.permissions).toEqual([{ action: "*", resource: "*", effect: "deny" }]);
|
||||
});
|
||||
it("loads complete prompt bodies while keeping version metadata out of every model instruction", async () => {
|
||||
const bodies: Record<string, string> = {};
|
||||
for (const name of ["instruction", "investigation", "prompt-suggestions"]) {
|
||||
const source = await readFile(`.opencode/agents/${name}.md`, "utf8");
|
||||
const [header, body] = source.split("\n---\n");
|
||||
expect(header).toContain(`name: ${name}`);
|
||||
expect(header).toMatch(/metadata:\n version: "\d+\.\d+\.\d+"/);
|
||||
bodies[name] = body.trimStart();
|
||||
}
|
||||
const config = serviceOpencodeConfig({});
|
||||
expect(config.agents.instruction.system).toBe(bodies.instruction);
|
||||
expect(config.agents.investigation.system).toBe(`${bodies.instruction}\n\n${bodies.investigation}`);
|
||||
expect(config.agents["prompt-suggestions"].system).toBe(bodies["prompt-suggestions"]);
|
||||
});
|
||||
it("keeps protected paths denied, shell/edit gated and subagents disabled", async () => {
|
||||
const config = JSON.parse(await readFile("opencode.json", "utf8"));
|
||||
const rules = config.permissions as { action: string; resource: string; effect: string }[];
|
||||
|
||||
@@ -22,7 +22,7 @@ describe("task agent isolation", () => {
|
||||
"*", ...PRODUCT_MCP_READ_TOOL_IDS,
|
||||
]);
|
||||
expect(PRODUCT_MCP_READ_TOOL_IDS).toHaveLength(45);
|
||||
expect(PRODUCT_MCP_TOOL_IDS).toHaveLength(71);
|
||||
expect(PRODUCT_MCP_TOOL_IDS).toHaveLength(72);
|
||||
expect(PRODUCT_MCP_TOOL_IDS.some((name) => /publish|approve|execute|shell/.test(name))).toBeFalse();
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user