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
+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]");
});
});