Move project and host queries into native MCP with host-owned OAuth/context, while retaining memory, skills, actions and publication plugins. Add nearby-model queries and explicit chat/task/learning model defaults. Adopt Responses HTTP/SSE and native steer/queue inbox delivery, preserving scoped session ownership, pending input edits and investigation communication. Disable local-provider discovery: Server /health allowed native vLLM to probe /v1/models on port 8000. A native before/after regression prevents recurrence. Validation: 170 focused Bun tests; service/plugin typechecks; native discovery, provider-discovery and input-inbox smoke checks; no paid model inference.
89 lines
7.8 KiB
TypeScript
89 lines
7.8 KiB
TypeScript
import { mkdtemp, rm } from "node:fs/promises";
|
|
import { tmpdir } from "node:os";
|
|
import { join } from "node:path";
|
|
import { SessionUiStateStore } from "../../src/sessions/uiStateStore.js";
|
|
import { SessionWorkspaceStore } from "../../src/sessions/workspace-store.js";
|
|
import { describe, expect, it } from "bun:test";
|
|
import { parseAnswerUiInput } from "../../src/chat/answer-ui.js";
|
|
import { streamPromptResponse } from "../../src/routes/chatStream.js";
|
|
import { toCollaborationMessages } from "../../src/routes/chatStreamRoute.js";
|
|
import type { OpencodeRuntimeAdapter } from "../../src/runtime/opencode.js";
|
|
import type { UIMessage } from "ai";
|
|
|
|
const input = { block_id: "pressure", fallback_text: "演练压力 0.3 MPa", spec: { root: "metric", elements: { metric: { type: "Metric", props: { label: "压力", value: "0.3", unit: "MPa", source: "演练数据" }, children: [] } } } };
|
|
describe("locally rendered answers", () => {
|
|
it("validates catalog props instead of accepting arbitrary model components", () => {
|
|
expect(parseAnswerUiInput(input)?.version).toBe("answer-ui@1");
|
|
for (const metric of [
|
|
{ ...input.spec.elements.metric, type: "Script" },
|
|
{ ...input.spec.elements.metric, props: { ...input.spec.elements.metric.props, execute: "alert(1)" } },
|
|
{ ...input.spec.elements.metric, children: ["metric"] },
|
|
]) expect(parseAnswerUiInput({ ...input, spec: { root: "metric", elements: { metric } } })).toBeNull();
|
|
});
|
|
it("rejects chart misalignment, nonfinite numbers and unreachable nodes", () => {
|
|
const node = { type: "Chart", props: { title: "演练", chart_type: "line", source: "演练数据", x_data: ["08:00"], series: [{ name: "压力", data: [0.3] }] }, children: [] };
|
|
const withNode = (value: unknown) => ({ ...input, spec: { root: "chart", elements: { chart: value } } });
|
|
expect(parseAnswerUiInput(withNode(node))).not.toBeNull();
|
|
for (const data of [[0.3, 0.4], [Infinity], [NaN]]) expect(parseAnswerUiInput(withNode({ ...node, props: { ...node.props, series: [{ name: "压力", data }] } }))).toBeNull();
|
|
expect(parseAnswerUiInput({ ...input, spec: { ...input.spec, elements: { ...input.spec.elements, unused: node } } })).toBeNull();
|
|
});
|
|
it("flushes prose before the interface, publishes once per tool call, and suppresses background UI", async () => {
|
|
const part = { id: "ui-call", type: "tool", tool: "present_answer", sessionID: "s", messageID: "m", state: { status: "completed", input, output: "{}", time: { start: 1, end: 2 } } };
|
|
const events = [
|
|
{ type: "message.part.updated", properties: { sessionID: "s", part: { id: "text", type: "text", text: "先看演练资料。", sessionID: "s", messageID: "m" } } },
|
|
...[part, part].map((value) => ({ type: "message.part.updated", properties: { sessionID: "s", part: value } })),
|
|
{ type: "session.idle", properties: { sessionID: "s" } },
|
|
];
|
|
const runtime = { prompt: async () => undefined, messages: async () => [], subscribeEvents: async () => ({ async *[Symbol.asyncIterator]() { for (const event of events) yield event; } }) } as unknown as OpencodeRuntimeAdapter;
|
|
for (const suppressAnswerUi of [false, true]) {
|
|
const emitted: Array<{ event: string; data: Record<string, unknown> }> = [];
|
|
await streamPromptResponse({ runtime, sessionId: "s", clientSessionId: "client", message: "演练", suppressAnswerUi, write: (event, data) => emitted.push({ event, data }) });
|
|
expect(emitted.filter((event) => event.event === "answer_ui")).toHaveLength(suppressAnswerUi ? 0 : 1);
|
|
if (!suppressAnswerUi) expect(emitted.findIndex((event) => event.event === "token")).toBeLessThan(emitted.findIndex((event) => event.event === "answer_ui"));
|
|
}
|
|
});
|
|
it("persists rich parts in the owned chat store and restores them without cross-scope access", async () => {
|
|
const root = await mkdtemp(join(tmpdir(), "tjwater-rich-answer-"));
|
|
try {
|
|
const owner = { actorKey: "actor-answer", projectKey: "project-answer", sessionId: "answer-session" };
|
|
const workspaces = new SessionWorkspaceStore(join(root, "workspaces"));
|
|
const store = new SessionUiStateStore(join(root, "legacy"), workspaces);
|
|
const parts: UIMessage["parts"] = [{ type: "text", text: "演练:" }, { type: "data-answer_ui", id: "pressure", data: parseAnswerUiInput(input) }, { type: "text", text: "仍待核验。" }];
|
|
const messages = [{ id: "a", role: "assistant", parts }];
|
|
await store.write(owner, { sessionId: owner.sessionId, messages });
|
|
const restored = new SessionUiStateStore(join(root, "legacy"), new SessionWorkspaceStore(workspaces.root));
|
|
expect((await restored.read(owner))?.messages).toEqual(messages);
|
|
expect(await restored.read({ ...owner, actorKey: "actor-other" })).toBeNull();
|
|
expect(await restored.read({ ...owner, projectKey: "project-other" })).toBeNull();
|
|
} finally { await rm(root, { recursive: true, force: true }); }
|
|
});
|
|
it("preserves UI-only answers and ordered rich parts in task collaboration history", () => {
|
|
const parts: UIMessage["parts"] = [{ type: "text", text: "演练:" }, { type: "data-answer_ui", id: "pressure", data: parseAnswerUiInput(input) }, { type: "text", text: "仍需核验。" }, { type: "data-final_answer", data: { content: "结论待确认。" } }];
|
|
const messages = [{ id: "a", role: "assistant", parts: [...parts, { type: "data-progress", data: { title: "查询" } }] }, { id: "b", role: "assistant", parts: [parts[1]] }] as unknown as UIMessage[];
|
|
const restored = toCollaborationMessages(messages);
|
|
expect(restored[0].parts).toEqual(parts);
|
|
expect(restored[1].parts).toEqual([parts[1]]);
|
|
});
|
|
});
|
|
|
|
it("finishes a rich chat turn at final_answer and prevents later automatic UI revisions", async () => {
|
|
const emitted: Array<{ event: string; data: Record<string, unknown> }> = [];
|
|
const interrupted: string[] = [];
|
|
const toolPart = (id: string, tool: string, args: unknown, output = "{}") => ({ type: "message.part.updated", properties: { sessionID: "s", part: { id, type: "tool", tool, sessionID: "s", messageID: "m", state: { status: "completed", input: args, output, time: { start: 1, end: 2 } } } } });
|
|
const events = [toolPart("ui", "present_answer", input), toolPart("final", "final_answer", { answer: "演练结论仍待核验。" }, JSON.stringify({ answer: "演练结论仍待核验。", ui_block_ids: ["pressure", "unknown", "pressure"] })), toolPart("late", "present_answer", { ...input, block_id: "late" }), { type: "session.idle", properties: { sessionID: "s" } }];
|
|
const runtime = { prompt: async () => undefined, messages: async () => [], abortSession: async (id: string) => { interrupted.push(id); }, waitForSessionIdle: async () => undefined, subscribeEvents: async () => ({ async *[Symbol.asyncIterator]() { for (const event of events) yield event; } }) } as unknown as OpencodeRuntimeAdapter;
|
|
const result = await streamPromptResponse({ runtime, sessionId: "s", clientSessionId: "client", message: "演练", write: (event, data) => emitted.push({ event, data }) });
|
|
expect(result).toMatchObject({ failed: false, aborted: false });
|
|
expect(emitted.filter((event) => event.event === "answer_ui")).toHaveLength(1);
|
|
expect(interrupted).toEqual(["s"]);
|
|
expect(emitted.find((event) => event.event === "final_answer")?.data.content).toBe("演练结论仍待核验。");
|
|
expect(emitted.find((event) => event.event === "final_answer")?.data.ui_block_ids).toEqual(["pressure"]);
|
|
expect(emitted.filter((event) => event.event === "done")).toHaveLength(1);
|
|
interrupted.length = 0;
|
|
emitted.length = 0;
|
|
await streamPromptResponse({ runtime, sessionId: "s", clientSessionId: "client", message: "后台执行", suppressAnswerUi: true, write: (event, data) => emitted.push({ event, data }) });
|
|
expect(interrupted).toEqual([]);
|
|
expect(emitted.filter((event) => event.event === "answer_ui")).toHaveLength(0);
|
|
expect(emitted.filter((event) => event.event === "done")).toHaveLength(1);
|
|
});
|