feat: upgrade investigation and presentation runtime
This commit is contained in:
@@ -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 });
|
||||
}
|
||||
});
|
||||
@@ -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");
|
||||
|
||||
@@ -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 },
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -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 () =>
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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("供水💧核查完成。");
|
||||
});
|
||||
});
|
||||
@@ -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-/),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
@@ -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()));
|
||||
}
|
||||
});
|
||||
@@ -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]");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user