feat: upgrade investigation and presentation runtime
Generic Container CI/CD / test-build-publish (push) Successful in 1m6s
Agent CI/CD / build-test-publish-and-deploy (push) Successful in 1m7s

This commit is contained in:
2026-10-30 17:30:19 +08:00
parent 3b52a0ddab
commit 6336157ff4
104 changed files with 6541 additions and 961 deletions
+21
View File
@@ -0,0 +1,21 @@
import { describe, expect, it } from "bun:test";
import presentAnswer, { answerUiExamples } from "../../.opencode/tools/present_answer.js";
import finalAnswer from "../../.opencode/tools/final_answer.js";
import { AnswerUiPublication } from "../../src/chat/answer-ui-publication.js";
import { answerUiBlockSchema } from "../../src/chat/answer-ui.js";
describe("model-facing answer UI examples", () => {
for (const example of answerUiExamples) {
it(`publishes and retains ${example.present_answer.block_id} through the real contracts`, async () => {
const context = { sessionID: "example-validation" };
const { recorded, ...output } = JSON.parse(await presentAnswer.execute(example.present_answer, context));
expect(recorded).toBe(true);
const block = answerUiBlockSchema.parse(output);
const publication = new AnswerUiPublication();
expect(publication.publish(block)).toBeUndefined();
const final = JSON.parse(await finalAnswer.execute(example.final_answer, context));
expect(publication.finalize(final.ui_block_ids)).toEqual([block.block_id]);
expect(block.phase).toBe("final");
});
}
});
+71 -1
View File
@@ -20,6 +20,37 @@ describe("locally rendered answers", () => {
{ ...input.spec.elements.metric, children: ["metric"] },
]) expect(parseAnswerUiInput({ ...input, spec: { root: "metric", elements: { metric } } })).toBeNull();
});
it("accepts semantic presentation hints and legacy props while keeping results bounded and passive", () => {
const result = { type: "Result", props: { title: "记录已保存", summary: "演练记录已保存,现场状态待核验。", status: "success", source: "保存工具回执" }, children: [] as string[] };
const section = { type: "Section", props: { title: "处理结果", description: null, role: "result" }, children: ["result"] };
const stack = { type: "Stack", props: { columns: "one", gap: "normal", intent: "response", density: "comfortable" }, children: ["section"] };
const spec = { root: "stack", elements: { stack, section, result } };
expect(parseAnswerUiInput({ ...input, spec })).not.toBeNull();
const leaf = (node: unknown) => parseAnswerUiInput({ ...input, spec: { root: "leaf", elements: { leaf: node } } });
for (const status of ["success", "partial", "unconfirmed", "error"]) {
expect(leaf({ ...result, props: { ...result.props, status, source: null } })).not.toBeNull();
}
expect(leaf({ type: "Text", props: { title: "说明", text: "已有事实", tone: "neutral" }, children: [] })).not.toBeNull();
expect(parseAnswerUiInput({ ...input, spec: { ...spec, elements: { ...spec.elements,
stack: { ...stack, props: { columns: "one", gap: "normal" } },
section: { ...section, props: { title: "处理结果", description: null } },
} } })).not.toBeNull();
for (const props of [
{ ...result.props, status: "done" }, { ...result.props, source: undefined },
{ ...result.props, title: "a".repeat(201) }, { ...result.props, summary: "a".repeat(4001) },
{ ...result.props, source: "a".repeat(201) }, { ...result.props, style: { color: "red" } },
{ ...result.props, action: "execute" },
]) expect(leaf({ ...result, props })).toBeNull();
expect(leaf({ ...result, children: ["leaf"] })).toBeNull();
for (const props of [
{ ...stack.props, intent: "custom" }, { ...stack.props, density: "tiny" },
{ intent: "response", density: "compact" }, { ...stack.props, className: "grid" },
]) expect(parseAnswerUiInput({ ...input, spec: { ...spec, elements: { ...spec.elements, stack: { ...stack, props } } } })).toBeNull();
expect(leaf({ type: "Text", props: { title: "a".repeat(201), text: "说明", tone: "neutral" }, children: [] })).toBeNull();
expect(parseAnswerUiInput({ ...input, spec: { ...spec, elements: { ...spec.elements,
section: { ...section, props: { ...section.props, role: "custom" } },
} } })).toBeNull();
});
it("rejects chart misalignment, nonfinite numbers and unreachable nodes", () => {
const node = { type: "Chart", props: { title: "演练", chart_type: "line", source: "演练数据", x_data: ["08:00"], series: [{ name: "压力", data: [0.3] }] }, children: [] };
const withNode = (value: unknown) => ({ ...input, spec: { root: "chart", elements: { chart: value } } });
@@ -70,7 +101,7 @@ it("finishes a rich chat turn at final_answer and prevents later automatic UI re
const emitted: Array<{ event: string; data: Record<string, unknown> }> = [];
const interrupted: string[] = [];
const toolPart = (id: string, tool: string, args: unknown, output = "{}") => ({ type: "message.part.updated", properties: { sessionID: "s", part: { id, type: "tool", tool, sessionID: "s", messageID: "m", state: { status: "completed", input: args, output, time: { start: 1, end: 2 } } } } });
const events = [toolPart("ui", "present_answer", input), toolPart("final", "final_answer", { answer: "演练结论仍待核验。" }, JSON.stringify({ answer: "演练结论仍待核验。", ui_block_ids: ["pressure", "unknown", "pressure"] })), toolPart("late", "present_answer", { ...input, block_id: "late" }), { type: "session.idle", properties: { sessionID: "s" } }];
const events = [toolPart("ui", "present_answer", { ...input, phase: "final" }), toolPart("final", "final_answer", { answer: "演练结论仍待核验。" }, JSON.stringify({ answer: "演练结论仍待核验。", ui_block_ids: ["pressure", "unknown", "pressure"] })), toolPart("late", "present_answer", { ...input, block_id: "late" }), { type: "session.idle", properties: { sessionID: "s" } }];
const runtime = { prompt: async () => undefined, messages: async () => [], abortSession: async (id: string) => { interrupted.push(id); }, waitForSessionIdle: async () => undefined, subscribeEvents: async () => ({ async *[Symbol.asyncIterator]() { for (const event of events) yield event; } }) } as unknown as OpencodeRuntimeAdapter;
const result = await streamPromptResponse({ runtime, sessionId: "s", clientSessionId: "client", message: "演练", write: (event, data) => emitted.push({ event, data }) });
expect(result).toMatchObject({ failed: false, aborted: false });
@@ -86,3 +117,42 @@ it("finishes a rich chat turn at final_answer and prevents later automatic UI re
expect(emitted.filter((event) => event.event === "answer_ui")).toHaveLength(0);
expect(emitted.filter((event) => event.event === "done")).toHaveLength(1);
});
it("normalizes new publication phases and preserves immutable phases and finalization", async () => {
const { AnswerUiPublication, answerUiAnchors } = await import("../../src/chat/answer-ui-publication.js");
const state = new AnswerUiPublication();
const block = parseAnswerUiInput(input)!;
expect(block.phase).toBe("process");
expect(parseAnswerUiInput({ ...input, phase: "unknown" })).toBeNull();
expect(state.publish(block)).toBeUndefined();
expect(state.publish({ ...block, fallback_text: "更新" })).toBeUndefined();
expect(state.publish({ ...block, phase: "final" })).toContain("不能变更");
expect(state.publish({ ...block, block_id: "final", phase: "final" })).toBeUndefined();
expect(state.publish({ ...block, block_id: "legacy", phase: undefined })).toBeUndefined();
expect(state.finalize(["pressure", "final", "unknown", "final", "legacy"])).toEqual(["final", "legacy"]);
expect(state.publish(block)).toContain("最终答复已提交");
expect(answerUiAnchors("[[ui:final]]\n```md\n[[ui:code]]\n```\n [[ui:indented]]\n> [[ui:quote]]\n~~~\n[[ui:tilde]]\n~~~\n[[ui:legacy]]")).toEqual(["final", "legacy"]);
});
it("filters process refs in the stream and refuses changing an existing block phase", async () => {
const emitted: Array<{ event: string; data: Record<string, unknown> }> = [];
const event = (id: string, tool: string, args: unknown, output = "{}") => ({ type: "message.part.updated", properties: { sessionID: "s", part: { id, type: "tool", tool, sessionID: "s", messageID: "m", state: { status: "completed", input: args, output, time: { start: 1, end: 2 } } } } });
const events = [event("p", "present_answer", input), event("p2", "present_answer", { ...input, fallback_text: "更新过程" }), event("bad", "present_answer", { ...input, phase: "final" }), event("f", "present_answer", { ...input, phase: "final", block_id: "report" }), event("end", "final_answer", {}, JSON.stringify({ answer: "最终正文\n[[ui:report]]", ui_block_ids: ["pressure", "report"] })), { type: "session.idle", properties: { sessionID: "s" } }];
const runtime = { prompt: async () => undefined, messages: async () => [], abortSession: async () => undefined, waitForSessionIdle: async () => undefined, subscribeEvents: async () => ({ async *[Symbol.asyncIterator]() { for (const value of events) yield value; } }) } as unknown as OpencodeRuntimeAdapter;
await streamPromptResponse({ runtime, sessionId: "s", clientSessionId: "client", message: "演练", write: (event, data) => emitted.push({ event, data }) });
expect(emitted.filter((event) => event.event === "answer_ui").map((event) => event.data.phase)).toEqual(["process", "process", "final"]);
expect(emitted.some((event) => event.event === "progress" && String(event.data.title).includes("不能变更"))).toBe(true);
expect(emitted.find((event) => event.event === "final_answer")?.data.ui_block_ids).toEqual(["report"]);
});
it("validates new final-answer anchors without treating code examples as references", async () => {
const { default: finalAnswer } = await import("../../.opencode/tools/final_answer.js");
const { default: presentAnswer } = await import("../../.opencode/tools/present_answer.js");
const context = { sessionID: "s" };
const published = JSON.parse(await presentAnswer.execute(input, context));
expect(published.phase).toBe("process");
const answer = "说明\n[[ui:report]]\n```\n[[ui:example]]\n```";
expect(JSON.parse(await finalAnswer.execute({ answer, ui_block_ids: ["report"] }, context)).ui_block_ids).toEqual(["report"]);
await expect(finalAnswer.execute({ answer, ui_block_ids: ["example"] }, context)).rejects.toThrow("锚点");
await expect(finalAnswer.execute({ answer: "文字", ui_block_ids: ["report"] }, context)).rejects.toThrow("锚点");
});
+6 -6
View File
@@ -156,10 +156,10 @@ describe("agent model config", () => {
});
it("exposes only the two configured gateway models with their roles, icons and context limits", () => {
expect(supportedModels).toEqual(["waternetwork/gpt-6-luna", "waternetwork/gpt-6.1-sol"]);
expect(supportedModels).toEqual(["waternetwork/gpt-5.6-terra", "waternetwork/gpt-5.6-sol"]);
expect(agentModelOptions.map(({ id, label, icon }) => ({ id, label, icon }))).toEqual([
{ id: "waternetwork/gpt-6-luna", label: "快速", icon: "bolt" },
{ id: "waternetwork/gpt-6.1-sol", label: "专家", icon: "sparkle" },
{ id: "waternetwork/gpt-5.6-terra", label: "快速", icon: "bolt" },
{ id: "waternetwork/gpt-5.6-sol", label: "专家", icon: "sparkle" },
]);
expect(isSupportedModel("deepseek/deepseek-flash")).toBe(false);
expect(isSupportedModel("waternetwork/deepseek-flash")).toBe(false);
@@ -167,8 +167,8 @@ describe("agent model config", () => {
expect(isSupportedModel("waternetwork/codex-auto-review")).toBe(false);
const native = serviceOpencodeConfig({});
expect(Object.keys(native.providers)).toEqual(["waternetwork"]);
expect(native.model).toBe("waternetwork/gpt-6-luna");
expect(native.providers.waternetwork.models["gpt-6-luna"].limit?.context).toBe(272000);
expect(native.providers.waternetwork.models["gpt-6.1-sol"].limit?.context).toBe(475000);
expect(native.model).toBe("waternetwork/gpt-5.6-terra");
expect(native.providers.waternetwork.models["gpt-5.6-terra"].limit?.context).toBe(272000);
expect(native.providers.waternetwork.models["gpt-5.6-sol"].limit?.context).toBe(475000);
});
});
+3 -3
View File
@@ -40,12 +40,12 @@ describe("OpenCode 2 business tool registration", () => {
rpc: { register: async (_definition: unknown, handlers: { list: () => Promise<unknown>; read_todos: (input: { session_id: string }) => Promise<unknown> }) => { rpcList = handlers.list; readTodos = handlers.read_todos; } },
storage: { set: async (key: string, value: unknown) => storage.set(key, value), get: async (key: string) => storage.get(key) },
} as never);
expect(tools.get("render_scada_analysis").options).toEqual({ codemode: false, permission: "render_scada_analysis" });
expect(tools.get("present_view").options).toEqual({ codemode: false, permission: "present_view" });
for (const name of ["publish_task_progress", "publish_task_presentation"]) {
expect(tools.get(name).options).toEqual({ codemode: false, permission: name });
}
const render = tools.get("render_scada_analysis");
await expect(render.execute({ reason: "test", items: [{ device_id: "x", level: "wrong" }] }, { sessionID: "s", id: "call-native" })).rejects.toThrow();
const render = tools.get("present_view");
await expect(render.execute({ reason: "test", command: { action: "render_scada_analysis", items: [{ device_id: "x", level: "wrong" }] } }, { sessionID: "s", id: "call-native" })).rejects.toThrow();
const todo = tools.get("todowrite");
const todos = [{ content: "check", status: "pending" }];
await todo.execute({ todos }, { sessionID: "s" });
+45
View File
@@ -0,0 +1,45 @@
import { describe, expect, it } from "bun:test";
import presentView from "../../.opencode/tools/present_view.js";
import { taskPublicationCommandSchema } from "../../.opencode/tools/task-publication-schema.js";
import { viewCommandSchema } from "../../src/frontendAction/command.js";
import { taskPresentationCommandSchema } from "../../src/product/task-presentation.js";
import { frontendActionResultSchema } from "../../src/frontendAction/coordinator.js";
const data = { type: "FeatureCollection", features: [{ type: "Feature", geometry: { type: "Point", coordinates: [121, 31] }, properties: { value: 0.4 } }] };
const chart = { title: "压力", chart_type: "line", x_data: ["10:00"], series: [{ name: "MP01", data: [0.4] }] };
const commands = [
{ action: "locate_features", layer: "pipes", feature_ids: ["P1"] },
{ action: "zoom_to_map", center: [121, 31], zoom: 14 },
{ action: "highlight_features", layer: "pipes", feature_ids: ["P1"], effect: "pulse" },
{ action: "clear_highlights" },
{ action: "open_chart", chart_id: "pressure", chart, evidence_refs: ["E1"] },
{ action: "close_chart", chart_id: "pressure" },
{ action: "set_overlay", overlay_id: "points", title: "证据", mode: "geometry", evidence_id: "E1", data },
{ action: "clear_overlay", overlay_id: "points" },
{ action: "render_scada_analysis", items: [{ device_id: "MP01", level: "high" }] },
{ action: "clear_scada_analysis" },
];
describe("unified view commands", () => {
it("uses identical native and host command definitions for every action", () => {
for (const command of commands) {
expect(presentView.args.command.parse(command)).toEqual(viewCommandSchema.parse(command));
expect(taskPublicationCommandSchema.parse(command)).toEqual(taskPresentationCommandSchema.parse(command));
}
});
it("requires resolved chat overlays and evidence-backed task charts", () => {
const { data: _data, ...unresolved } = commands[6];
expect(viewCommandSchema.safeParse(unresolved).success).toBeFalse();
expect(taskPresentationCommandSchema.safeParse(unresolved).success).toBeTrue();
expect(viewCommandSchema.safeParse({ ...commands[4], evidence_refs: [] }).success).toBeTrue();
expect(taskPresentationCommandSchema.safeParse({ ...commands[4], evidence_refs: [] }).success).toBeFalse();
});
it("rejects old envelopes, unknown fields, scripts, nonfinite numbers and old receipts", () => {
for (const command of [
{ name: "locate_features", params: { layer: "pipes", feature_ids: ["P1"] } },
{ action: "show_chart", ...chart },
{ ...commands[1], zoom: Infinity },
{ ...commands[6], script: "alert(1)" },
]) expect(viewCommandSchema.safeParse(command).success).toBeFalse();
expect(frontendActionResultSchema.safeParse({ version: "frontend-action-result@1", actionId: "a", status: "succeeded", completedAt: Date.now() }).success).toBeFalse();
});
});
+14 -18
View File
@@ -24,8 +24,7 @@ const start = (
const pending = coordinator.request({
sessionId: "session-1",
toolCallId: "call-1",
name,
params,
command: { action: name, ...(params as object) },
});
if (!emitted) throw new Error("request was not emitted");
return { emitted, pending };
@@ -33,7 +32,7 @@ const start = (
describe("FrontendActionCoordinator", () => {
it("registers the finite view actions with 15 second expiry", () => {
expect(frontendActionRegistry.map((action) => action.id).sort()).toEqual(["clear_scada_analysis", "locate_features", "render_scada_analysis", "show_chart", "view_history", "zoom_to_map"]);
expect(frontendActionRegistry.map((action) => action.id).sort()).toEqual(["clear_highlights", "clear_overlay", "clear_scada_analysis", "close_chart", "highlight_features", "locate_features", "open_chart", "render_scada_analysis", "set_overlay", "zoom_to_map"]);
expect(frontendActionRegistry.every((action) => action.risk === "view" && action.timeoutMs === 15_000)).toBeTrue();
});
@@ -41,14 +40,13 @@ describe("FrontendActionCoordinator", () => {
const coordinator = new FrontendActionCoordinator();
const { emitted, pending } = start(coordinator);
expect(emitted).toMatchObject({
name: "render_scada_analysis",
command: { action: "render_scada_analysis", items },
risk: "view",
replayPolicy: "never",
params: { items },
});
expect(emitted.expiresAt - emitted.issuedAt).toBe(15_000);
const result = {
version: "frontend-action-result@1" as const,
version: "frontend-action-result@2" as const,
actionId: emitted.actionId,
status: "succeeded" as const,
output: {
@@ -71,14 +69,13 @@ describe("FrontendActionCoordinator", () => {
const pending = coordinator.request({
sessionId: "session-1",
toolCallId: "call-no-replay",
name: "clear_scada_analysis",
params: {},
command: { action: "clear_scada_analysis" },
});
unregister();
coordinator.registerSink("session-1", (request) => emitted.push(request));
expect(emitted).toHaveLength(1);
coordinator.submitResult("session-1", {
version: "frontend-action-result@1",
version: "frontend-action-result@2",
actionId: emitted[0].actionId,
status: "succeeded",
output: { cleared: true },
@@ -91,14 +88,14 @@ describe("FrontendActionCoordinator", () => {
const coordinator = new FrontendActionCoordinator();
const { emitted, pending } = start(coordinator, "clear_scada_analysis", {});
expect(() => coordinator.submitResult("session-1", {
version: "frontend-action-result@1",
version: "frontend-action-result@2",
actionId: emitted.actionId,
status: "succeeded",
output: { cleared: false },
completedAt: Date.now(),
})).toThrow("invalid frontend action output");
coordinator.submitResult("session-1", {
version: "frontend-action-result@1",
version: "frontend-action-result@2",
actionId: emitted.actionId,
status: "succeeded",
output: { cleared: true },
@@ -113,21 +110,20 @@ describe("FrontendActionCoordinator", () => {
const request = (value: unknown) => coordinator.request({
sessionId: "s",
toolCallId: "call-validation",
name: "render_scada_analysis",
params: value,
command: { action: "render_scada_analysis", ...(value as object) },
});
expect(() => request({ items: [] })).toThrow(FrontendActionError);
expect(() => request({ items: Array.from({ length: 101 }, (_, index) => ({ device_id: `MP${index}`, level: "low" })) })).toThrow("invalid frontend action params");
expect(() => request({ items: [{ device_id: "MP01", level: "high" }, { device_id: "MP01", level: "low" }] })).toThrow("invalid frontend action params");
expect(() => request({ items: [{ device_id: "x".repeat(129), level: "low" }] })).toThrow("invalid frontend action params");
expect(() => request({ items: [{ device_id: "MP01", level: "critical" }] })).toThrow("invalid frontend action params");
expect(() => request({ items: Array.from({ length: 101 }, (_, index) => ({ device_id: `MP${index}`, level: "low" })) })).toThrow("invalid view command");
expect(() => request({ items: [{ device_id: "MP01", level: "high" }, { device_id: "MP01", level: "low" }] })).toThrow("invalid view command");
expect(() => request({ items: [{ device_id: "x".repeat(129), level: "low" }] })).toThrow("invalid view command");
expect(() => request({ items: [{ device_id: "MP01", level: "critical" }] })).toThrow("invalid view command");
});
it("keeps terminal results idempotent and rejects conflicts", async () => {
const coordinator = new FrontendActionCoordinator();
const { emitted, pending } = start(coordinator, "clear_scada_analysis", {});
const result = {
version: "frontend-action-result@1" as const,
version: "frontend-action-result@2" as const,
actionId: emitted.actionId,
status: "succeeded" as const,
output: { cleared: true as const },
+13 -3
View File
@@ -1,11 +1,21 @@
import { describe, expect, it } from "bun:test";
import { tool } from "../../.opencode/tool.js";
import presentView from "../../.opencode/tools/present_view.js";
import { unwrapFrontendActionResult } from "../../.opencode/tools/frontend_action.js";
describe("frontend action tool bridge", () => {
it("accepts a presentation without a reason and trims optional explanations", () => {
const schema = tool.schema.object(presentView.args);
const command = { action: "clear_highlights" };
expect(schema.parse({ command })).toEqual({ command });
expect(schema.parse({ command, reason: " 清理上轮定位 " }).reason).toBe("清理上轮定位");
expect(schema.safeParse({ command, reason: " " }).success).toBeFalse();
});
it("returns only validated browser output for successful actions", () => {
expect(unwrapFrontendActionResult(JSON.stringify({
version: "frontend-action-result@1",
version: "frontend-action-result@2",
actionId: "action-1",
status: "succeeded",
output: { cleared: true },
@@ -15,14 +25,14 @@ describe("frontend action tool bridge", () => {
it("throws the browser error for failed and expired actions", () => {
expect(() => unwrapFrontendActionResult(JSON.stringify({
version: "frontend-action-result@1",
version: "frontend-action-result@2",
actionId: "action-1",
status: "failed",
error: { code: "WFS_UNAVAILABLE", message: "SCADA geometry query failed" },
completedAt: Date.now(),
}))).toThrow("WFS_UNAVAILABLE: SCADA geometry query failed");
expect(() => unwrapFrontendActionResult(JSON.stringify({
version: "frontend-action-result@1",
version: "frontend-action-result@2",
actionId: "action-2",
status: "expired",
error: { code: "ACTION_TIMEOUT", message: "browser action timed out" },
+156
View File
@@ -0,0 +1,156 @@
// Opt-in behavioral evaluation: real inference, synthetic user data, isolated native sessions.
import "../../src/config.js";
import assert from "node:assert/strict";
import { randomBytes } from "node:crypto";
import { mkdtemp, readFile, rm } from "node:fs/promises";
import { createServer } from "node:http";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { OpenCodeClient } from "@opencode/client";
import { getAgentModelOptions, loadAgentModelConfig, parseModelReference } from "../../src/chat/modelConfig.js";
import { answerUiAnchors, AnswerUiPublication } from "../../src/chat/answer-ui-publication.js";
import { parseAnswerUiInput } from "../../src/chat/answer-ui.js";
import { serviceOpencodeConfig } from "../../src/runtime/opencode2-config.js";
import { startOpencode2 } from "../../src/runtime/opencode2-server.js";
import { createConversationWorkspace } from "../../src/runtime/conversationWorkspace.js";
if (process.env.TJWATER_ANSWER_UI_EVAL !== "1") {
console.log("Skipped. Set TJWATER_ANSWER_UI_EVAL=1 to send three synthetic prompts to a real model.");
process.exit(0);
}
const cases = [
{ id: "comparison", component: "Table", prompt: "以下仅为演练资料:管道甲管径400 mm、长度120 m;乙管径250 mm、长度90 m;丙管径300 mm、长度150 m。请比较这三根管道,并说明哪些结论不能仅凭这些属性判断。只依据这些资料回答,不查询外部数据。" },
{ id: "trend", component: "Chart", prompt: "以下仅为演练资料:今天北京时间08:00压力0.42 MPa,09:00压力0.36 MPa,10:00压力0.24 MPa。请分析压力的变化趋势,指出哪个时段下降更明显,并说明判断局限。只依据这些资料回答,不查询外部数据。" },
{ id: "short-explanation", component: null, prompt: "请用一句话解释供水管网中的压力是什么意思。" },
];
const models = loadAgentModelConfig();
const model = process.env.TJWATER_ANSWER_UI_EVAL_MODEL ?? models.defaults.chat;
assert(getAgentModelOptions(models).some((option) => option.id === model), "Select an enabled chat model from agent-models.json");
const { providerID, modelID } = parseModelReference(model);
const root = await mkdtemp(join(tmpdir(), "tjwater-answer-ui-selection-"));
const controller = new AbortController();
const stop = () => controller.abort();
process.once("SIGINT", stop);
process.once("SIGTERM", stop);
let native: Awaited<ReturnType<typeof startOpencode2>> | undefined;
let sessionID: string | undefined;
let failures = 0;
const internalToken = randomBytes(32).toString("hex");
// Only the host-question gate is mocked; both presentation tools execute their real code.
const bridge = createServer(async (req, res) => {
if (req.method !== "POST" || req.headers["x-agent-internal-token"] !== internalToken) { res.writeHead(403).end(); return; }
let raw = "";
for await (const chunk of req) raw += chunk;
const body = JSON.parse(raw) as { session_id?: string; tool?: string };
if (!sessionID || body.session_id !== sessionID) { res.writeHead(403).end(); return; }
if (req.url === "/internal/chat-interactions/policy") {
res.setHeader("content-type", "application/json"); res.end(JSON.stringify({ enabled: false, waiting: false }));
} else if (["/internal/chat-interactions/enter", "/internal/chat-interactions/leave"].includes(req.url ?? "")
&& ["present_answer", "final_answer"].includes(body.tool ?? "")) {
res.setHeader("content-type", "application/json"); res.end("{}");
} else res.writeHead(403).end();
});
try {
await new Promise<void>((resolve, reject) => { bridge.once("error", reject); bridge.listen(0, "127.0.0.1", resolve); });
const bridgeUrl = `http://127.0.0.1:${(bridge.address() as { port: number }).port}`;
const source = JSON.parse(await readFile("opencode.json", "utf8"));
const permissions = [
{ action: "*", resource: "*", effect: "deny" as const },
...["present_answer", "final_answer"].map((action) => ({ action, resource: "*", effect: "allow" as const })),
];
const nativeConfig = serviceOpencodeConfig({ ...source, mcp: { servers: {} }, plugins: [] }, models, root);
nativeConfig.permissions = permissions;
nativeConfig.skills = [];
nativeConfig.agents.instruction.permissions = permissions;
native = await startOpencode2({
command: process.env.OPENCODE_COMMAND ?? "opencode2", hostname: "127.0.0.1", port: 0,
timeout: 30_000, signal: controller.signal, config: nativeConfig,
env: {
AGENT_INTERNAL_TOKEN: internalToken, TJWATER_AGENT_WORKSPACE_ROOT: root,
TJWATER_AGENT_INTERNAL_BASE_URL: bridgeUrl,
TJWATER_AGENT_MCP_CONNECTION_PATH: join(root, "absent-query-mcp.json"),
OPENCODE_DB: join(root, "data", "opencode.db"), XDG_DATA_HOME: join(root, "data"),
XDG_CONFIG_HOME: join(root, "config"), XDG_CACHE_HOME: join(root, "cache"), XDG_STATE_HOME: join(root, "state"),
},
});
console.log(JSON.stringify({ model, cases: cases.length, scope: "native component selection; no business access or browser rendering" }));
for (const scenario of cases) {
controller.signal.throwIfAborted();
const started = Date.now();
const directory = await createConversationWorkspace(root);
sessionID = (await native.client.session.create({ agent: "instruction", model: { providerID, id: modelID }, location: { directory } })).id;
try {
const signal = AbortSignal.any([controller.signal, AbortSignal.timeout(120_000)]);
await native.client.session.prompt({ sessionID, text: scenario.prompt }, { signal });
await native.client.session.wait({ sessionID }, { signal });
const messages: Awaited<ReturnType<OpenCodeClient["message"]["list"]>>["data"] = (await native.client.message.list({ sessionID, order: "asc", limit: 100 }, { signal })).data;
const publication = new AnswerUiPublication();
const blocks = new Map<string, NonNullable<ReturnType<typeof parseAnswerUiInput>>>();
let final: { answer: string; ui_block_ids: string[] } | undefined;
for (const message of messages) {
if (message.type !== "assistant") continue;
assert(!message.error, "Model response failed");
for (const content of message.content) {
if (content.type !== "tool") continue;
assert(["present_answer", "final_answer"].includes(content.name), "Unexpected tool attempted");
assert.equal(content.state.status, "completed", "Presentation tool did not complete");
if (content.state.status !== "completed") continue;
const input = content.state.input;
if (content.name === "present_answer") {
const block = parseAnswerUiInput(input);
assert(block, "Invalid answer UI payload");
assert.equal(publication.publish(block), undefined, "Invalid publication lifecycle");
blocks.set(block.block_id, block);
} else {
assert.equal(final, undefined, "Final answer was submitted more than once");
const value = input as { answer: string; ui_block_ids?: string[] };
final = { answer: value.answer, ui_block_ids: value.ui_block_ids ?? [] };
assert.deepEqual([...new Set(answerUiAnchors(final.answer))], final.ui_block_ids, "Final anchors differ from references");
assert.deepEqual(publication.finalize(final.ui_block_ids), final.ui_block_ids, "Final answer references unavailable blocks");
}
}
}
assert(!messages.some((message) => message.type === "idle" && message.outcome === "failed"), "Native session failed before completion");
assert(final, "Missing final answer");
assert(final.answer.trim(), "Empty final answer");
const nodes = final.ui_block_ids.flatMap((id) => Object.values(blocks.get(id)!.spec.elements));
if (scenario.component) {
const matches = nodes.filter((node) => node.type === scenario.component);
assert(matches.length > 0, `Final answer did not retain ${scenario.component}`);
if (scenario.component === "Chart") {
assert(matches.some((node) => {
const props = node.props as { x_data: string[]; series: Array<{ data: number[] }> };
return props.x_data.length === 3 && ["08:00", "09:00", "10:00"].every((time, i) => props.x_data[i].includes(time))
&& props.series.some((series) => JSON.stringify(series.data) === "[0.42,0.36,0.24]")
&& JSON.stringify(node.props).includes("MPa");
}), "Trend lost input values, times or units");
} else {
assert(matches.some((node) => {
const props = node.props as { rows: string[][] };
return props.rows.length === 3 && [["甲", "400", "120"], ["乙", "250", "90"], ["丙", "300", "150"]]
.every((values) => props.rows.some((row) => values.every((value) => row.some((cell) => cell.includes(value)))));
}), "Comparison lost supplied objects or values");
}
} else assert.equal(blocks.size, 0, "Short explanation generated unnecessary UI");
console.log(JSON.stringify({ case: scenario.id, passed: true, components: nodes.map((node) => node.type), duration_ms: Date.now() - started }));
} catch (error) {
failures += 1;
// Assertion messages are ours; never print provider response bodies or credentials.
console.log(JSON.stringify({ case: scenario.id, passed: false, reason: error instanceof assert.AssertionError ? error.message : "Native request failed or timed out", duration_ms: Date.now() - started }));
} finally {
await native.client.session.interrupt({ sessionID }).catch(() => {});
await native.client.session.remove({ sessionID }).catch(() => {});
sessionID = undefined;
}
}
} finally {
if (native && sessionID) await native.client.session.remove({ sessionID }).catch(() => {});
native?.close();
if (native) await native.exited;
await new Promise<void>((resolve) => bridge.close(() => resolve()));
await rm(root, { recursive: true, force: true });
process.removeListener("SIGINT", stop);
process.removeListener("SIGTERM", stop);
}
if (failures) process.exitCode = 1;
@@ -0,0 +1,43 @@
import { spawn } from 'node:child_process';
import { mkdtemp,mkdir,rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { OpenCode } from '@opencode/client';
// Opt in: RUN_HOST_QUESTION_NATIVE_SMOKE=1 bun tests/integration/hostQuestionNativeSmoke.ts
// Uses only owned temporary roots and resume:false; never calls a provider.
if (process.env.RUN_HOST_QUESTION_NATIVE_SMOKE !== "1") {
console.log("Skipped native host-question smoke; set RUN_HOST_QUESTION_NATIVE_SMOKE=1 to run.");
process.exit(0);
}
const root=await mkdtemp(join(tmpdir(),'tjwater-native-question-'));
await mkdir(join(root,'home')); await mkdir(join(root,'workspace'));
let child:any;
async function start(){
child=spawn(process.env.OPENCODE_COMMAND ?? 'opencode',['serve','--hostname=127.0.0.1','--port=0'],{cwd:root,env:{PATH:process.env.PATH,HOME:join(root,'home'),XDG_CONFIG_HOME:join(root,'config'),XDG_DATA_HOME:join(root,'data'),XDG_CACHE_HOME:join(root,'cache'),OPENCODE_DB:join(root,'native.sqlite'),OPENCODE_CONFIG_CONTENT:JSON.stringify({})},stdio:['ignore','pipe','pipe']});
const endpoint=await new Promise<any>((resolve,reject)=>{let output='';const timer=setTimeout(()=>reject(new Error('startup timeout')),20000);child.on('exit',()=>reject(new Error('startup exit')));child.stderr.resume();child.stdout.on('data',(chunk:any)=>{output+=chunk;const url=/server listening on (https?:\/\/\S+)/.exec(output)?.[1];const password=/server password (\S+)/.exec(output)?.[1];if(url&&password){clearTimeout(timer);resolve({url,password});}});});
return OpenCode.make({baseUrl:endpoint.url,headers:{authorization:`Basic ${Buffer.from(`opencode:${endpoint.password}`).toString('base64')}`}});
}
async function stop(){if(!child)return; const closing=new Promise<void>(r=>child.once('close',()=>r()));child.kill('SIGTERM');await closing;child=undefined;}
try{
let client=await start();
const session=await client.session.create({title:'No-inference host question smoke',location:{directory:join(root,'workspace')}});
const input={sessionID:session.id,id:'msg_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',text:'Durable fixed-ID user answer',resume:false};
await client.session.prompt(input);
let duplicate='accepted';try{await client.session.prompt(input);}catch(e:any){duplicate=e?._tag??e?.name??'rejected';}
const queued=await client.session.inbox.list({sessionID:session.id});
if(queued.filter((x:any)=>x.id===input.id).length!==1)throw new Error('fixed input duplicated');
if((await client.session.active())[session.id])throw new Error('resume:false started inference');
const form=await client.session.form.create({sessionID:session.id,title:'Smoke question',fields:[{key:'q',type:'string',required:true}]});
const before=await client.session.form.get({sessionID:session.id,formID:form.id});
if(before.state.status !== 'pending') throw new Error('new form was not pending');
await client.session.form.reply({sessionID:session.id,formID:form.id,answer:{q:'answer'}});
let terminal='unknown';try{terminal=(await client.session.form.get({sessionID:session.id,formID:form.id})).state.status;}catch(e:any){terminal=e?._tag;}
const cancelled=await client.session.form.create({sessionID:session.id,title:'Cancel smoke',fields:[{key:'q',type:'string'}]});
await client.session.form.cancel({sessionID:session.id,formID:cancelled.id});
let cancelledState='unknown';try{cancelledState=(await client.session.form.get({sessionID:session.id,formID:cancelled.id})).state.status;}catch(e:any){cancelledState=e?._tag;}
await stop();client=await start();
const recovered=await client.session.inbox.list({sessionID:session.id});
if(recovered.filter((x:any)=>x.id===input.id).length!==1)throw new Error('fixed input lost after restart');
let restored='unknown';try{restored=(await client.session.form.get({sessionID:session.id,formID:form.id})).state.status;}catch(e:any){restored=e?._tag;}
console.log(JSON.stringify({native:'current isolated executable',fixed_id_unique:true,duplicate_result:duplicate,restart_durable:true,form_pending:before.state.status,form_after_reply:terminal,form_after_cancel:cancelledState,form_after_restart:restored,inference_started:false}));
}catch(e:any){console.log(JSON.stringify({error:e?.message,tag:e?._tag,name:e?.name}));process.exitCode=1;}finally{await stop();await rm(root,{recursive:true,force:true});}
+2 -3
View File
@@ -78,7 +78,7 @@ try {
for (const action of ["browser", "browser_click"]) {
assert.equal((await native.client.permission.create({ sessionID: sessionId, action, resources: ["*"], save: [] })).effect, "deny");
}
for (const action of ["present_answer", "show_chart"]) {
for (const action of ["present_answer", "present_view"]) {
assert.equal((await native.client.permission.create({ sessionID: sessionId, action, resources: ["*"], save: [] })).effect, "allow");
}
for (const id of ["opencode_models", "opencode_session_move", "opencode_session_rename",
@@ -88,8 +88,7 @@ try {
assert(!tools.some((tool) => tool.id === "mcp_tjwater_web_search"), "web search must not have a duplicate MCP tool");
for (const id of ["query_project_data", "query_operational_task_data", "get_operational_task_context", "session_search", "web_search", "geocode", "todoread", "list_investigation_tasks", "get_investigation_task", "read_agent_messages"]) assert(!tools.some((tool) => tool.id === id), `${id} must not remain a standalone plugin query`);
for (const id of ["memory_manager", "skill_manager", "send_task_message", "reply_agent_message", "todowrite",
"present_answer", "final_answer", "locate_features", "zoom_to_map", "show_chart", "view_history",
"render_scada_analysis", "clear_scada_analysis", "publish_execution_plan", "publish_task_report",
"present_answer", "final_answer", "present_view", "publish_execution_plan", "publish_task_report",
"publish_task_progress", "publish_task_presentation", "propose_task_action", "add_task_human_evidence",
"request_task_data_access"]) assert(tools.some((tool) => tool.id === id), `${id} must remain a plugin tool`);
for (const tool of ["mcp_tjwater_session_search", "mcp_tjwater_todoread"]) {
+28 -8
View File
@@ -1,4 +1,4 @@
import { afterEach, expect, it, mock } from "bun:test";
import { afterEach, expect, it, mock, spyOn } from "bun:test";
import { Client, StreamableHTTPClientTransport, type Tool } from "@modelcontextprotocol/client";
import { startQueryMcpServer } from "../../src/mcp/query-server.js";
import { createLocalQueryExecutor } from "../../src/mcp/local-query-tools.js";
@@ -17,17 +17,17 @@ afterEach(async () => {
});
const productTool: Tool = { name: "get_task_context", description: "Bound task", inputSchema: { type: "object", properties: { report_revision: { type: "integer", minimum: 1 } }, additionalProperties: false } };
it("advertises simulation budgets and derives the native connection ceiling from the catalog", async () => {
it("hides HTTP budgets from models and fixes the native connection ceiling", async () => {
const simulation: Tool = { name: "run_simulation", inputSchema: { type: "object", properties: {
timeout_seconds: { type: "integer", minimum: 1, maximum: 3600, default: 120 },
timeout_seconds: { type: "integer", minimum: 1, maximum: 240, default: 120 },
}, additionalProperties: false } };
const { server, client, call, query } = await setup({ productTools: [simulation] });
setRuntimeSessionContext(owner);
expect(server.executionTimeoutMs).toBe(35_000);
expect((await client.listTools()).tools[0].inputSchema.properties?.timeout_seconds).toMatchObject({ maximum: 3600 });
expect((await call("run_simulation", { timeout_seconds: 900 })).isError).not.toBeTrue();
expect((query.mock.calls[0] as unknown as unknown[])[3]).toEqual({ timeout_seconds: 900 });
try { await call("run_simulation", { timeout_seconds: 3601 }); } catch { /* SDK schema rejection */ }
expect(server.executionTimeoutMs).toBe(300_000);
expect((await client.listTools()).tools[0].inputSchema.properties).not.toHaveProperty("timeout_seconds");
expect((await call("run_simulation", {})).isError).not.toBeTrue();
expect((query.mock.calls[0] as unknown as unknown[])[3]).toEqual({});
try { await call("run_simulation", { timeout_seconds: 241 }); } catch { /* SDK schema rejection */ }
expect(query).toHaveBeenCalledTimes(1);
});
it("discovers calculations natively but denies verified readers before upstream dispatch", async () => {
@@ -183,3 +183,23 @@ it("distinguishes safe network and timeout failures without upstream text", asyn
expect(JSON.stringify(result)).not.toContain("SECRET");
}
});
for (const stage of ["reader", "identity", "artifact"] as const) {
it(`bounds a hanging ${stage} operation within the host deadline`, async () => {
const never = () => new Promise<never>(() => {});
const name = stage === "artifact" ? "export_project_model" : "get_task_context";
const { call } = await setup({ productTools: [{ ...productTool, name }],
...(stage === "reader" ? { resolveReader: never } : {}),
...(stage === "identity" ? { resolveNativeCall: never } : {}),
...(stage === "artifact" ? { artifactWorkspace: never } : {}),
});
if (stage !== "reader") setRuntimeSessionContext(owner);
const original = AbortSignal.timeout.bind(AbortSignal);
const timeout = spyOn(AbortSignal, "timeout").mockImplementation((ms) => original(ms === 180_000 ? 20 : ms));
try {
const result = await call(name);
expect(result.isError).toBeTrue();
expect(JSON.stringify(result)).toContain("Query timed out");
} finally { timeout.mockRestore(); }
});
}
+17 -22
View File
@@ -1,34 +1,29 @@
import { expect, it } from "bun:test";
import type { Tool } from "@modelcontextprotocol/client";
import { nativeQueryTimeout, queryTimeouts } from "../../src/mcp/query-timeouts.js";
import { boundedQuery, nativeQueryTimeout, queryTimeouts } from "../../src/mcp/query-timeouts.js";
const simulation: Tool = { name: "run_simulation", inputSchema: { type: "object", properties: {
timeout_seconds: { type: "integer", minimum: 1, maximum: 3600, default: 120 },
timeout_seconds: { type: "integer", minimum: 1, maximum: 240, default: 120 },
} } };
it("budgets calculations independently from their simulated duration and from ordinary reads", () => {
expect(queryTimeouts(simulation, { duration: 86400, timeout_seconds: 900 })).toEqual({ executionMs: 900_000, callMs: 30_000, requestMs: 35_000 });
expect(queryTimeouts(simulation, {})).toEqual({ executionMs: 120_000, callMs: 30_000, requestMs: 35_000 });
expect(queryTimeouts({ name: "list_elements", inputSchema: { type: "object" } }, {})).toEqual({ executionMs: 30_000, callMs: 30_000, requestMs: 35_000 });
it("keeps HTTP budgets host-owned and separate from calculation duration", () => {
expect(queryTimeouts(simulation, { duration: 86400, timeout_seconds: 240 })).toEqual({ serverMs: 120_000, callMs: 180_000, requestMs: 180_000 });
expect(queryTimeouts({ name: "list_elements", inputSchema: { type: "object" }, _meta: {
"io.tjwater/request-timeout": { defaultSeconds: 240, maxSeconds: 240 },
} }).requestMs).toBe(270_000);
expect(nativeQueryTimeout([simulation])).toBe(300_000);
expect(nativeQueryTimeout([])).toBe(300_000);
});
it("honors the effective advertised maximum and rejects invalid per-call budgets", () => {
for (const timeout_seconds of [0, -1, 1.5, 3601, "900", null, true, NaN, Infinity]) {
expect(() => queryTimeouts(simulation, { timeout_seconds })).toThrow();
it("falls back safely for malformed advertised budgets", () => {
for (const metadata of [null, [], { defaultSeconds: 999, maxSeconds: 999 }, { defaultSeconds: 240, maxSeconds: 120 }, { defaultSeconds: "120", maxSeconds: 240 }]) {
expect(queryTimeouts({ ...simulation, _meta: { "io.tjwater/request-timeout": metadata } }).requestMs).toBe(180_000);
}
const limited = { ...simulation, inputSchema: { type: "object" as const, properties: {
timeout_seconds: { type: "integer", maximum: 120, default: 120 },
} } };
expect(queryTimeouts(limited, { timeout_seconds: 120 }).executionMs).toBe(120_000);
expect(() => queryTimeouts(limited, { timeout_seconds: 121 })).toThrow();
expect(nativeQueryTimeout([simulation, limited])).toBe(35_000);
expect(nativeQueryTimeout([limited])).toBe(35_000);
expect(queryTimeouts({ ...simulation, inputSchema: { type: "object", properties: {
timeout_seconds: { type: "integer", maximum: 3600, default: 1800 },
} } }).requestMs).toBe(180_000);
});
it("uses the catalog's thirty-minute burst-location default as a worker execution budget", () => {
const location: Tool = { name: "run_burst_location", inputSchema: { type: "object", properties: {
timeout_seconds: { type: "integer", minimum: 1, maximum: 3600, default: 1800 },
} } };
expect(queryTimeouts(location, {})).toEqual({ executionMs: 1_800_000, callMs: 30_000, requestMs: 35_000 });
expect(nativeQueryTimeout([location])).toBe(35_000);
it("returns on deadline even when the underlying adapter ignores cancellation", async () => {
await expect(boundedQuery(() => new Promise(() => {}), AbortSignal.timeout(10))).rejects.toThrow();
});
+78
View File
@@ -36,3 +36,81 @@ it("resumes recovery for a worker-interrupted job instead of waiting or completi
expect(resume).toContain("不要重复提交");
expect(pause).not.toHaveBeenCalled();
});
for (const failure of [
Object.assign(new TypeError("fetch failed"), { cause: { code: "ECONNRESET" } }),
new DOMException("Timed out", "TimeoutError"),
Object.assign(new Error("busy"), { status: 429 }),
Object.assign(new Error("unavailable"), { status: 503 }),
...["RATE_LIMITED", "QUERY_TIMEOUT", "SOURCE_UNAVAILABLE"].map((code) => ({ content: [], isError: true, structuredContent: { error: { code } } })),
]) {
it(`retries transient status failures (${"name" in failure ? failure.name : failure.structuredContent.error.code})`, async () => {
let calls = 0;
const query = mock(async () => {
if (calls++ === 0) { if (failure instanceof Error || failure instanceof DOMException) throw failure; return failure; }
return result("succeeded");
});
const pause = mock(async (_ms: number, _signal: AbortSignal) => {});
const wait = createTaskCalculationWaiter({ query }, () => owner, pause, () => 0.5);
expect(await wait("s", new AbortController().signal)).toContain("succeeded");
expect(pause.mock.calls.map(([ms]) => ms)).toEqual([1000]);
expect(query).toHaveBeenCalledTimes(2);
});
}
it("bounds exponential retry delays and fails clearly after exhaustion", async () => {
const names: string[] = [];
const query = mock(async (_id: string, _owner: unknown, name: string) => {
names.push(name);
throw Object.assign(new Error("unavailable"), { status: 503 });
});
const pause = mock(async (_ms: number, _signal: AbortSignal) => {});
const wait = createTaskCalculationWaiter({ query }, () => owner, pause, () => 1);
await expect(wait("s", new AbortController().signal)).rejects.toThrow("after transient retries");
expect(pause.mock.calls.map(([ms]) => ms)).toEqual([1250, 2500, 5000]);
expect(names).toEqual(Array(4).fill("list_calculation_jobs"));
});
for (const failure of [
Object.assign(new Error("denied"), { status: 401 }),
Object.assign(new Error("denied"), { status: 403 }),
...["AUTH_REQUIRED", "QUERY_CONTEXT_DENIED", "QUERY_CONTEXT_STALE"].map((code) => ({ content: [], isError: true, structuredContent: { error: { code } } })),
]) {
it("fails authorization and stale context errors without retrying", async () => {
const query = mock(async () => { if (failure instanceof Error) throw failure; return failure; });
const pause = mock(async (_ms: number, _signal: AbortSignal) => {});
await expect(createTaskCalculationWaiter({ query }, () => owner, pause)("s", new AbortController().signal)).rejects.toThrow();
expect(query).toHaveBeenCalledTimes(1);
expect(pause).not.toHaveBeenCalled();
});
}
it("aborts retry backoff without reading again or cancelling jobs", async () => {
const controller = new AbortController();
const names: string[] = [];
const query = mock(async (_id: string, _owner: unknown, name: string) => {
names.push(name);
throw new DOMException("Timed out", "TimeoutError");
});
const wait = createTaskCalculationWaiter({ query }, () => owner, async () => { controller.abort(); });
await expect(wait("s", controller.signal)).rejects.toThrow();
expect(names).toEqual(["list_calculation_jobs"]);
});
it("returns new terminal jobs while other jobs remain pending and checks pending jobs next round", async () => {
const mixed = { content: [], structuredContent: { data: [{ job_id: "job-1", status: "succeeded" }, { job_id: "job-2", status: "running" }], page: { has_more: false } } };
const finished = { content: [], structuredContent: { data: [{ job_id: "job-1", status: "succeeded" }, { job_id: "job-2", status: "failed" }], page: { has_more: false } } };
const outputs = [mixed, mixed, finished];
const query = mock(async () => outputs.shift()!);
const pause = mock(async (_ms: number, _signal: AbortSignal) => {});
const wait = createTaskCalculationWaiter({ query }, () => owner, pause);
const signal = new AbortController().signal;
const first = await wait("s", signal);
expect(first).toContain("job-1");
expect(first).not.toContain("job-2");
expect(pause).not.toHaveBeenCalled();
const second = await wait("s", signal);
expect(second).toContain("job-2");
expect(second).not.toContain("job-1");
expect(pause).toHaveBeenCalledTimes(1);
});
+5 -4
View File
@@ -8,17 +8,18 @@ const disabledServerTools = [
];
describe("disabled opencode tools", () => {
it("keeps removed generic server tools disabled while retaining SCADA frontend actions", () => {
it("keeps removed generic server tools disabled while using one typed presentation tool", () => {
for (const toolName of disabledServerTools) {
expect(existsSync(`.opencode/tools/${toolName}.ts`)).toBeFalse();
}
expect(existsSync(".opencode/tools/server_api_shared.ts")).toBeFalse();
expect(existsSync(".opencode/tools/web_search.ts")).toBeFalse();
expect(existsSync(".opencode/tools/geocode.ts")).toBeFalse();
expect(existsSync(".opencode/tools/zoom_to_map.ts")).toBeTrue();
expect(existsSync(".opencode/tools/present_view.ts")).toBeTrue();
for (const name of ["locate_features", "zoom_to_map", "show_chart", "view_history", "render_scada_analysis", "clear_scada_analysis"]) {
expect(existsSync(`.opencode/tools/${name}.ts`)).toBeFalse();
}
expect(existsSync(".opencode/tools/frontend_action.ts")).toBeTrue();
expect(existsSync(".opencode/tools/render_scada_analysis.ts")).toBeTrue();
expect(existsSync(".opencode/tools/clear_scada_analysis.ts")).toBeTrue();
expect(existsSync(".opencode/plugins/tjwater-tools.ts")).toBeTrue();
});
});
+3 -2
View File
@@ -17,11 +17,12 @@ it("configures a pinned native MCP connection using the host-owned endpoint and
const root = mkdtempSync(join(tmpdir(), "query-connection-")); roots.push(root);
process.env.AGENT_INTERNAL_TOKEN = "MCP_TEST_ONLY";
writeQueryMcpConnection(join(root, "sessions"), "http://127.0.0.1:12345/mcp");
expect(queryMcpConnection()?.timeout?.execution).toBe(300_000);
expect(queryMcpConnection()).toMatchObject({ type: "remote", protocol: "2026-07-28", oauth: false, codemode: false, headers: { "x-agent-internal-token": "MCP_TEST_ONLY" } });
writeQueryMcpConnection(join(root, "sessions"), "http://127.0.0.1:12345/mcp", 3_665_000);
expect(() => queryMcpConnection()).toThrow("timeout");
writeQueryMcpConnection(join(root, "sessions"), "http://127.0.0.1:12345/mcp", 35_000);
expect(queryMcpConnection()?.timeout?.execution).toBe(35_000);
writeQueryMcpConnection(join(root, "sessions"), "http://127.0.0.1:12345/mcp", 300_000);
expect(queryMcpConnection()?.timeout?.execution).toBe(300_000);
writeQueryMcpConnection(join(root, "sessions"), "http://127.0.0.1:12345/mcp", Infinity);
expect(() => queryMcpConnection()).toThrow("timeout");
writeQueryMcpConnection(join(root, "sessions"), "https://foreign.test/mcp");
+27 -1
View File
@@ -1,5 +1,6 @@
import { describe, expect, it } from "bun:test";
import { taskReportSchema, taskProgressSchema } from "../../src/product/investigation-report.js";
import { taskPublicationDiagramSchema, taskPublicationProgressSchema, taskReportUiBlocksSchema } from "../../.opencode/tools/task-publication-schema.js";
const report = { title: "调查报告", current_conclusion: "无法确定原因", conclusion_boundary: "仅有上游数据",
evidence_refs: ["query-1"], recommendations: [], pending_items: [], stage: "final", outcome: "insufficient_data",
@@ -26,6 +27,9 @@ describe("new investigation report writes", () => {
for (const mermaid of ['%%{init: {}}%%\nflowchart TD\nA-->B', 'flowchart TD\nclick A callback', 'flowchart TD; A-->B; click A callback', 'sequenceDiagram\nA->>B: 查询']) {
expect(() => taskReportSchema.parse({ ...report, diagram: { ...diagram, mermaid } })).toThrow();
}
const mismatched = { ...diagram, node_refs: [{ ...diagram.node_refs[0], node_id: "query" }] };
expect(() => taskReportSchema.parse({ ...report, diagram: mismatched })).toThrow("未在 Mermaid 源码中声明");
expect(taskPublicationDiagramSchema.safeParse(mismatched).success).toBe(false);
});
it("rejects downstream work or unsupported claims on insufficient-data exit", () => {
for (const changes of [{ recommendations: ["派采样工单"] }, { pending_items: ["等待人工"] },
@@ -43,13 +47,26 @@ describe("new investigation report writes", () => {
expect(progress.diagram?.node_refs[0]).toMatchObject({ status: "running", map_targets: [{ source_id: "scada", feature_id: "MP01" }] });
expect(taskReportSchema.parse({ ...report, diagram }).diagram).toEqual(progress.diagram);
expect(taskProgressSchema.parse({ summary: "调整调查方向", diagram: null }).diagram).toBeNull();
for (const change of [{ status: "waiting" }, { map_targets: [{ source_id: "drainage", feature_id: "X" }] },
for (const change of [{ status: "blocked" }, { map_targets: [{ source_id: "drainage", feature_id: "X" }] },
{ map_targets: [{ source_id: "scada", feature_id: "" }] }]) {
expect(() => taskProgressSchema.parse({ summary: "核对", diagram: {
...diagram, node_refs: [{ ...diagram.node_refs[0], ...change }],
} })).toThrow();
}
});
it("preserves waiting, unknown and evidence-confirmed states at every diagram boundary", () => {
const diagram = { title: "等待系统结果", mermaid: "flowchart LR\nDONE --> WAIT --> UNKNOWN --> UNSET", node_refs: [
{ node_id: "DONE", label: "查询完成", status: "completed", evidence_refs: ["query-1"] },
{ node_id: "WAIT", label: "等待系统结果", status: "waiting" },
{ node_id: "UNKNOWN", label: "未知", status: null },
{ node_id: "UNSET", label: "未确认" },
] };
const nodes = taskProgressSchema.parse({ summary: "等待系统结果", diagram }).diagram!.node_refs;
expect(nodes?.map((node) => node.status)).toEqual(["completed", "waiting", null, undefined]);
expect(taskReportSchema.parse({ ...report, diagram }).diagram?.node_refs).toEqual(nodes);
expect(taskPublicationDiagramSchema.parse(diagram).node_refs).toEqual(nodes);
expect(taskPublicationProgressSchema.parse({ summary: "等待系统结果", diagram }).diagram?.node_refs).toEqual(nodes);
});
it("preserves parallel branches with independent activity and a pending join", () => {
const mermaid = 'flowchart TD\nSTART[受理投诉] --> SPLIT{并行核查}\nSPLIT --> TICKETS[工单核查]\nSPLIT --> SCADA[监测核查]\nTICKETS --> JOIN[汇总证据]\nSCADA --> JOIN';
const diagram = { title: "并行调查", mermaid, node_refs: [
@@ -76,6 +93,15 @@ const uiBlock = {
};
describe("interactive report publication", () => {
it("accepts final and legacy blocks and rejects process or unknown phases at both publication boundaries", () => {
for (const phase of [undefined, "final", "process", "unknown"]) {
const block = phase === undefined ? uiBlock : { ...uiBlock, phase };
const blocks = [{ block, evidence_refs: ["query-1"] }];
const accepted = phase === undefined || phase === "final";
expect(taskReportSchema.safeParse({ ...report, ui_blocks: blocks }).success).toBe(accepted);
expect(taskReportUiBlocksSchema.safeParse(blocks).success).toBe(accepted);
}
});
it("preserves rich blocks and legacy reports without changing fixed facts", () => {
expect(taskReportSchema.parse(report).ui_blocks).toBeUndefined();
const parsed = taskReportSchema.parse({ ...report, ui_blocks: [{ block: uiBlock, evidence_refs: ["query-1"] }] });
+9 -7
View File
@@ -7,7 +7,7 @@ const sessions: string[] = [];
afterEach(() => { for (const id of sessions.splice(0)) releaseSessionCredential(id); });
const settings = { issuer: "http://localhost/realms/test", resource: "http://localhost/mcp", audience: "tjwater-mcp", clientId: "tjwater-agent-mcp", clientSecret: "fixture-secret" };
const owner = (id: string) => ({ context: { sessionId: id, actorKey: "actor", clientSessionId: id, projectId: "project", projectKey: "project", traceId: "test", purpose: "ordinary_chat" as const }, runtimeSessionId: id });
const catalog = PRODUCT_MCP_TOOLS.map((name) => ({ name, inputSchema: { type: "object" as const, additionalProperties: false, properties: { context_handle: { type: "string" }, element_type: { type: "string" }, ...(name === "run_simulation" ? { timeout_seconds: { type: "integer", default: 120, maximum: 3600 } } : {}) }, required: ["context_handle", "element_type"] } }));
const catalog = PRODUCT_MCP_TOOLS.map((name) => ({ name, inputSchema: { type: "object" as const, additionalProperties: false, properties: { context_handle: { type: "string" }, element_type: { type: "string" }, ...(name === "run_simulation" ? { timeout_seconds: { type: "integer", default: 120, maximum: 240 } } : {}) }, required: ["context_handle", "element_type"] } }));
// Test through a transport-independent fake only where the concern is token ownership/renewal.
function fixture(http: McpHttpFetch, authMethod = "client_secret_post") {
const client = new McpQueryClient(settings, (async (url, init) => {
@@ -26,7 +26,7 @@ const resourceToken = jwt("resource");
const token = () => Response.json({ access_token: resourceToken, token_type: "Bearer", expires_in: 300 });
function credential(id: string) { sessions.push(id); bindSessionCredential(id, { authorization: `Bearer ${jwt(id)}`, projectId: "project" }); }
it("forwards the selected simulation budget through HTTP and SDK while rejecting excess before OAuth", async () => {
it("owns simulation HTTP budgets and rejects model overrides before OAuth", async () => {
const id = "mcp-calculation-budget"; credential(id);
let requests = 0, transportTimeout = 0, sdkTimeout = 0;
const client = fixture((async (url, init) => {
@@ -42,12 +42,14 @@ it("forwards the selected simulation budget through HTTP and SDK while rejecting
return { content: [], structuredContent: params };
} });
} });
await expect(client.query(id, owner(id), "run_simulation", { timeout_seconds: 3601 }, "excess")).rejects.toMatchObject({ status: 400 });
await expect(client.query(id, owner(id), "run_simulation", { timeout_seconds: 241 }, "excess")).rejects.toMatchObject({ status: 400 });
expect(requests).toBe(0);
const result = await client.query(id, owner(id), "run_simulation", { timeout_seconds: 900 }, "long");
expect(transportTimeout).toBe(30_000);
expect(sdkTimeout).toBe(30_000);
expect(result.structuredContent).toMatchObject({ arguments: { timeout_seconds: 900, context_handle: "x".repeat(43) } });
expect(client.catalog().find((tool) => tool.name === "run_simulation")!.inputSchema.properties).not.toHaveProperty("timeout_seconds");
Object.assign(client, { tools: catalog.map((tool) => ({ ...tool, _meta: { "io.tjwater/request-timeout": { defaultSeconds: 240, maxSeconds: 240 } } })) });
const result = await client.query(id, owner(id), "run_simulation", {}, "long");
expect(transportTimeout).toBe(270_000);
expect(sdkTimeout).toBe(270_000);
expect(result.structuredContent).toMatchObject({ arguments: { timeout_seconds: 240, context_handle: "x".repeat(43) } });
});
it("requires HTTPS except loopback, and fixed resource paths", () => {
@@ -196,6 +196,26 @@ describe("OperationalTaskClient", () => {
expect(requests[2]!.headers.get("authorization")).toBe("Bearer transient-secret");
});
it("injects the claimed run into task questions without changing its lease revision", async () => {
const requests: Request[] = [];
globalThis.fetch = (async (input, init) => {
const request = new Request(input, init); requests.push(request);
if (request.url.endsWith("/questions")) return Response.json({ question_request_id: crypto.randomUUID(), status: "pending", version: 1 });
return Response.json(run);
}) as typeof fetch;
bindOperationalTaskCredential("runtime-1", { authorization: "Bearer transient-secret", projectId: "project-1" });
const client = new OperationalTaskClient("http://server.test");
const questions = [{ question_id: "flow", title: "流量", body: "是否有计量记录?", options: [],
mode: "single" as const, allow_custom: true, required: true }];
await expect(client.askTaskUser("runtime-1", taskId, { request_id: executionRequestId,
sourceSessionId: "session-1", questions })).rejects.toThrow("task run context unavailable");
await client.claimTask("runtime-1", taskId, runId, { requestId: executionRequestId, expectedRevision: 1, sourceSessionId: "session-1" });
await client.askTaskUser("runtime-1", taskId, { request_id: executionRequestId, sourceSessionId: "session-1", questions });
expect(await requests[1]!.json()).toEqual({ request_id: executionRequestId, source_session_id: "session-1", run_id: runId, questions });
await client.heartbeatTask("runtime-1", taskId, runId, "session-1");
expect(await requests[2]!.json()).toMatchObject({ expected_revision: run.revision });
});
it("fails closed without a current product credential", async () => {
const client = new OperationalTaskClient("http://server.test");
expect(client.getAgentContext("runtime-1", "50000000-0000-4000-8000-000000000001"))
+6 -1
View File
@@ -19,7 +19,7 @@ describe("task presentation input boundaries", () => {
it("accepts finite display commands and rejects unsupported data, layers and chart evidence", () => {
const chart = { title: "压力", chart_type: "line", x_data: ["10:00"], series: [{ name: "MP01", data: [0.4] }] };
const commands = [
{ action: "highlight_features", layer: "scada", feature_ids: ["MP01"] },
{ action: "highlight_features", layer: "scada", feature_ids: ["MP01"], effect: "pulse" },
{ action: "clear_highlights" },
{ action: "open_chart", chart_id: "pressure", chart, evidence_refs: ["reading-1"] },
{ action: "close_chart", chart_id: "pressure" },
@@ -47,5 +47,10 @@ describe("task presentation input boundaries", () => {
await expect(publishProgress.execute({ request_id, progress: {
summary: "查询", diagram: { title: "路线", mermaid: "flowchart TD; A-->B; click A callback", node_refs: [] },
} }, { sessionID: "test" })).rejects.toThrow("Mermaid");
await expect(publishProgress.execute({ request_id, progress: {
summary: "查询", diagram: { title: "路线", mermaid: "flowchart TD\nA-->B",
node_refs: [{ node_id: "query", label: "查询", status: "running",
evidence_refs: [], action_refs: [], map_targets: [] }] },
} }, { sessionID: "test" })).rejects.toThrow("未在 Mermaid 源码中声明");
});
});
+23 -9
View File
@@ -21,7 +21,7 @@ const trigger = {
location: "演示小区",
priority: "high",
scenario_code: "WQ-SECONDARY-SUPPLY-001",
origin: "synthetic",
origin: "system_data",
},
} as const;
@@ -30,9 +30,10 @@ describe("agent event trigger", () => {
const parsed = agentTriggerContextSchema.parse(trigger);
const prompt = buildAgentTriggerPrompt(parsed);
expect(prompt).toContain("水质监测点");
expect(prompt).toContain("二次供水");
expect(prompt).toContain("合成演练数据");
expect(prompt).toContain("不得预设上游正常");
expect(prompt).not.toContain("上游三个模拟水质监测点的数据质量均为 valid");
expect(prompt).toContain("外围业务系统的系统数据");
expect(prompt).not.toContain("合成演练来源");
expect(prompt).toContain("动态规划本次调查");
expect(prompt).not.toContain("请按‘上游数据质量正常");
expect(prompt).toContain(trigger.trigger_id);
@@ -41,23 +42,36 @@ describe("agent event trigger", () => {
it("keeps event entry and action-tool instructions consistent with report-driven completion", () => {
const prompt = buildAgentTriggerPrompt(agentTriggerContextSchema.parse(trigger));
expect(prompt).toContain("mcp_tjwater_get_task_context");
expect(prompt).toContain("final/insufficient_data");
expect(prompt).toContain("正常结束本轮");
expect(prompt).toContain("业务 Skill");
expect(prompt).toContain("后台调查指令的停止条件");
expect(prompt).toContain("权限拒绝和接口错误不能证明数据缺失");
expect(prompt).toContain("interim/concluded");
expect(prompt).not.toContain("提议成功后再保存 final");
expect(prompt).not.toContain("开始调查前调用 publish_execution_plan");
expect(prompt).not.toContain("人工工作保持 waiting");
expect(proposeTaskAction.description).toContain("interim concluded report");
expect(proposeTaskAction.description).toContain("No execution plan is required");
expect(proposeTaskAction.description).toContain("node_id is an optional display reference");
expect(proposeTaskAction.description).toContain("continue executable necessary analysis");
expect(proposeTaskAction.description).not.toContain("After success, publish a final report");
expect(proposeTaskAction.description).not.toContain("published current plan");
expect(proposeTaskAction.description).not.toContain("republish the plan");
});
it("rejects a different scenario instead of routing arbitrary work orders", () => {
it("accepts business intake without a demo scenario", () => {
const parsed = agentTriggerContextSchema.parse({
...trigger, payload: { ...trigger.payload, origin: "system_data", scenario_code: null },
});
const prompt = buildAgentTriggerPrompt(parsed);
expect(prompt).toContain("外围业务系统的系统数据");
expect(prompt).not.toContain("合成演练");
expect(prompt).not.toContain("且相邻片区没有同类投诉");
expect(prompt).toContain("mcp_tjwater_get_task_context");
});
it("rejects any non-system work-order provenance", () => {
expect(agentTriggerContextSchema.safeParse({
...trigger,
payload: { ...trigger.payload, scenario_code: "UNKNOWN" },
payload: { ...trigger.payload, origin: "synthetic" },
}).success).toBe(false);
});
});
@@ -0,0 +1,77 @@
import { expect, it } from "bun:test";
import express, { type Request } from "express";
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { config } from "../../src/config.js";
import { attachTrustedAgentContext, getLocalAgentContext, ProductContextError } from "../../src/context/localContext.js";
import { registerAnswerViewStateRoutes } from "../../src/routes/answer-view-state-routes.js";
import { AnswerViewStateStore } from "../../src/sessions/answer-view-state-store.js";
import { SessionMetadataStore } from "../../src/sessions/metadataStore.js";
import { SessionUiStateStore } from "../../src/sessions/uiStateStore.js";
import { SessionWorkspaceStore } from "../../src/sessions/workspace-store.js";
it("authenticates state reads and writes against the persisted session owner and returns safe conflicts", async () => {
const originalFetch = globalThis.fetch;
const originalEnvironment = config.NODE_ENV;
const root = await mkdtemp(join(tmpdir(), "answer-state-routes-"));
const workspaces = new SessionWorkspaceStore(join(root, "workspaces"));
const sessions = new SessionMetadataStore(join(root, "metadata"), workspaces);
const states = new AnswerViewStateStore(workspaces);
globalThis.fetch = (async (input: Parameters<typeof fetch>[0], init: Parameters<typeof fetch>[1]) => {
const url = String(input);
const headers = new Headers(init?.headers);
if (url.endsWith("/api/v1/agent-auth-context")) return Response.json({ user_id: headers.get("Authorization") === "Bearer OWNER" ? "owner" : "other", project_id: headers.get("X-Project-Id"), network: "supply" });
if (url.endsWith("/api/v1/access-context")) return Response.json({ user_id: headers.get("Authorization") === "Bearer OWNER" ? "owner" : "other" });
return originalFetch(input, init);
}) as typeof fetch;
config.NODE_ENV = "production";
const ownerRequest = { header: (name: string) => ({ authorization: "Bearer OWNER", "x-project-id": "project" })[name.toLowerCase() as "authorization" | "x-project-id"] } as Request;
await attachTrustedAgentContext(ownerRequest);
const context = getLocalAgentContext(ownerRequest);
const sessionId = "state-session";
await sessions.ensure({ ...context, sessionId });
await new SessionUiStateStore(join(root, "unused"), workspaces).write({ ...context, sessionId }, { sessionId, messages: [{ id: "assistant", role: "assistant", parts: [{ type: "data-answer_ui", data: { version: "answer-ui@1", block_id: "block", fallback_text: "fallback", spec: { root: "evidence", elements: { evidence: { type: "Evidence", props: { title: "Evidence", summary: "Summary", source: "source", evidence_id: null, observed_at: null, kind: "observed" }, children: [] } } } } }] }] });
const app = express();
app.use(express.json({ limit: "2mb" }));
app.use(async (req, res, next) => {
try { await attachTrustedAgentContext(req); next(); }
catch (error) { res.status(error instanceof ProductContextError ? error.status : 503).end(); }
});
registerAnswerViewStateRoutes(app, { sessions, states });
const server = app.listen(0, "127.0.0.1");
await new Promise<void>((resolve) => server.once("listening", resolve));
const url = `http://127.0.0.1:${(server.address() as { port: number }).port}/session/${sessionId}/ui-state`;
const headers = { Authorization: "Bearer OWNER", "X-Project-Id": "project", "Content-Type": "application/json" };
try {
expect((await originalFetch(`${url}?message_id=assistant&block_id=block`)).status).toBe(401);
for (const bad of [{ ...headers, Authorization: "Bearer OTHER" }, { ...headers, "X-Project-Id": "other" }]) {
expect((await originalFetch(`${url}?message_id=assistant&block_id=block`, { headers: bad })).status).toBe(404);
expect((await originalFetch(url, { method: "PUT", headers: bad, body: '{}' })).status).toBe(404);
}
const initial = await originalFetch(`${url}?message_id=assistant&block_id=block`, { headers });
expect(initial.headers.get("Cache-Control")).toBe("private, no-store");
const first = await initial.json() as { schema_digest: string };
const payload = { message_id: "assistant", block_id: "block", schema_digest: first.schema_digest, expected_revision: 0, request_id: "request", nodes: { evidence: { type: "Evidence", open: true } }, block: { evidenceFilter: "all" } };
expect((await originalFetch(url, { method: "PUT", headers, body: JSON.stringify(payload) })).status).toBe(200);
const conflict = await originalFetch(url, { method: "PUT", headers, body: JSON.stringify({ ...payload, request_id: "new" }) });
expect(conflict.status).toBe(409);
expect(await conflict.json()).toMatchObject({ code: "ANSWER_VIEW_STATE_CONFLICT", current: { revision: 1 } });
expect((await originalFetch(`${url}?message_id=assistant&block_id=missing`, { headers })).status).toBe(404);
expect((await originalFetch(`${url}?message_id=assistant&block_id=block&unexpected=x`, { headers })).status).toBe(400);
await writeFile(workspaces.path({ ...context, sessionId }, "answer-view-state.json"), JSON.stringify({ damaged: `${root}/private-form-content` }));
for (const response of [
await originalFetch(`${url}?message_id=assistant&block_id=block`, { headers }),
await originalFetch(url, { method: "PUT", headers, body: JSON.stringify(payload) }),
]) {
expect(response.status).toBe(503);
expect(await response.json()).toEqual({ code: "ANSWER_VIEW_STATE_UNAVAILABLE", detail: "answer view state unavailable" });
}
await sessions.remove((await sessions.get(context, sessionId))!);
expect((await originalFetch(url, { method: "PUT", headers, body: JSON.stringify(payload) })).status).toBe(404);
} finally {
globalThis.fetch = originalFetch; config.NODE_ENV = originalEnvironment;
await new Promise<void>((resolve) => server.close(() => resolve()));
await rm(root, { recursive: true, force: true });
}
});
+2 -2
View File
@@ -13,7 +13,7 @@ describe("business stages and final answer", () => {
track(part("b", "error"));
expect(events.at(-1)).toMatchObject({ id: "business-data", status: "error" });
});
it("buffers final_answer until the runtime succeeds and does not end the run at the tool", async () => {
it("keeps background task completion under runtime and Server control", async () => {
const output: string[] = [];
const events = [
{ type: "message.part.updated", properties: { sessionID: "s", part: { id: "f", type: "tool", tool: "final_answer", state: { status: "completed", input: { answer: "最终结论" }, output: JSON.stringify({ answer: "最终结论" }) } } } },
@@ -21,7 +21,7 @@ describe("business stages and final answer", () => {
{ type: "session.idle", properties: { sessionID: "s" } },
];
const runtime = { prompt: async () => undefined, subscribeEvents: async () => ({ async *[Symbol.asyncIterator]() { yield* events; } }), messages: async () => { throw new Error("final answer needs no text fallback"); } } as unknown as OpencodeRuntimeAdapter;
const result = await streamPromptResponse({ runtime, sessionId: "s", clientSessionId: "c", message: "test", write: (event, data) => { output.push(event); if (event === "final_answer") expect(data.content).toBe("最终结论"); } });
const result = await streamPromptResponse({ runtime, sessionId: "s", clientSessionId: "c", message: "test", suppressAnswerUi: true, write: (event, data) => { output.push(event); if (event === "final_answer") expect(data.content).toBe("最终结论"); } });
expect(result.failed).toBeFalse();
expect(output.indexOf("tool_call")).toBeLessThan(output.indexOf("final_answer"));
expect(output.at(-1)).toBe("done");
+17 -6
View File
@@ -257,6 +257,17 @@ describe("extractLatestFrontendTurn", () => {
describe("buildForkedSessionUiState", () => {
it("copies truncated source messages and preserves tool artifacts", () => {
const command = {
action: "open_chart",
chart_id: "chart-1",
chart: {
title: "液位曲线",
chart_type: "line",
x_data: ["00:00", "01:00"],
series: [{ name: "P-101", data: [0.42, 0.41] }],
},
evidence_refs: [],
};
const forked = buildForkedSessionUiState(
{
messages: [
@@ -267,9 +278,9 @@ describe("buildForkedSessionUiState", () => {
artifacts: [
{
id: "chart-1",
tool: "show_chart",
kind: "chart",
params: { chart_type: "line" },
tool: "present_view",
kind: "map",
params: { command },
},
],
},
@@ -293,9 +304,9 @@ describe("buildForkedSessionUiState", () => {
artifacts: [
{
id: "chart-1",
tool: "show_chart",
kind: "chart",
params: { chart_type: "line" },
tool: "present_view",
kind: "map",
params: { command },
},
],
},
+22
View File
@@ -547,6 +547,28 @@ describe("streamPromptResponse", () => {
});
});
it("streams native tool identity and sanitized full inputs with progress", async () => {
const address = "完整地址".repeat(70);
const runtime = {
subscribeEvents: async () => createEventStream([
{ type: "message.part.updated", properties: { sessionID: "runtime-session-1", part: {
id: "tool-part", sessionID: "runtime-session-1", messageID: "message-1", type: "tool",
callID: "native-call", tool: "mcp_tjwater_geocode", state: {
status: "completed", input: { address, api_key: "private-value" }, output: "done", time: { start: Date.now(), end: Date.now() }
}
} } },
{ type: "session.idle", properties: { sessionID: "runtime-session-1" } }
]),
prompt: async () => undefined,
messages: async () => []
} as unknown as OpencodeRuntimeAdapter;
const events: Array<{ event: string; data: Record<string, unknown> }> = [];
await streamPromptResponse({ runtime, sessionId: "runtime-session-1", clientSessionId: "client-session-1", message: "定位", write: (event, data) => events.push({ event, data }) });
const progress = events.find((item) => item.event === "progress" && item.data.id === "tool-part");
expect(progress?.data.tool).toEqual({ name: "mcp_tjwater_geocode", call_id: "native-call", input: { address, api_key: "[redacted]" } });
expect(progress?.data.detail).not.toContain("private-value");
});
it("converts question tool parts into question request SSE payloads", async () => {
const runtime = {
subscribeEvents: async () =>
+145
View File
@@ -0,0 +1,145 @@
import { describe, expect, it } from "bun:test";
import { streamPromptResponse } from "../../src/routes/chatStream.js";
import type { OpencodeRuntimeAdapter } from "../../src/runtime/opencode.js";
type Output = { event: string; data: Record<string, unknown> };
const textStart = { type: "message.part.updated", properties: { sessionID: "s", part: { id: "text", type: "text", text: "" } } };
const textDelta = (delta: string) => ({ type: "message.part.delta", properties: { sessionID: "s", partID: "text", field: "text", delta } });
const textEnd = { type: "message.part.updated", properties: { sessionID: "s", part: { id: "text", type: "text", text: "短说明。", time: { start: 1, end: 2 } } } };
const idle = { type: "session.idle", properties: { sessionID: "s" } };
const tool = (id: string, name: string, status = "completed", input: unknown = {}, output = "{}") => ({
type: "message.part.updated", properties: { sessionID: "s", part: {
id, type: "tool", tool: name, callID: id, state: { status, input, output, time: { start: 1, end: status === "completed" ? 2 : undefined } },
} },
});
const final = tool("final", "final_answer", "completed", {}, JSON.stringify({ answer: "核查完成。", ui_block_ids: [] }));
const uiInput = {
block_id: "note", phase: "process", fallback_text: "资料说明。",
spec: { root: "note", elements: { note: { type: "Text", props: { text: "资料说明。", tone: "neutral" }, children: [] } } },
};
const ui = tool("ui", "present_answer", "completed", uiInput);
function createRuntime(events: AsyncIterable<unknown>, extra: Record<string, unknown> = {}) {
return { prompt: async () => {}, messages: async () => [], abortSession: async () => {}, waitForSessionIdle: async () => {},
subscribeEvents: async () => events, ...extra } as unknown as OpencodeRuntimeAdapter;
}
function run(runtime: OpencodeRuntimeAdapter, output: Output[], extra: Partial<Parameters<typeof streamPromptResponse>[0]> = {}) {
return streamPromptResponse({ runtime, sessionId: "s", clientSessionId: "c", message: "核查", write: (event, data) => { output.push({ event, data }); }, ...extra });
}
describe("stream stage boundaries", () => {
it.each(["text-end", "tool", "permission"])("releases narration before waiting at %s", async (boundary) => {
const output: Output[] = [];
const runtime = createRuntime({ async *[Symbol.asyncIterator]() {
yield textStart;
yield textDelta("短说明。");
yield boundary === "text-end" ? textEnd : boundary === "tool" ? tool("query", "mcp_fixture_query", "running") : {
type: "permission.asked", properties: { id: "permission", sessionID: "s", permission: "shell", patterns: ["probe"], always: [] },
};
// The next event is withheld until stage handling has completed.
expect(output.filter((item) => item.event === "token").map((item) => item.data.content).join("")).toBe("短说明。");
yield idle;
} });
await run(runtime, output);
});
it("keeps text before and after an answer component in wire order", async () => {
const output: Output[] = [];
const runtime = createRuntime({ async *[Symbol.asyncIterator]() {
yield textStart; yield textDelta("组件前。"); yield ui;
yield textDelta("组件后。"); yield textEnd; yield idle;
} });
await run(runtime, output);
expect(output.filter((item) => ["token", "answer_ui"].includes(item.event)).map((item) => item.event === "token" ? item.data.content : "UI"))
.toEqual(["组件前。", "UI", "组件后。"]);
});
it("publishes a plain final answer before native cleanup and never consumes later rewrites", async () => {
const output: Output[] = [];
let release!: () => void;
const cleanup = new Promise<void>((resolve) => { release = resolve; });
const runtime = createRuntime({ async *[Symbol.asyncIterator]() {
yield final;
throw new Error("must not start an automatic rewrite after final_answer");
} }, { waitForSessionIdle: () => cleanup });
const running = run(runtime, output);
void running.catch(() => {});
try {
await Bun.sleep(15);
expect(output.filter((item) => item.event === "final_answer").map((item) => item.data.content)).toEqual(["核查完成。"]);
expect(output.some((item) => item.event === "done")).toBe(false);
} finally { release(); }
expect(await running).toMatchObject({ aborted: false, failed: false });
expect(output.filter((item) => item.event === "final_answer")).toHaveLength(1);
});
it("holds final publication while a parallel tool is still running", async () => {
const output: Output[] = [];
const runtime = createRuntime({ async *[Symbol.asyncIterator]() {
yield tool("query", "mcp_fixture_query", "running"); yield final;
expect(output.some((item) => item.event === "final_answer")).toBe(false);
yield tool("query", "mcp_fixture_query");
throw new Error("must close once all tools finish");
} });
await run(runtime, output);
expect(output.filter((item) => item.event === "final_answer")).toHaveLength(1);
});
it("waits for a pending answer component before resolving final references", async () => {
const output: Output[] = [];
const component = tool("ui", "present_answer", "completed", { ...uiInput, phase: "final" });
const answer = tool("final", "final_answer", "completed", {}, JSON.stringify({ answer: "结论\n[[ui:note]]", ui_block_ids: ["note"] }));
const runtime = createRuntime({ async *[Symbol.asyncIterator]() {
yield tool("ui", "present_answer", "running"); yield answer;
expect(output.some((item) => item.event === "final_answer")).toBe(false);
yield component;
throw new Error("must close after the pending component is published");
} });
await run(runtime, output);
expect(output.filter((item) => item.event === "answer_ui")).toHaveLength(1);
expect(output.find((item) => item.event === "final_answer")?.data.ui_block_ids).toEqual(["note"]);
});
it("does not interrupt an older answer when additional user input needs processing", async () => {
const output: Output[] = [];
let interrupted = false;
const runtime = createRuntime({ async *[Symbol.asyncIterator]() {
yield final;
expect(output.some((item) => item.event === "final_answer")).toBe(false);
expect(interrupted).toBe(false);
yield idle;
} }, { abortSession: async () => { interrupted = true; } });
await run(runtime, output, { beforeAnswerClose: async () => false });
expect(interrupted).toBe(false);
expect(output.filter((item) => item.event === "final_answer")).toHaveLength(1);
});
it("drains received text before awaiting cancellation without inventing a final answer", async () => {
const output: Output[] = [];
let receivedAtAbort = "";
const controller = new AbortController();
const runtime = createRuntime({ async *[Symbol.asyncIterator]() {
yield textStart; yield textDelta("已经收到。"); controller.abort(); yield idle;
} }, { abortSession: async () => {
receivedAtAbort = output.filter((item) => item.event === "token").map((item) => item.data.content).join("");
} });
expect(await run(runtime, output, { signal: controller.signal })).toMatchObject({ aborted: true });
expect(receivedAtAbort).toBe("已经收到。");
expect(output.some((item) => item.event === "final_answer" || item.event === "done")).toBe(false);
});
it("interrupts a paced tool transition before publishing the tool event", async () => {
const output: Output[] = [];
const controller = new AbortController();
const text = "管道压力核查。".repeat(15);
const runtime = createRuntime({ async *[Symbol.asyncIterator]() {
yield textStart; yield textDelta(text);
setTimeout(() => controller.abort(), 1);
yield tool("query", "mcp_fixture_query", "running");
throw new Error("must stop reading native events after cancellation");
} });
expect(await run(runtime, output, { signal: controller.signal })).toMatchObject({ aborted: true });
expect(output.filter((item) => item.event === "token").map((item) => item.data.content).join("")).toBe(text);
expect(output.some((item) => item.event === "progress" && item.data.id === "query")).toBe(false);
});
});
+114
View File
@@ -0,0 +1,114 @@
import { describe, expect, it } from "bun:test";
import { createTokenSmoother } from "../../src/routes/chatTokenSmoother.js";
function createOutput(options: { enabled?: boolean; delayMs?: number; maxWaitMs?: number } = {}) {
const chunks: string[] = [];
const smoother = createTokenSmoother({
enabled: true, delayMs: 1, locale: "zh", sessionId: "s", ...options,
write: (_event, data) => { chunks.push(String(data.content)); },
});
return { chunks, smoother };
}
describe("token output lifecycle", () => {
it("releases short Chinese narration during an upstream pause", async () => {
const { chunks, smoother } = createOutput({ maxWaitMs: 25 });
try {
smoother.writeToken("正在核查,请稍候。");
await Bun.sleep(60);
expect(chunks.join("")).toBe("正在核查,请稍候。");
} finally { await smoother.flush(); }
});
it("continues streaming after a stage flush", async () => {
const { chunks, smoother } = createOutput();
smoother.writeToken("发布界面前。");
await smoother.flush();
smoother.writeToken("发布界面后继续解释。");
await smoother.flush();
expect(chunks).toEqual(["发布界面前。", "发布界面后继续解释。"]);
});
it("keeps releasing a burst in bounded chunks beyond the short-tail deadline", async () => {
const { chunks, smoother } = createOutput({ delayMs: 5, maxWaitMs: 25 });
const text = "核查管道压力与流量。".repeat(30);
try {
smoother.writeToken(text);
await Bun.sleep(60);
expect(chunks.length).toBeGreaterThan(1);
expect(chunks.every((chunk) => Array.from(chunk).length <= 18)).toBe(true);
} finally { await smoother.flush(); }
expect(chunks.join("")).toBe(text);
});
it("paces a stage flush instead of dumping the remaining paragraph", async () => {
const chunks: string[] = [];
const times: number[] = [];
const smoother = createTokenSmoother({
enabled: true, delayMs: 10, locale: "zh", sessionId: "s",
write: (_event, data) => { chunks.push(String(data.content)); times.push(performance.now()); },
});
const text = "核查管道压力与流量。".repeat(10);
smoother.writeToken(text);
await smoother.flush();
expect(chunks.length).toBeGreaterThan(2);
expect(chunks.every((chunk) => Array.from(chunk).length <= 23)).toBe(true);
expect(times.at(-1)! - times[0]).toBeGreaterThanOrEqual(30);
expect(chunks.join("")).toBe(text);
});
it("keeps later text behind an in-progress stage boundary", async () => {
const { chunks, smoother } = createOutput({ delayMs: 5 });
const before = "管道压力核查。".repeat(8);
smoother.writeToken(before);
const boundary = smoother.flush();
smoother.writeToken("组件后的解释。");
await boundary;
expect(chunks.join("")).toBe(before);
await smoother.close();
expect(chunks.join("")).toBe(`${before}组件后的解释。`);
});
it("interrupts a paced drain immediately without duplicate late output", async () => {
const { chunks, smoother } = createOutput({ delayMs: 1000 });
const text = "管道压力核查。".repeat(8);
smoother.writeToken(text);
const draining = smoother.flush();
await smoother.close({ immediate: true });
await draining;
await Bun.sleep(30);
expect(chunks).toEqual([text]);
});
it("does not postpone the first release when new Chinese text keeps arriving", async () => {
const { chunks, smoother } = createOutput({ maxWaitMs: 25 });
try {
for (let i = 0; i < 8; i++) {
smoother.writeToken("核查");
await Bun.sleep(10);
}
expect(chunks.join("").length).toBeGreaterThan(0);
} finally { await smoother.flush(); }
expect(chunks.join("")).toBe("核查".repeat(8));
});
it.each([true, false])("closes once and ignores late writes with smoothing=%s", async (enabled) => {
const { chunks, smoother } = createOutput({ enabled });
smoother.writeToken("已经收到的文字。");
await smoother.close();
smoother.writeToken("关闭后不能追加。");
await smoother.close();
await Bun.sleep(35);
expect(chunks.join("")).toBe("已经收到的文字。");
});
it("preserves Unicode and order across repeated flushes without delayed duplicates", async () => {
const { chunks, smoother } = createOutput({ maxWaitMs: 25 });
smoother.writeToken("供水💧");
await smoother.flush();
smoother.writeToken("核查完成。");
await smoother.flush();
await Bun.sleep(60);
expect(chunks.join("")).toBe("供水💧核查完成。");
});
});
+20 -18
View File
@@ -10,46 +10,48 @@ import {
describe("appendBackendToolArtifact", () => {
it("marks SCADA frontend actions as map artifacts", () => {
const artifacts = appendBackendToolArtifact([], {
tool: "render_scada_analysis",
tool: "present_view",
reason: "显示已完成的分级结论",
params: { items: [{ device_id: "MP01", level: "high" }] },
params: { command: { action: "render_scada_analysis", items: [{ device_id: "MP01", level: "high" }] } },
}) as Array<Record<string, unknown>>;
expect(artifacts[0]).toMatchObject({
tool: "render_scada_analysis",
tool: "present_view",
kind: "map",
description: "显示已完成的分级结论",
});
});
it("persists removed visual tool calls as generic tool artifacts", () => {
const artifacts = appendBackendToolArtifact([], {
session_id: "session-1",
tool: "show_chart",
reason: "测试折线图渲染",
params: {
it("persists chart presentation commands as view artifacts", () => {
const command = {
action: "open_chart",
chart_id: "chart-1",
chart: {
title: "液位曲线",
chart_type: "line",
x_data: ["00:00", "01:00"],
series: [{ name: "P-101", data: [0.42, 0.41] }],
},
evidence_refs: [],
};
const artifacts = appendBackendToolArtifact([], {
session_id: "session-1",
tool: "present_view",
reason: "测试折线图渲染",
params: { command },
}) as Array<Record<string, unknown>>;
expect(artifacts).toHaveLength(1);
expect(artifacts[0]).toMatchObject({
tool: "show_chart",
kind: "tool",
title: "液位曲线",
tool: "present_view",
kind: "map",
title: "present_view",
description: "测试折线图渲染",
params: {
chart_type: "line",
x_data: ["00:00", "01:00"],
series: [{ name: "P-101", data: [0.42, 0.41] }],
},
params: { command },
});
expect(artifacts[0]).toEqual(
expect.objectContaining({
id: expect.stringMatching(/^show_chart-/),
id: expect.stringMatching(/^present_view-/),
}),
);
});
+85 -3
View File
@@ -12,6 +12,8 @@ import { SessionWorkspaceStore } from "../../src/sessions/workspace-store.js";
import { removeRuntimeSessionContext } from "../../src/runtime/sessionContext.js";
import { toActorKey, toProjectKey } from "../../src/utils/fileStore.js";
import type { ResponseInput } from "../../src/chat/response-inputs.js";
import { InteractionRequestStore } from "../../src/sessions/interaction-request-store.js";
import { INTERACTION_VERSION } from "../../src/chat/interaction-request-contract.js";
const http = globalThis.fetch;
let root: string;
@@ -22,12 +24,19 @@ let prompted: string[];
let pendingInputs: ResponseInput[];
let aborts: number;
let gate: Promise<void> | undefined;
let runtimeEvents: AsyncIterable<unknown> | undefined;
let release: () => void = () => {};
let bridge: ChatSessionBridge;
let states: SessionUiStateStore;
let interactions: InteractionRequestStore;
let nativeState: "missing" | "enqueued" | "delivered";
let completedResult: string | undefined;
let enqueueResumes: boolean[];
const project = "10000000-0000-4000-8000-000000000001";
beforeEach(async () => {
fail = false; prompted = []; pendingInputs = []; aborts = 0; gate = undefined;
runtimeEvents = undefined;
nativeState = "missing"; completedResult = undefined; enqueueResumes = [];
root = await mkdtemp(join(tmpdir(), "tjwater-responses-"));
globalThis.fetch = (async (_input, init) => Response.json({
user_id: new Headers(init?.headers).get("authorization") === "Bearer owner" ? "owner" : "other",
@@ -36,6 +45,7 @@ beforeEach(async () => {
const workspaces = new SessionWorkspaceStore(join(root, "workspaces"));
const sessions = new SessionMetadataStore(join(root, "metadata"), workspaces);
states = new SessionUiStateStore(join(root, "ui"), workspaces);
interactions = new InteractionRequestStore(workspaces);
await sessions.initialize();
const context = { actorKey: toActorKey("product:owner"), projectKey: toProjectKey(`product:${project}`), projectId: project, userId: "owner", sessionId: "owned" };
await sessions.ensure(context);
@@ -51,14 +61,16 @@ beforeEach(async () => {
pendingInputs = pendingInputs.filter((input) => input.id !== id);
return { ...item, status: "cancelled" };
},
enqueueUserInput: async (_sessionId: string, input: ResponseInput) => { pendingInputs.push({ ...input }); },
enqueueUserInput: async (_sessionId: string, input: ResponseInput, options?: { resume?: boolean }) => { pendingInputs.push({ ...input }); enqueueResumes.push(options?.resume ?? true); },
agentMessageState: async () => nativeState,
readInteractionResult: async () => { if (completedResult === undefined) throw new Error("No completed result"); return completedResult; },
updatePendingUserInput: async (_sessionId: string, id: string, delivery: "steer" | "queue") => {
const item = pendingInputs.find((input) => input.id === id);
if (!item) throw Object.assign(new Error("Consumed"), { status: 409 });
item.delivery = delivery;
return { ...item };
},
subscribeEvents: async () => ({ async *[Symbol.asyncIterator]() {
subscribeEvents: async () => runtimeEvents ?? ({ async *[Symbol.asyncIterator]() {
if (gate) await gate;
yield { type: "message.part.updated", properties: { sessionID: "owned", part: { id: "answer", type: "text", text: "", sessionID: "owned", messageID: "native-answer" } } };
yield { type: "message.part.delta", properties: { sessionID: "owned", partID: "answer", field: "text", delta: "压力待核验" } };
@@ -71,7 +83,7 @@ beforeEach(async () => {
const app = express(); app.use(express.json());
bridge = new ChatSessionBridge(runtime);
app.use("/api/v1", buildChatRouter({
runtime, sessionBridge: bridge, sessionMetadataStore: sessions, sessionUiStateStore: states,
runtime, sessionBridge: bridge, sessionMetadataStore: sessions, sessionUiStateStore: states, interactionRequestStore: interactions,
sessionTranscriptStore: { getRecentTurns: async () => [] } as never,
memoryStore: { buildPromptSnapshot: async () => "" } as never,
learningOrchestrator: { onTurnCompleted: async () => {} } as never,
@@ -95,6 +107,43 @@ const call = (body: unknown, token = "owner", path = "/api/v1/responses") => htt
method: "POST", headers: { Authorization: `Bearer ${token}`, "X-Project-Id": project, "Content-Type": "application/json" }, body: JSON.stringify(body), signal: AbortSignal.timeout(5000),
});
it("admits steer and queue while a host question is waiting without starting unowned inference", async () => {
const context = { actorKey: toActorKey("product:owner"), projectKey: toProjectKey(`product:${project}`), sessionId: "owned" };
const question = await interactions.create(context, { message_id: "question", turn_id: "turn", call_id: "ask", questions: [{ question_id: "scope", title: "范围", body: "核查哪里?", options: [], mode: "single", required: true, allow_custom: true }] });
for (const [i, delivery] of ["steer", "queue"].entries()) {
const result = await call({ input: `补充 ${i}`, conversation: "owned", tjwater: { delivery, input_id: `msg_${String(i).repeat(32)}` } });
expect(result.status).toBe(202);
expect(await result.json()).toMatchObject({ delivery, status: "pending" });
}
expect(pendingInputs).toHaveLength(2);
expect(enqueueResumes).toEqual([false, false]);
expect(prompted).toEqual([]);
expect((await interactions.get(context, question.interaction_id))?.status).toBe("pending");
expect((await call({ input: "foreign", conversation: "owned", tjwater: { delivery: "queue", input_id: `msg_${"f".repeat(32)}` } }, "other")).status).toBe(404);
expect(pendingInputs).toHaveLength(2);
});
for (const recovery of ["list", "retry"] as const) it(`reconciles a blocked acknowledgement through ${recovery} before admitting the next message`, async () => {
const context = { actorKey: toActorKey("product:owner"), projectKey: toProjectKey(`product:${project}`), sessionId: "owned" };
const question = await interactions.create(context, { message_id: "question", turn_id: "turn", call_id: "ask", questions: [{ question_id: "scope", title: "范围", body: "核查哪里?", options: [], mode: "single", required: true, allow_custom: true }] });
await interactions.reply(context, question.interaction_id, { version: INTERACTION_VERSION, request_id: "reply", expected_revision: 0, answers: [{ question_id: "scope", selected_option_ids: [], custom_text: "当前片区" }] });
await interactions.updateDelivery(context, question.interaction_id, { expected_revision: 0, status: "blocked", error_code: "CONTINUATION_UNCONFIRMED" });
nativeState = "delivered"; completedResult = "已完成核查。";
if (recovery === "retry") {
const replay = await call({ input: "重试继续", conversation: "owned", tjwater: { interaction_id: question.interaction_id } });
expect(replay.status).toBe(200);
expect(JSON.stringify(await replay.json())).toContain("已完成核查。");
}
const list = await http(`${base}/api/v1/session/owned/interactions`, { headers: { Authorization: "Bearer owner", "X-Project-Id": project } });
expect((await list.json()).items[0].delivery.status).toBe("delivered");
expect(await interactions.getBlocking(context)).toBeNull();
expect(prompted).toEqual([]);
const response = await call({ input: "继续检查压力", conversation: "owned" });
expect(response.status).toBe(200);
expect((await response.json()).status).toBe("completed");
expect(prompted).toHaveLength(1);
});
it("serves Responses SSE through the real HTTP route and preserves the owned conversation", async () => {
const response = await call({ input: [{ role: "user", content: [{ type: "input_text", text: "核查压力" }] }], conversation: "owned", stream: true });
const text = await response.text();
@@ -108,6 +157,39 @@ it("serves Responses SSE through the real HTTP route and preserves the owned con
expect(prompted).toHaveLength(1);
expect(prompted[0]).toContain("核查压力");
});
it("delivers narration over HTTP while a tool is still waiting", async () => {
gate = new Promise<void>((resolve) => { release = resolve; });
runtimeEvents = { async *[Symbol.asyncIterator]() {
yield { type: "message.part.updated", properties: { sessionID: "owned", part: { id: "text", type: "text", text: "" } } };
yield { type: "message.part.delta", properties: { sessionID: "owned", partID: "text", field: "text", delta: "正在核查。" } };
yield { type: "message.part.updated", properties: { sessionID: "owned", part: {
id: "query", type: "tool", tool: "mcp_fixture_query", callID: "query", state: { status: "running", input: {}, time: { start: 1 } },
} } };
await gate;
yield { type: "session.idle", properties: { sessionID: "owned" } };
} };
const response = await call({ input: "核查压力", conversation: "owned", stream: true });
const reader = response.body!.getReader();
const decoder = new TextDecoder();
let received = "";
try {
while (!received.includes("event: response.output_text.delta")) {
const next = await reader.read();
if (next.done) break;
received += decoder.decode(next.value, { stream: true });
}
expect(received).toContain('"delta":"正在核查。"');
expect(received).not.toContain("event: response.completed");
} finally { release(); }
while (true) {
const next = await reader.read();
if (next.done) break;
received += decoder.decode(next.value, { stream: true });
}
expect(received).toContain("event: response.completed");
});
it("returns a Response object when streaming is omitted", async () => {
const response = await call({ input: "核查压力", conversation: { id: "owned" } });
expect(await response.json()).toMatchObject({ object: "response", status: "completed", conversation: { id: "owned" }, output: [{ type: "message", content: [{ type: "output_text", text: "压力待核验" }] }] });
@@ -0,0 +1,76 @@
import { afterEach, expect, it } from "bun:test";
import express from "express";
import type { Server } from "node:http";
import { config } from "../../src/config.js";
import { buildChatRouter } from "../../src/routes/chat.js";
import { buildTaskExecutionPrompt } from "../../src/routes/chatSessionRoutes.js";
const http = globalThis.fetch;
let server: Server | undefined;
it("tells background investigations to read the current Server Skill explicitly", () => {
const prompt = buildTaskExecutionPrompt("burst_report");
expect(prompt).toContain("mcp_tjwater_get_task_context");
expect(prompt).toContain('section="skill"');
expect(prompt).toContain("skill.version");
expect(prompt).toContain("不沿用记忆中的旧版本");
});
afterEach(async () => {
globalThis.fetch = http;
if (server) {
server.closeAllConnections();
await new Promise<void>((resolve) => server!.close(() => resolve()));
server = undefined;
}
});
it("rejects a durable task execution addressed to another Agent instance", async () => {
const projectId = "10000000-0000-4000-8000-000000000001";
globalThis.fetch = (async (_input, init) => {
const headers = new Headers(init?.headers);
return Response.json({
user_id: "worker",
project_id: headers.get("x-project-id"),
network: "supply",
});
}) as typeof fetch;
const app = express();
app.use(express.json());
app.use("/api/v1", buildChatRouter({
frontendActionCoordinator: {} as never,
learningOrchestrator: {} as never,
memoryStore: {} as never,
runtime: {} as never,
sessionBridge: {} as never,
sessionMetadataStore: {} as never,
sessionTranscriptStore: {} as never,
sessionUiStateStore: {} as never,
}));
server = app.listen(0, "127.0.0.1");
await new Promise<void>((resolve) => server!.once("listening", resolve));
const address = server.address();
if (!address || typeof address === "string") throw new Error("missing server address");
const response = await http(`http://127.0.0.1:${address.port}/api/v1/task-executions`, {
method: "POST",
headers: {
Authorization: "Bearer worker",
"Content-Type": "application/json",
"X-Project-Id": projectId,
},
body: JSON.stringify({
task_id: "50000000-0000-4000-8000-000000000001",
run_id: "55000000-0000-4000-8000-000000000001",
execution_request_id: "60000000-0000-4000-8000-000000000001",
agent_instance_id: `${config.AGENT_INSTANCE_ID}-foreign`,
}),
});
expect(response.status).toBe(409);
expect(await response.json()).toEqual({
message: "task execution targeted a different Agent instance",
});
});
+40
View File
@@ -0,0 +1,40 @@
import { expect, test } from "bun:test";
import express from "express";
import { registerTaskQuestionBridge } from "../../src/routes/task-question-bridge.js";
import { removeRuntimeSessionContext, setRuntimeSessionContext } from "../../src/runtime/sessionContext.js";
test("task questions bind identity on the host and reject chat, readers, and forged targets", async () => {
const calls: unknown[][] = [];
const taskId = crypto.randomUUID();
for (const purpose of ["task_execution", "task_collaboration", "ordinary_chat"] as const) {
setRuntimeSessionContext({ sessionId: purpose, clientSessionId: `client-${purpose}`, actorKey: "actor",
projectKey: "project", traceId: "test", taskId, purpose });
}
const app = express(); app.use(express.json());
registerTaskQuestionBridge(app, { internalToken: "test-token", client: {
askTaskUser: async (...args) => { calls.push(args); return { question_request_id: crypto.randomUUID(), status: "pending", version: 1 }; }
} });
const server = app.listen(0, "127.0.0.1");
await new Promise<void>((resolve) => server.once("listening", resolve));
const body = { session_id: "task_execution", request_id: crypto.randomUUID(), questions: [{
question_id: "flow", title: "流量依据", body: "是否有流量记录?", options: [], mode: "single", allow_custom: true, required: true
}] };
const post = (input = body, token = "test-token") => fetch(`http://127.0.0.1:${(server.address() as { port: number }).port}/internal/operational-task/questions`, {
method: "POST", headers: { "content-type": "application/json", "x-agent-internal-token": token }, body: JSON.stringify(input)
});
try {
expect((await post(body, "wrong")).status).toBe(403);
for (const session_id of ["task_collaboration", "ordinary_chat", "unregistered-reader"]) {
expect((await post({ ...body, session_id })).status).toBe(403);
}
expect((await post({ ...body, task_id: "forged" } as typeof body)).status).toBe(400);
expect((await post({ ...body, questions: [body.questions[0], body.questions[0]] })).status).toBe(400);
expect(calls).toHaveLength(0);
expect((await post()).status).toBe(200);
expect(calls[0]).toEqual(["task_execution", taskId, { request_id: body.request_id,
questions: body.questions, sourceSessionId: "client-task_execution" }]);
} finally {
["task_execution", "task_collaboration", "ordinary_chat"].forEach(removeRuntimeSessionContext);
await new Promise<void>((resolve) => server.close(() => resolve()));
}
});
+38
View File
@@ -0,0 +1,38 @@
import { describe, expect, it } from "bun:test";
import { buildToolProgressDetail, extractRequestReason, sanitizeToolProgressInput } from "../../src/routes/chatStreamEvents.js";
import { upsertBackendProgress } from "../../src/routes/chatUiState.js";
describe("browser tool progress metadata", () => {
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("当前说明");
});
it("preserves useful full inputs while redacting credentials", () => {
const address = "完整地址".repeat(70);
const input = sanitizeToolProgressInput({ address, point: { longitude: 121.5, latitude: 31.2 }, authorization: "private", nested: { api_key: "private", text: "Bearer abcdefghijklmnop12345" } });
expect(input).toEqual({ address, point: { longitude: 121.5, latitude: 31.2 }, authorization: "[redacted]", nested: { api_key: "[redacted]", text: "[redacted]" } });
const first = upsertBackendProgress([], { id: "call", phase: "tool", tool: { name: "mcp_tjwater_geocode", call_id: "native-call", input } });
const next = upsertBackendProgress(first, { id: "call", status: "completed" });
expect(next[0].tool).toEqual({ name: "mcp_tjwater_geocode", callId: "native-call", input });
});
it("keeps secrets out of the compatibility detail summary", () => {
const detail = buildToolProgressDetail("query", "error", { api_key: "private-value", nested: { password: "nested-value" } }, "Bearer abcdefghijklmnop12345", "api_key=error-secret");
expect(detail).not.toContain("private-value");
expect(detail).not.toContain("nested-value");
expect(detail).not.toContain("abcdefghijklmnop12345");
expect(detail).not.toContain("error-secret");
});
it("bounds oversized inputs", () => {
const input = sanitizeToolProgressInput({ text: "a".repeat(20_000), items: Array.from({ length: 100 }, () => "b".repeat(4_000)) });
expect(JSON.stringify(input).length).toBeLessThan(18_000);
expect(JSON.stringify(input)).toContain("[truncated]");
});
});
+6 -6
View File
@@ -163,7 +163,7 @@ describe("OpencodeRuntimeAdapter.generateText", () => {
}) as OpencodeRuntimeAdapter;
try {
expect(await runtime.generateText("recommend questions", { providerID: "test", modelID: "fast" }, controller.signal)).toBe("generated suggestions");
expect(calls[0]).toMatchObject({ model: { providerID: "test", id: "fast" } });
expect(calls[0]).toMatchObject({ agent: "prompt-suggestions", model: { providerID: "test", id: "fast" }, permissions: [{ action: "*", resource: "*", effect: "deny" }] });
expect(calls[1]).toBe("catalog ready");
expect(calls[2]).toEqual({ input: { sessionID: "temporary-generator", prompt: "recommend questions" }, signal: controller.signal });
expect(calls[3]).toEqual({ sessionID: "temporary-generator" });
@@ -225,7 +225,7 @@ describe("resolveEmbeddedOpencodePort", () => {
});
describe("OpencodeRuntimeAdapter.warmup", () => {
const toolIds = [...PRODUCT_MCP_TOOL_IDS, ...LOCAL_QUERY_TOOL_IDS, "websearch", "shell", "final_answer", "present_answer", "locate_features", "zoom_to_map", "show_chart", "view_history", "memory_manager", "skill_manager", "render_scada_analysis", "clear_scada_analysis", "publish_execution_plan", "publish_task_report", "publish_task_progress", "publish_task_presentation", "propose_task_action", "add_task_human_evidence", "request_task_data_access", "todowrite", "send_task_message", "reply_agent_message"];
const toolIds = [...PRODUCT_MCP_TOOL_IDS, ...LOCAL_QUERY_TOOL_IDS, "websearch", "shell", "ask_user", "ask_task_user", "final_answer", "present_answer", "memory_manager", "skill_manager", "present_view", "publish_execution_plan", "publish_task_report", "publish_task_progress", "publish_task_presentation", "propose_task_action", "add_task_human_evidence", "request_task_data_access", "todowrite", "send_task_message", "reply_agent_message"];
async function createWarmupRuntime(missing: string[] = [], invalidModel?: string, delayedMcp = false,
search: { providers?: readonly string[]; selection?: false | { provider: string }; unavailable?: boolean; extraTools?: readonly string[] } = {}) {
const root = await mkdtemp(join(tmpdir(), "warmup-workspace-test-"));
@@ -300,8 +300,8 @@ describe("OpencodeRuntimeAdapter.warmup", () => {
} finally { await cleanup(); }
});
it("fails readiness if a business tool was lost and still cleans up", async () => {
const { runtime, calls, cleanup } = await createWarmupRuntime(["render_scada_analysis"]);
try { await expect(runtime.warmup()).rejects.toThrow("business tools missing: render_scada_analysis");
const { runtime, calls, cleanup } = await createWarmupRuntime(["present_view"]);
try { await expect(runtime.warmup()).rejects.toThrow("business tools missing: present_view");
expect(calls.at(-1)).toBe("session.remove"); } finally { await cleanup(); }
});
it("waits for native MCP catalog publication before reporting ready", async () => {
@@ -313,9 +313,9 @@ describe("OpencodeRuntimeAdapter.warmup", () => {
} finally { await cleanup(); }
});
it("rejects an invalid non-default model before readiness and cleans up the session", async () => {
const { runtime, calls, cleanup } = await createWarmupRuntime([], "waternetwork/gpt-6.1-sol");
const { runtime, calls, cleanup } = await createWarmupRuntime([], "waternetwork/gpt-5.6-sol");
try {
await expect(runtime.warmup()).rejects.toThrow("waternetwork/gpt-6.1-sol: not_advertised");
await expect(runtime.warmup()).rejects.toThrow("waternetwork/gpt-5.6-sol: not_advertised");
expect(calls).not.toContain("model.list");
expect(calls.at(-1)).toBe("session.remove");
} finally { await cleanup(); }
+15
View File
@@ -1,7 +1,17 @@
import { describe, expect, it } from "bun:test";
import { readFile } from "node:fs/promises";
import { serviceOpencodeConfig } from "../../src/runtime/opencode2-config.js";
import { TASK_READER_AGENT } from "../../src/runtime/task-agent-policy.js";
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"));
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("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 }[];
@@ -10,6 +20,7 @@ describe("OpenCode 2 permissions", () => {
expect(effect("external_directory", "*")).toBe("deny");
expect(effect("subagent", "*")).toBe("deny");
expect(effect("question", "*")).toBe("allow");
expect(effect("ask_user", "*")).toBe("allow");
expect(effect("todowrite", "*")).toBe("allow");
for (const action of ["read", "edit"]) {
for (const path of [".env", "*.env", "data/**", "**/logs/**"]) expect(effect(action, path)).toBe("deny");
@@ -20,4 +31,8 @@ describe("OpenCode 2 permissions", () => {
expect(config.permission).toBeUndefined();
expect(config.experimental?.continue_loop_on_deny).toBeUndefined();
});
it("lets each parent subagent call provide the reader prompt", () => {
const reader = serviceOpencodeConfig({}).agents[TASK_READER_AGENT];
expect(reader).not.toHaveProperty("system");
});
});
+2 -2
View File
@@ -21,8 +21,8 @@ describe("task agent isolation", () => {
expect(config.agents[TASK_READER_AGENT].permissions.map((rule) => rule.action)).toEqual([
"*", ...PRODUCT_MCP_READ_TOOL_IDS,
]);
expect(PRODUCT_MCP_READ_TOOL_IDS).toHaveLength(42);
expect(PRODUCT_MCP_TOOL_IDS).toHaveLength(67);
expect(PRODUCT_MCP_READ_TOOL_IDS).toHaveLength(45);
expect(PRODUCT_MCP_TOOL_IDS).toHaveLength(71);
expect(PRODUCT_MCP_TOOL_IDS.some((name) => /publish|approve|execute|shell/.test(name))).toBeFalse();
});
+234
View File
@@ -0,0 +1,234 @@
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
import { lstat, mkdtemp, readFile, rm, symlink, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { AnswerViewStateError, AnswerViewStateStore, canonicalAnswerJson, type AnswerViewStatePut } from "../../src/sessions/answer-view-state-store.js";
import { SessionWorkspaceStore } from "../../src/sessions/workspace-store.js";
import { SessionUiStateStore } from "../../src/sessions/uiStateStore.js";
const owner = { actorKey: "actor-a", projectKey: "project-a", sessionId: "session" };
const identity = { message_id: "assistant", block_id: "block" };
const chartProps = { title: "Chart", chart_type: "line", x_data: ["a", "b", "c"], series: [{ name: "pressure", data: [1, 2, 3] }, { name: "flow", data: [4, 5, 6] }], source: "source" };
const chart = { type: "Chart", props: chartProps, children: [] };
const table = { type: "Table", props: { title: "Table", columns: ["name", "value"], rows: [["a", "1"], ["b", "2"]] }, children: [] };
const form = { type: "FollowUpForm", props: { title: "Form", description: "description", fields: [{ name: "note", label: "Note", kind: "text", required: false }, { name: "count", label: "Count", kind: "number", required: false }] }, children: [] };
const evidence = { type: "Evidence", props: { title: "Evidence", summary: "Summary", source: "source", evidence_id: null, observed_at: null, kind: "observed" }, children: [] };
const spec = () => ({ root: "root", elements: { root: { type: "Stack", props: { columns: "one", gap: "normal" }, children: ["chart", "table", "form", "evidence"] }, chart: structuredClone(chart), table: structuredClone(table), form: structuredClone(form), evidence: structuredClone(evidence) } });
describe("answer view state", () => {
let base: string;
let workspaces: SessionWorkspaceStore;
let messages: SessionUiStateStore;
let states: AnswerViewStateStore;
let currentSpec: ReturnType<typeof spec>;
const saveMessages = async (role = "assistant", parts?: unknown[]) => messages.write(owner, { sessionId: owner.sessionId, messages: [{ id: identity.message_id, role, parts: parts ?? [{ type: "data-answer_ui", data: { version: "answer-ui@1", block_id: identity.block_id, fallback_text: "Fallback", spec: currentSpec } }] }] });
const input = async (patch: Partial<AnswerViewStatePut> = {}): Promise<AnswerViewStatePut> => ({ ...identity, schema_digest: (await states.get(owner, identity)).schema_digest, expected_revision: 0, request_id: "request-1", nodes: {}, block: { evidenceFilter: "all" }, ...patch });
beforeEach(async () => {
base = await mkdtemp(join(tmpdir(), "answer-state-"));
workspaces = new SessionWorkspaceStore(join(base, "workspaces"));
messages = new SessionUiStateStore(join(base, "unused"), workspaces);
states = new AnswerViewStateStore(workspaces);
currentSpec = spec();
await saveMessages();
});
afterEach(async () => { await rm(base, { recursive: true, force: true }); });
it("returns revision zero without writing and persists a private independent record", async () => {
const first = await states.get(owner, identity);
expect(first).toMatchObject({ version: "answer-view-state@1", ...identity, revision: 0, nodes: {}, block: { evidenceFilter: "all" }, updated_at: null });
await expect(lstat(workspaces.path(owner, "answer-view-state.json"))).rejects.toThrow();
const payload = await input({ nodes: { form: { type: "FollowUpForm", values: { note: "draft" } } } });
const saved = await states.put(owner, payload);
expect(saved.revision).toBe(1);
expect(await new AnswerViewStateStore(workspaces).get(owner, identity)).toEqual(saved);
expect((await messages.read(owner))?.messages).not.toContain("draft");
expect((await lstat(workspaces.path(owner, "answer-view-state.json"))).mode & 0o777).toBe(0o600);
const branch = { ...owner, sessionId: "branch" };
await messages.write(branch, { sessionId: branch.sessionId, messages: (await messages.read(owner))!.messages });
expect((await states.get(branch, identity)).nodes).toEqual({});
expect(canonicalAnswerJson({ b: 1, a: { y: 3, x: 2 } })).toBe(canonicalAnswerJson({ a: { x: 2, y: 3 }, b: 1 }));
});
it("serializes concurrent CAS writes and preserves every idempotency response", async () => {
const payload = await input();
const outcomes = await Promise.allSettled([states.put(owner, payload), states.put(owner, { ...payload, request_id: "other" })]);
expect(outcomes.filter((result) => result.status === "fulfilled")).toHaveLength(1);
const rejected = outcomes.find((result) => result.status === "rejected") as PromiseRejectedResult;
expect(rejected.reason).toMatchObject({ status: 409, code: "ANSWER_VIEW_STATE_CONFLICT", current: { revision: 1 } });
const original = await states.put(owner, payload);
await states.put(owner, { ...payload, request_id: "second", expected_revision: 1 });
expect(await states.put(owner, payload)).toEqual(original);
await expect(states.put(owner, { ...payload, nodes: { evidence: { type: "Evidence", open: true } } })).rejects.toMatchObject({ status: 409, code: "ANSWER_VIEW_REQUEST_CONFLICT" });
});
it("uses saved assistant blocks and final publication rather than client component declarations", async () => {
await expect(states.get(owner, { ...identity, message_id: "missing" })).rejects.toMatchObject({ status: 404 });
await saveMessages("user");
await expect(states.get(owner, identity)).rejects.toMatchObject({ status: 404 });
await saveMessages();
await expect(states.put(owner, await input({ nodes: { table: { type: "Evidence", open: true } } }))).rejects.toMatchObject({ status: 400 });
const parts: Array<{ type: string; data: Record<string, unknown> }> = [{ type: "data-answer_ui", data: { version: "answer-ui@1", block_id: "block", fallback_text: "fallback", spec: currentSpec } }, { type: "data-final_answer", data: { content: "final", ui_block_ids: [] } }];
await saveMessages("assistant", parts);
await expect(states.get(owner, identity)).rejects.toMatchObject({ status: 404 });
parts[1].data.ui_block_ids = ["block"];
await saveMessages("assistant", parts);
expect((await states.get(owner, identity)).revision).toBe(0);
currentSpec.elements.chart.props.series[0].data = [];
await saveMessages();
await expect(states.get(owner, identity)).rejects.toMatchObject({ status: 404 });
});
it("rejects explicit process blocks even when final refs list them and retains legacy/final access", async () => {
const payload = await input();
const parts = (phase?: string) => [{ type: "data-answer_ui", data: { version: "answer-ui@1", block_id: "block", phase, fallback_text: "fallback", spec: currentSpec } }, { type: "data-final_answer", data: { content: "final", ui_block_ids: ["block"] } }];
// Save a receipt first: retries must recheck current block visibility too.
await states.put(owner, payload);
await saveMessages("assistant", parts("process"));
await expect(states.get(owner, identity)).rejects.toMatchObject({ status: 404, code: "ANSWER_VIEW_BLOCK_NOT_FOUND" });
await expect(states.put(owner, payload)).rejects.toMatchObject({ status: 404, code: "ANSWER_VIEW_BLOCK_NOT_FOUND" });
await saveMessages("assistant", parts("final"));
expect((await states.get(owner, identity)).revision).toBe(1);
await saveMessages("assistant", parts());
expect((await states.get(owner, identity)).revision).toBe(1);
});
it("enforces exact fields, real catalog bounds and 500-character form drafts", async () => {
const valid = await input();
for (const nodes of [
{ chart: { type: "Chart", view: "line", hidden: [0, 1], range: [0, 2], point: 0, inspecting: false } },
{ chart: { type: "Chart", view: "line", hidden: [], range: [2, 0], point: 0, inspecting: false } },
{ table: { type: "Table", query: "", sort: null, selection: [2] } },
{ table: { type: "Table", query: "", sort: { column: 2, descending: false }, selection: [] } },
{ form: { type: "FollowUpForm", values: { note: "a".repeat(501) } } },
{ form: { type: "FollowUpForm", values: { invented: "value" } } },
{ evidence: { type: "Evidence", open: true, arbitrary: "field" } },
{ root: { type: "Evidence", open: true } },
]) await expect(states.put(owner, { ...valid, nodes })).rejects.toMatchObject({ status: 400 });
await expect(states.put(owner, undefined)).rejects.toMatchObject({ status: 400 });
await expect(states.put(owner, { ...valid, arbitrary: true })).rejects.toMatchObject({ status: 400 });
await expect(states.put(owner, { ...valid, nodes: { form: { type: "FollowUpForm", values: { note: "a".repeat(40000) } } } })).rejects.toMatchObject({ status: 413 });
expect((await states.put(owner, { ...valid, nodes: { form: { type: "FollowUpForm", values: { note: "a".repeat(500) } } } })).revision).toBe(1);
});
it("applies the block size limit to UTF-8 bytes of otherwise valid controls", async () => {
const extended = currentSpec.elements as Record<string, unknown>;
const fields = Array.from({ length: 12 }, (_, index) => ({ name: `field_${index}`, label: `Field ${index}`, kind: "text", required: false }));
const nodes: AnswerViewStatePut["nodes"] = {};
for (const name of ["form", "form_two"]) {
extended[name] = { type: "FollowUpForm", props: { ...form.props, fields }, children: [] };
if (name !== "form") currentSpec.elements.root.children.push(name);
nodes[name] = { type: "FollowUpForm", values: Object.fromEntries(fields.map((field) => [field.name, "水".repeat(500)])) };
}
await saveMessages();
const payload = await input({ nodes });
expect(JSON.stringify(payload).length).toBeLessThan(32768);
expect(Buffer.byteLength(JSON.stringify(payload))).toBeGreaterThan(32768);
await expect(states.put(owner, payload)).rejects.toMatchObject({ status: 413 });
expect((await states.get(owner, identity)).revision).toBe(0);
});
it("migrates current spec by stable labels and field types, without copying readings", async () => {
const saved = await states.put(owner, await input({ block: { evidenceFilter: "observed" }, nodes: {
chart: { type: "Chart", view: "table", hidden: [1], range: [1, 2], point: 2, inspecting: true },
table: { type: "Table", query: "search", sort: { column: 1, descending: true }, selection: [1] },
form: { type: "FollowUpForm", values: { note: "draft", count: "42" } }, evidence: { type: "Evidence", open: true },
} }));
const oldBytes = await readFile(workspaces.path(owner, "answer-view-state.json"), "utf8");
expect(oldBytes).not.toContain('"data"');
currentSpec.elements.chart.props.x_data = ["new", "a", "b", "c"];
currentSpec.elements.chart.props.series = [{ name: "flow", data: [11, 12, 13, 14] }, { name: "pressure", data: [15, 16, 17, 18] }];
currentSpec.elements.table.props.rows[1][1] = "changed";
currentSpec.elements.form.props.fields[1].kind = "text";
currentSpec.elements.evidence.props.kind = "inference";
await saveMessages();
const restored = await states.get(owner, identity);
expect(restored.revision).toBe(1);
expect(restored.schema_digest).not.toBe(saved.schema_digest);
expect(restored.nodes.chart).toEqual({ type: "Chart", view: "table", hidden: [], range: [2, 3], point: 3, inspecting: true });
expect(restored.nodes.table).toMatchObject({ query: "search", sort: { column: 1, descending: true }, selection: [] });
expect(restored.nodes.form).toEqual({ type: "FollowUpForm", values: { note: "draft" } });
expect(restored.block.evidenceFilter).toBe("all");
expect(await readFile(workspaces.path(owner, "answer-view-state.json"), "utf8")).toBe(oldBytes);
await expect(states.put(owner, await input({ schema_digest: saved.schema_digest, expected_revision: 1, request_id: "stale" }))).rejects.toMatchObject({ status: 409, code: "ANSWER_VIEW_SCHEMA_CHANGED", current: restored });
currentSpec.elements.chart.props.x_data = ["a", "b", "b", "c"];
currentSpec.elements.table.props.columns.reverse();
currentSpec.elements.form = { type: "Text", props: { text: "new", tone: "neutral" }, children: [] } as unknown as typeof form;
await saveMessages();
const changed = await states.get(owner, identity);
expect(changed.nodes.chart).toMatchObject({ range: [0, 3], inspecting: false });
expect(changed.nodes.table).toMatchObject({ sort: null, selection: [] });
expect(changed.nodes.form).toBeUndefined();
});
it("bounds retry receipts so continuous saving does not exhaust the session", async () => {
const payload = await input({ nodes: { form: { type: "FollowUpForm", values: { note: "水".repeat(500) } } } });
for (let revision = 0; revision < 100; revision++) await states.put(owner, { ...payload, expected_revision: revision, request_id: `save-${revision}` });
const file = JSON.parse(await readFile(workspaces.path(owner, "answer-view-state.json"), "utf8"));
expect(Object.keys(file.requests)).toHaveLength(32);
expect(Buffer.byteLength(JSON.stringify(file.requests))).toBeLessThanOrEqual(131072);
expect(file.entries[JSON.stringify([identity.message_id, identity.block_id])].state.revision).toBe(100);
await expect(states.put(owner, { ...payload, request_id: "save-0" })).rejects.toMatchObject({ status: 409, code: "ANSWER_VIEW_STATE_CONFLICT" });
// Existing state entries cannot be evicted to make room for a new block.
file.padding = "x".repeat(1048576);
await writeFile(workspaces.path(owner, "answer-view-state.json"), JSON.stringify(file));
await expect(states.put(owner, { ...payload, expected_revision: 100, request_id: "over-limit" })).rejects.toMatchObject({ status: 413 });
expect((await states.get(owner, identity)).revision).toBe(100);
});
it("validates nested chart tables and clears their selections when readings or derived columns change", async () => {
const payload = await input({ nodes: { chart: { type: "Chart", view: "table", hidden: [], range: [0, 2], point: 1, inspecting: true, table: { query: "search", sort: { column: 2, descending: false }, selection: [1] } } } });
await states.put(owner, payload);
currentSpec.elements.chart.props.series[0].data[0] = 99;
await saveMessages();
expect((await states.get(owner, identity)).nodes.chart).toMatchObject({ table: { query: "search", sort: { column: 2, descending: false }, selection: [] } });
currentSpec.elements.chart.props.series.reverse();
await saveMessages();
expect((await states.get(owner, identity)).nodes.chart).toMatchObject({ table: { sort: null, selection: [] } });
await expect(states.put(owner, await input({ expected_revision: 1, request_id: "bad", nodes: { chart: { type: "Chart", view: "table", hidden: [1], range: [1, 2], point: 1, inspecting: true, table: { query: "", sort: { column: 2, descending: false }, selection: [] } } } }))).rejects.toMatchObject({ status: 400 });
});
it("resets ambiguous chart label selections when only readings change", async () => {
currentSpec.elements.chart.props.x_data = ["duplicate", "duplicate", "unique"];
await saveMessages();
await states.put(owner, await input({ nodes: { chart: { type: "Chart", view: "table", hidden: [1], range: [1, 2], point: 1, inspecting: true, table: { query: "keep", sort: null, selection: [0] } } } }));
currentSpec.elements.chart.props.series[0].data[0] = 99;
await saveMessages();
expect((await states.get(owner, identity)).nodes.chart).toEqual({ type: "Chart", view: "table", hidden: [1], range: [0, 2], point: 0, inspecting: false, table: { query: "keep", sort: null, selection: [] } });
});
it("reads current messages inside the queue and blocks writes queued behind deletion", async () => {
const payload = await input();
let release!: () => void;
const barrier = new Promise<void>((resolve) => { release = resolve; });
const pending = workspaces.updateRecord(owner, "answer-view-state.json", async () => { await barrier; return { result: undefined }; });
currentSpec.elements.table.props.rows[0][0] = "changed";
const updateMessages = workspaces.writeRecord(owner, "messages.json", { sessionId: owner.sessionId, messages: [{ id: identity.message_id, role: "assistant", parts: [{ type: "data-answer_ui", data: { version: "answer-ui@1", block_id: identity.block_id, fallback_text: "fallback", spec: currentSpec } }] }] });
const write = states.put(owner, payload);
release();
await pending; await updateMessages;
await expect(write).rejects.toMatchObject({ code: "ANSWER_VIEW_SCHEMA_CHANGED" });
const fresh = await input();
const deletion = workspaces.markDeleted(owner);
const late = states.put(owner, fresh);
await deletion;
await expect(late).rejects.toThrow();
await expect(lstat(workspaces.path(owner, "answer-view-state.json"))).rejects.toThrow();
});
it("does not cross ownership, follow symlinks or revive deleted records", async () => {
const payload = await input();
await expect(states.get({ ...owner, actorKey: "actor-b" }, identity)).rejects.toThrow();
await expect(states.put({ ...owner, projectKey: "project-b" }, payload)).rejects.toThrow();
const outside = join(base, "outside.json");
await writeFile(outside, '{}');
await symlink(outside, workspaces.path(owner, "answer-view-state.json"));
await expect(states.get(owner, identity)).rejects.toThrow("symbolic link");
await expect(states.put(owner, payload)).rejects.toThrow("symbolic link");
await rm(workspaces.path(owner, "answer-view-state.json"));
await workspaces.markDeleted(owner);
await expect(states.put(owner, payload)).rejects.toThrow();
await workspaces.purge(owner);
await expect(states.put(owner, payload)).rejects.toThrow();
await expect(lstat(workspaces.path(owner, "answer-view-state.json"))).rejects.toThrow();
});
});
+4 -4
View File
@@ -11,12 +11,12 @@ describe("toUiEnvelopeFromToolCall", () => {
it("does not generate duplicate UIEnvelopes for SCADA frontend actions", () => {
expect(toUiEnvelopeFromToolCall({
tool: "render_scada_analysis",
params: { items: [{ device_id: "MP01", level: "high" }] },
tool: "present_view",
params: { command: { action: "render_scada_analysis", items: [{ device_id: "MP01", level: "high" }] } },
})).toBeNull();
expect(toUiEnvelopeFromToolCall({
tool: "clear_scada_analysis",
params: {},
tool: "present_view",
params: { command: { action: "clear_scada_analysis" } },
})).toBeNull();
});
});