fix: harden report tools and guide investigation presentation
Generic Container CI/CD / test-build-publish (push) Successful in 56s
Agent CI/CD / build-test-publish-and-deploy (push) Successful in 56s

This commit is contained in:
2026-11-02 15:23:34 +08:00
parent 6336157ff4
commit 874bfba907
16 changed files with 264 additions and 49 deletions
+12
View File
@@ -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"]) {
+101
View File
@@ -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;
}
});
});
+16 -1
View File
@@ -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 }[];
+1 -1
View File
@@ -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();
});