feat: add scoped history and artifacts
This commit is contained in:
@@ -48,3 +48,20 @@ it("bounds pending input and refuses admission after an explicit stop", async ()
|
||||
tracker.close();
|
||||
await expect(tracker.submit(input(21))).rejects.toMatchObject({ status: 409 });
|
||||
});
|
||||
|
||||
it("allows close after withdrawal and keeps cancelled retries from enqueueing again", async () => {
|
||||
const runtime = { listPendingUserInputs: async () => [], enqueueUserInput: mock(async () => {}) };
|
||||
const changed = mock((_input: ResponseInput) => {});
|
||||
const tracker = new ResponseInputTracker(runtime, "owned", changed);
|
||||
tracker.markReady();
|
||||
await tracker.submit(input(1));
|
||||
tracker.observe("session.inbox.cancelled", { inboxID: input(1).id });
|
||||
expect(changed.mock.calls.at(-1)?.[0].status).toBe("cancelled");
|
||||
expect(tracker.hasAdditionalInput).toBe(false);
|
||||
expect((await tracker.submit(input(1))).status).toBe("cancelled");
|
||||
expect(runtime.enqueueUserInput).toHaveBeenCalledTimes(1);
|
||||
tracker.observe("session.inbox.delivery.changed", { inboxID: input(1).id, delivery: "queue" });
|
||||
tracker.observe("session.inbox.delivered", { inboxID: input(1).id });
|
||||
expect(changed).toHaveBeenCalledTimes(2);
|
||||
expect(await tracker.canClose()).toBe(true);
|
||||
});
|
||||
|
||||
@@ -1,6 +1,17 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "bun:test";
|
||||
import plugin from "../../.opencode/plugins/tjwater-tools.js";
|
||||
|
||||
const originalInternalToken = process.env.AGENT_INTERNAL_TOKEN;
|
||||
|
||||
beforeAll(() => {
|
||||
process.env.AGENT_INTERNAL_TOKEN = "test-internal-token";
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
if (originalInternalToken === undefined) delete process.env.AGENT_INTERNAL_TOKEN;
|
||||
else process.env.AGENT_INTERNAL_TOKEN = originalInternalToken;
|
||||
});
|
||||
|
||||
describe("OpenCode 2 business tool registration", () => {
|
||||
it("registers business tools for direct dispatch, forwards the native call ID and validates inputs", async () => {
|
||||
const tools = new Map<string, any>();
|
||||
@@ -9,7 +20,11 @@ describe("OpenCode 2 business tool registration", () => {
|
||||
let readTodos: ((input: { session_id: string }) => Promise<unknown>) | undefined;
|
||||
await plugin.setup({
|
||||
tool: { transform: async (fn: (editor: unknown) => void) => fn({ list: () => Array.from(tools, ([id, tool]) => ({ ...tool, id })), add: (tool: any) => tools.set(tool.name, tool), remove: (name: string) => tools.delete(name), update: () => {} }), list: async () => Array.from(tools, ([id, tool]) => ({ ...tool, id })) },
|
||||
mcp: { transform: async (fn: (editor: unknown) => void) => fn({ list: () => [] }), list: async () => ({ data: [] }) },
|
||||
mcp: {
|
||||
transform: async (fn: (editor: unknown) => void) =>
|
||||
fn({ list: () => [], set: () => {} }),
|
||||
list: async () => ({ data: [] }),
|
||||
},
|
||||
shell: { hook: async () => {} },
|
||||
permission: { hook: async () => {} },
|
||||
session: { hook: async () => {} },
|
||||
|
||||
@@ -25,8 +25,9 @@ try {
|
||||
const response = await native.client.rpc.call({ rpcID: "tjwater.tools", method: "list", input: {}, location: { directory } });
|
||||
const result = response.output as Array<{ id: string; direct: boolean }>;
|
||||
assert.ok(result.some((tool) => tool.id === "present_answer" && tool.direct), "present_answer must be registered as a direct native tool");
|
||||
assert.ok(result.some((tool) => tool.id === "publish_task_report" && tool.direct), "publish_task_report must remain registered with rich report support");
|
||||
assert.equal((await native.client.permission.create({ sessionID, action: "present_answer", resources: ["*"], save: [] })).effect, "allow");
|
||||
console.log("Native present_answer discovery and permission passed. No inference requests.");
|
||||
console.log("Native answer and report tool discovery, plus answer permission, passed. No inference requests.");
|
||||
} finally {
|
||||
if (native && sessionID) await native.client.session.remove({ sessionID }).catch(() => {});
|
||||
native?.close();
|
||||
|
||||
@@ -5,6 +5,7 @@ import { bindSessionCredential, releaseSessionCredential } from "../../src/produ
|
||||
import { Client, StreamableHTTPClientTransport } from "@modelcontextprotocol/client";
|
||||
import { startQueryMcpServer } from "../../src/mcp/query-server.js";
|
||||
import { setRuntimeSessionContext, removeRuntimeSessionContext } from "../../src/runtime/sessionContext.js";
|
||||
import { PRODUCT_MCP_TOOLS, QUERY_MCP_TOOL_IDS } from "../../src/runtime/product-mcp-tools.js";
|
||||
|
||||
const python = process.env.MCP_TEST_PYTHON;
|
||||
const root = process.env.MCP_TEST_SERVER_ROOT;
|
||||
@@ -44,23 +45,31 @@ try {
|
||||
};
|
||||
const client = new McpQueryClient({ issuer: "http://localhost/realms/test", resource, audience: "tjwater-mcp", clientId: "tjwater-agent-mcp", clientSecret: "fixture" }, http);
|
||||
const catalog = await client.prepare();
|
||||
if (catalog.length !== 35) throw new Error("Query catalog discovery failed");
|
||||
if (catalog.length !== PRODUCT_MCP_TOOLS.length) throw new Error("Query catalog discovery failed");
|
||||
if (!catalog.some((tool) => tool.name === "run_simulation" && tool.inputSchema.properties?.start_time && tool.inputSchema.properties?.output_node_ids)) throw new Error("Standard hydraulic simulation schema missing");
|
||||
bindSessionCredential(session, { authorization: `Bearer ${fixtureJwt("source")}`, projectId: "d44a9241-de48-4e60-9348-867dc466a031" });
|
||||
setRuntimeSessionContext({ sessionId: session, clientSessionId: session, actorKey: "fixture", traceId: "fixture", projectId: "d44a9241-de48-4e60-9348-867dc466a031", projectKey: "fixture-project", purpose: "ordinary_chat" });
|
||||
gateway = startQueryMcpServer({ internalToken: "MCP_TEST_ONLY", productClient: client, productTools: catalog, resolveReader: async () => null, executeLocal: async () => { throw new Error("unused fixture query"); } });
|
||||
nativeClient = new Client({ name: "fixture-native", version: "1" }, { versionNegotiation: { mode: { pin: "2026-07-28" } } });
|
||||
await nativeClient.connect(new StreamableHTTPClientTransport(new URL(gateway.url), { requestInit: { headers: { "x-agent-internal-token": "MCP_TEST_ONLY" } } }));
|
||||
if ((await nativeClient.listTools()).tools.length !== 41) throw new Error("Merged MCP query catalog discovery failed");
|
||||
if ((await nativeClient.listTools()).tools.length !== QUERY_MCP_TOOL_IDS.length) throw new Error("Merged MCP query catalog discovery failed");
|
||||
const result = await nativeClient.callTool({ name: "list_elements", arguments: { element_type: "pipe", limit: 1 }, _meta: { "ai.opencode/sessionID": session } });
|
||||
if (result.isError || JSON.stringify((result.structuredContent as { data?: unknown } | undefined)?.data) !== '[{"id":"P-1"}]') throw new Error("Typed query execution failed");
|
||||
const nearbyTool = catalog.find((tool) => tool.name === "query_nearby_elements");
|
||||
if (!nearbyTool?.inputSchema.properties?.center) throw new Error("Nearby model tool schema missing");
|
||||
const nearby = await nativeClient.callTool({ name: "query_nearby_elements", arguments: { center: [121.5, 30.9], radius_m: 500, limit: 5 }, _meta: { "ai.opencode/sessionID": session } });
|
||||
if (nearby.isError) throw new Error("Nearby model query failed through merged MCP");
|
||||
for (const [name, input] of [
|
||||
["get_project_info", {}],
|
||||
["get_node_connections", { node_id: "J1", limit: 1 }],
|
||||
["query_network_extent", { bounds: [121, 31, 121.1, 31.1], limit: 1 }],
|
||||
] as const) {
|
||||
const response = await nativeClient.callTool({ name, arguments: input, _meta: { "ai.opencode/sessionID": session } });
|
||||
if (response.isError) throw new Error(`Extended tool failed through merged MCP: ${name}`);
|
||||
}
|
||||
if (!requests.includes("server/discover") || !requests.includes("tools/call") || requests.includes("initialize") || requests.includes("GET")) throw new Error("Transport used unexpected protocol requests");
|
||||
if (grants.join(",") !== "client_credentials,urn:ietf:params:oauth:grant-type:token-exchange") throw new Error("OAuth grants were not validated");
|
||||
console.log("PASS: local MCP 41-tool discovery, real Server schemas, OAuth discovery, resource-bound grants, host binding, query and revocation over MCP HTTP");
|
||||
console.log(`PASS: local MCP ${QUERY_MCP_TOOL_IDS.length}-tool discovery, real Server schemas, OAuth discovery, resource-bound grants, host binding, extended query and revocation over MCP HTTP`);
|
||||
} finally {
|
||||
await nativeClient?.close();
|
||||
await gateway?.close();
|
||||
|
||||
@@ -61,6 +61,9 @@ try {
|
||||
await client.session.inbox.update({ sessionID: session.id, inboxID: item.id, delivery: "steer" });
|
||||
assert.equal((await client.session.inbox.list({ sessionID: session.id })).find((entry) => entry.id === item.id)?.delivery, "steer");
|
||||
await client.session.inbox.update({ sessionID: session.id, inboxID: item.id, delivery: "queue" });
|
||||
const withdrawn = await tracker.submit({ id: `msg_${"2".repeat(32)}`, text: "Withdraw this before delivery", delivery: "queue", status: "pending" });
|
||||
assert.equal((await runtime.cancelPendingUserInput(session.id, withdrawn.id)).status, "cancelled");
|
||||
assert.ok(!(await client.session.inbox.list({ sessionID: session.id })).some((entry) => entry.id === withdrawn.id));
|
||||
release();
|
||||
await wait("queued continuation", () => prompts.length >= 2);
|
||||
await Bun.sleep(100);
|
||||
@@ -77,8 +80,13 @@ try {
|
||||
assert.ok(tokens.join("").split("Local completed.").length >= 3, "managed stream closed before queued answer");
|
||||
assert.ok(JSON.stringify(prompts[1]).includes("Additional local user input"));
|
||||
assert.ok(!(await client.session.inbox.list({ sessionID: session.id })).some((entry) => entry.id === item.id));
|
||||
assert.ok(!JSON.stringify(prompts).includes(withdrawn.text));
|
||||
await assert.rejects(runtime.cancelPendingUserInput(session.id, item.id), { status: 409 });
|
||||
// Simulate delivery winning the race after the adapter's pending-list read.
|
||||
const staleRuntime = Object.assign(Object.create(runtime), { listPendingUserInputs: async () => [item] }) as OpencodeRuntimeAdapter;
|
||||
await assert.rejects(staleRuntime.cancelPendingUserInput(session.id, item.id), { status: 409 });
|
||||
await client.session.remove({ sessionID: session.id });
|
||||
console.log("Native queue delivery, pending steer/queue changes and automatic continuation passed; local fixture only.");
|
||||
console.log("Native queue delivery, pending changes, withdrawal and delivery-race rejection passed; local fixture only.");
|
||||
} finally {
|
||||
release();
|
||||
releaseSecond();
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { afterEach, expect, it } from "bun:test";
|
||||
import { createHash } from "node:crypto";
|
||||
import { mkdtemp, mkdir, readFile, rm, symlink } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { config } from "../../src/config.js";
|
||||
import { saveMcpArtifact, openArtifactDirectory } from "../../src/mcp/artifact-files.js";
|
||||
import { PRODUCT_MCP_WRITE_TOOL_IDS } from "../../src/runtime/product-mcp-tools.js";
|
||||
import { serviceOpencodeConfig } from "../../src/runtime/opencode2-config.js";
|
||||
import { allowedQueryTools } from "../../src/runtime/query-mcp-policy.js";
|
||||
import type { RuntimeSessionContext } from "../../src/runtime/sessionContext.js";
|
||||
|
||||
const roots: string[] = [];
|
||||
const originalRoot = config.CONVERSATION_WORKSPACE_ROOT;
|
||||
afterEach(async () => {
|
||||
config.CONVERSATION_WORKSPACE_ROOT = originalRoot;
|
||||
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
|
||||
});
|
||||
const owner: RuntimeSessionContext = { sessionId: "export-native", clientSessionId: "export-client", actorKey: "actor-a", projectKey: "project-a", projectId: "p", purpose: "ordinary_chat", traceId: "test" };
|
||||
|
||||
async function workspace() {
|
||||
const root = await mkdtemp(join(tmpdir(), "artifact-test-")); roots.push(root);
|
||||
config.CONVERSATION_WORKSPACE_ROOT = root;
|
||||
const directory = join(root, "actor-a", "project-a", `session-${createHash("sha256").update(owner.sessionId).digest("hex")}`, "workspace");
|
||||
await mkdir(join(directory, "files"), { recursive: true });
|
||||
return directory;
|
||||
}
|
||||
|
||||
function result() {
|
||||
const bytes = Buffer.from("export fixture");
|
||||
return { content: [{ type: "text" as const, text: "binary" }], structuredContent: { source: "export_project_model", evidence_id: "E1", data: { file: {
|
||||
name: "model.zip", mime_type: "application/zip", encoding: "base64", data: bytes.toString("base64"), size: bytes.length, sha256: createHash("sha256").update(bytes).digest("hex"),
|
||||
} } } };
|
||||
}
|
||||
|
||||
it("writes verified exports only to the owning workspace and returns no binary or host path", async () => {
|
||||
const directory = await workspace();
|
||||
const saved = await saveMcpArtifact(result(), owner, directory);
|
||||
const data = saved.structuredContent as { data: { file: { relative_path: string; download_url: string } } };
|
||||
expect(await readFile(join(directory, data.data.file.relative_path), "utf8")).toBe("export fixture");
|
||||
expect(data.data.file.download_url).toStartWith("/api/v1/session/export-client/files/mcp-");
|
||||
expect(JSON.stringify(saved)).not.toContain(directory);
|
||||
expect(JSON.stringify(saved)).not.toContain(Buffer.from("export fixture").toString("base64"));
|
||||
expect(saved.structuredContent).toHaveProperty("evidence_id", "E1");
|
||||
});
|
||||
|
||||
it("rejects tampered file contents, cross-session workspaces and symlink directories", async () => {
|
||||
const directory = await workspace();
|
||||
const tampered = result(); tampered.structuredContent.data.file.sha256 = "0".repeat(64);
|
||||
await expect(saveMcpArtifact(tampered, owner, directory)).rejects.toThrow("integrity");
|
||||
await expect(openArtifactDirectory(directory, "other-session")).rejects.toThrow("owner");
|
||||
await rm(join(directory, "files"), { recursive: true });
|
||||
await symlink(tmpdir(), join(directory, "files"));
|
||||
await expect(saveMcpArtifact(result(), owner, directory)).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("keeps all product writes behind native ask permissions and out of task, reader and review policies", () => {
|
||||
const configuration = serviceOpencodeConfig({});
|
||||
for (const tool of PRODUCT_MCP_WRITE_TOOL_IDS) {
|
||||
expect(configuration.permissions.filter((rule) => rule.action === tool).at(-1)?.effect).toBe("ask");
|
||||
expect(allowedQueryTools(owner)).toContain(tool);
|
||||
expect(allowedQueryTools({ ...owner, purpose: "task_execution" })).not.toContain(tool);
|
||||
expect(allowedQueryTools({ ...owner, purpose: "task_collaboration" })).not.toContain(tool);
|
||||
expect(allowedQueryTools(owner, true)).not.toContain(tool);
|
||||
expect(allowedQueryTools({ ...owner, learningMode: "review" })).not.toContain(tool);
|
||||
}
|
||||
});
|
||||
@@ -66,3 +66,46 @@ describe("new investigation report writes", () => {
|
||||
expect(updated.diagram?.node_refs.map((node) => node.status)).toEqual(["completed", "running", "pending"]);
|
||||
});
|
||||
});
|
||||
|
||||
const uiBlock = {
|
||||
version: "answer-ui@1" as const, block_id: "pressure", fallback_text: "演练数据,等待核验。",
|
||||
spec: { root: "chart", elements: { chart: { type: "Chart", children: [], props: {
|
||||
title: "演练压力", chart_type: "line", x_data: ["08:00", "09:00"],
|
||||
series: [{ name: "压力", data: [0.3, 0.28] }], source: "query-1",
|
||||
} } } },
|
||||
};
|
||||
|
||||
describe("interactive report publication", () => {
|
||||
it("preserves rich blocks and legacy reports without changing fixed facts", () => {
|
||||
expect(taskReportSchema.parse(report).ui_blocks).toBeUndefined();
|
||||
const parsed = taskReportSchema.parse({ ...report, ui_blocks: [{ block: uiBlock, evidence_refs: ["query-1"] }] });
|
||||
expect(parsed.ui_blocks?.[0].block).toEqual(uiBlock);
|
||||
expect(parsed.outcome).toBe("insufficient_data");
|
||||
expect(parsed.data_gaps).toEqual(report.data_gaps);
|
||||
});
|
||||
it("rejects foreign evidence, duplicate ids, executable props and malformed charts", () => {
|
||||
const item = { block: uiBlock, evidence_refs: ["query-1"] };
|
||||
for (const blocks of [
|
||||
[item, item], [{ ...item, evidence_refs: ["other-task"] }],
|
||||
Array.from({ length: 9 }, (_, index) => ({ ...item, block: { ...uiBlock, block_id: `b${index}` } })),
|
||||
...[
|
||||
{ type: "iframe" },
|
||||
{ props: { ...uiBlock.spec.elements.chart.props, onclick: "execute()" } },
|
||||
{ props: { ...uiBlock.spec.elements.chart.props, series: [{ name: "压力", data: [1] }] } },
|
||||
{ props: { ...uiBlock.spec.elements.chart.props, series: [{ name: "压力", data: [NaN, 1] }] } },
|
||||
].map((change) => [{ ...item, block: { ...uiBlock, spec: { ...uiBlock.spec,
|
||||
elements: { chart: { ...uiBlock.spec.elements.chart, ...change } } } } }]),
|
||||
]) expect(taskReportSchema.safeParse({ ...report, ui_blocks: blocks }).success).toBe(false);
|
||||
});
|
||||
it("bounds the total UTF-8 payload even when every individual block is valid", () => {
|
||||
const elements = Object.fromEntries(Array.from({ length: 20 }, (_, index) => [
|
||||
`n${index}`, { type: "Text", props: { text: "资".repeat(3900), tone: "neutral" }, children: [] },
|
||||
]));
|
||||
const block = { ...uiBlock, spec: { root: "root", elements: {
|
||||
root: { type: "Stack", props: { columns: "one", gap: "normal" }, children: Object.keys(elements) }, ...elements,
|
||||
} } };
|
||||
const blocks = ["first", "second"].map((block_id) => ({ block: { ...block, block_id }, evidence_refs: ["query-1"] }));
|
||||
expect(taskReportSchema.safeParse({ ...report, ui_blocks: [blocks[0]] }).success).toBe(true);
|
||||
expect(taskReportSchema.safeParse({ ...report, ui_blocks: blocks }).success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import { expect, it } from "bun:test";
|
||||
import express, { type Request } from "express";
|
||||
import { createHash } from "node:crypto";
|
||||
import { mkdtemp, mkdir, writeFile, rm, symlink } 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 { registerArtifactRoutes } from "../../src/routes/artifact-routes.js";
|
||||
import { SessionMetadataStore } from "../../src/sessions/metadataStore.js";
|
||||
|
||||
it("requires verified actor, project and session ownership for exports and rejects symlinks", async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
const originalEnv = config.NODE_ENV;
|
||||
const originalRoot = config.CONVERSATION_WORKSPACE_ROOT;
|
||||
const root = await mkdtemp(join(tmpdir(), "artifact-route-"));
|
||||
const store = new SessionMetadataStore(join(root, "metadata"));
|
||||
const app = express();
|
||||
let calls = 0;
|
||||
globalThis.fetch = (async (input: Parameters<typeof fetch>[0], init: Parameters<typeof fetch>[1]) => {
|
||||
if (String(input).endsWith("/api/v1/agent-auth-context")) {
|
||||
const headers = new Headers(init?.headers);
|
||||
return Response.json({ user_id: headers.get("Authorization") === "Bearer OWNER" ? "owner" : "other", project_id: headers.get("X-Project-Id"), network: "supply" });
|
||||
}
|
||||
return originalFetch(input, init);
|
||||
}) as typeof fetch;
|
||||
config.NODE_ENV = "production";
|
||||
config.CONVERSATION_WORKSPACE_ROOT = root;
|
||||
const owner = { header: (name: string) => ({ authorization: "Bearer OWNER", "x-project-id": "project" })[name.toLowerCase() as "authorization" | "x-project-id"] } as Request;
|
||||
await attachTrustedAgentContext(owner);
|
||||
const context = getLocalAgentContext(owner);
|
||||
const session = "export-session";
|
||||
await store.ensure({ ...context, sessionId: session });
|
||||
const directory = join(root, context.actorKey, context.projectKey, `session-${createHash("sha256").update(session).digest("hex")}`, "workspace");
|
||||
await mkdir(join(directory, "files"), { recursive: true });
|
||||
const filename = "mcp-11111111-1111-1111-1111-111111111111.zip";
|
||||
await writeFile(join(directory, "files", filename), "fixture");
|
||||
const router = express.Router();
|
||||
router.use(async (req, res, next) => {
|
||||
try { await attachTrustedAgentContext(req); next(); }
|
||||
catch (error) { res.status(error instanceof ProductContextError ? error.status : 503).end(); }
|
||||
});
|
||||
registerArtifactRoutes(router, { sessionMetadataStore: store, runtime: {
|
||||
ensureSessionWorkspace: async () => { calls++; return directory; },
|
||||
} });
|
||||
app.use("/api/v1", router);
|
||||
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}/api/v1/session/${session}/files/${filename}`;
|
||||
const headers = { Authorization: "Bearer OWNER", "X-Project-Id": "project" };
|
||||
try {
|
||||
expect((await originalFetch(url)).status).toBe(401);
|
||||
expect((await originalFetch(url, { headers: { ...headers, Authorization: "Bearer OTHER" } })).status).toBe(404);
|
||||
expect((await originalFetch(url, { headers: { ...headers, "X-Project-Id": "other" } })).status).toBe(404);
|
||||
expect(calls).toBe(0);
|
||||
const response = await originalFetch(url, { headers });
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get("Cache-Control")).toBe("private, no-store");
|
||||
expect(response.headers.get("Content-Disposition")).toContain("attachment");
|
||||
expect(await response.text()).toBe("fixture");
|
||||
await rm(join(directory, "files", filename));
|
||||
await symlink(join(root, "metadata"), join(directory, "files", filename));
|
||||
expect((await originalFetch(url, { headers })).status).toBe(404);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch; config.NODE_ENV = originalEnv; config.CONVERSATION_WORKSPACE_ROOT = originalRoot;
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
import { afterEach, expect, it } from "bun:test";
|
||||
import express from "express";
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import type { Server } from "node:http";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { buildChatRouter } from "../../src/routes/chat.js";
|
||||
import { SessionMetadataStore } from "../../src/sessions/metadataStore.js";
|
||||
import { SessionUiStateStore } from "../../src/sessions/uiStateStore.js";
|
||||
import { SessionWorkspaceStore } from "../../src/sessions/workspace-store.js";
|
||||
import { toActorKey, toProjectKey } from "../../src/utils/fileStore.js";
|
||||
|
||||
const http = globalThis.fetch;
|
||||
let root: string;
|
||||
let server: Server;
|
||||
afterEach(async () => { globalThis.fetch = http; if (server) { server.closeAllConnections(); await new Promise<void>((resolve) => server.close(() => resolve())); } if (root) await rm(root, { recursive: true, force: true }); });
|
||||
|
||||
it("persists owned topics, searches old text across themes and preserves chats when a theme is deleted", async () => {
|
||||
root = await mkdtemp(join(tmpdir(), "tjwater-topics-"));
|
||||
const project = "10000000-0000-4000-8000-000000000001";
|
||||
const context = { actorKey: toActorKey("product:owner"), projectKey: toProjectKey(`product:${project}`), projectId: project, userId: "owner" };
|
||||
globalThis.fetch = (async (_input, init) => Response.json({ user_id: new Headers(init?.headers).get("authorization") === "Bearer owner" ? "owner" : "other", project_id: new Headers(init?.headers).get("x-project-id"), network: "supply" })) as typeof fetch;
|
||||
const workspaces = new SessionWorkspaceStore(join(root, "workspaces"));
|
||||
const sessions = new SessionMetadataStore(join(root, "metadata"), workspaces);
|
||||
const states = new SessionUiStateStore(join(root, "ui"), workspaces);
|
||||
for (const id of ["a", "b", "unclassified"]) {
|
||||
await sessions.ensure({ ...context, sessionId: id });
|
||||
await states.write({ ...context, sessionId: id }, { sessionId: id, messages: [
|
||||
{ id: "old", role: "user", parts: [{ type: "text", text: `历史最早消息的共同关键词:南桥阀门 ${id}` }] },
|
||||
...Array.from({ length: 60 }, (_, index) => ({ id: `message-${index}`, role: "assistant", parts: [{ type: "text", text: "正在复核监测记录" }] })),
|
||||
{ role: "assistant", parts: [{ type: "reasoning", text: "隐藏推理密码" }, { type: "tool-input", text: "工具秘密参数" }] },
|
||||
] });
|
||||
}
|
||||
await sessions.ensure({ ...context, sessionId: "collaboration", purpose: "task_collaboration" });
|
||||
await states.write({ ...context, sessionId: "collaboration" }, { sessionId: "collaboration", messages: [{ role: "user", parts: [{ type: "text", text: "协作讨论:南桥阀门" }] }] });
|
||||
await sessions.ensure({ ...context, sessionId: "execution", purpose: "task_execution" });
|
||||
const app = express(); app.use(express.json());
|
||||
app.use("/api/v1", buildChatRouter({ sessionMetadataStore: sessions, sessionUiStateStore: states, runtime: { createSession: async () => ({ id: "new-topic-chat" }) } as never,
|
||||
sessionBridge: {} as never, sessionTranscriptStore: {} as never, memoryStore: {} as never, learningOrchestrator: {} as never, frontendActionCoordinator: {} 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 address");
|
||||
const request = async (path: string, method = "GET", body?: unknown, user = "owner", projectId = project) => {
|
||||
const response = await http(`http://127.0.0.1:${address.port}/api/v1${path}`, { method,
|
||||
headers: { Authorization: `Bearer ${user}`, "X-Project-Id": projectId, "Content-Type": "application/json" }, body: body === undefined ? undefined : JSON.stringify(body) });
|
||||
return { status: response.status, body: JSON.parse(await response.text() || "null") };
|
||||
};
|
||||
const first = (await request("/topics", "POST", { name: "压力异常事件" })).body;
|
||||
const second = (await request("/topics", "POST", { name: "阀门检修问题" })).body;
|
||||
expect((await request("/topics", "POST", { name: " " })).status).toBe(400);
|
||||
expect((await request("/topics", "GET", undefined, "other")).body.topics).toEqual([]);
|
||||
expect((await request(`/topics/${first.id}`, "PATCH", { name: "越权" }, "other")).status).toBe(404);
|
||||
expect((await request(`/session/a/topic`, "PATCH", { topic_id: first.id })).status).toBe(200);
|
||||
expect((await request(`/session/b/topic`, "PATCH", { topic_id: second.id })).status).toBe(200);
|
||||
expect((await request(`/session/a/topic`, "PATCH", { topic_id: first.id }, "other")).status).toBe(404);
|
||||
expect((await request(`/session/a/topic`, "PATCH", { topic_id: first.id }, "owner", "10000000-0000-4000-8000-000000000002")).status).toBe(404);
|
||||
const hits = (await request(`/sessions?q=${encodeURIComponent("南桥阀门")}&limit=1`)).body;
|
||||
const next = (await request(`/sessions?q=${encodeURIComponent("南桥阀门")}&limit=10&cursor=${encodeURIComponent(hits.next_cursor)}`)).body;
|
||||
expect(hits.sessions.length + next.sessions.length).toBe(3);
|
||||
expect([...hits.sessions, ...next.sessions].every((item) => item.search_snippet.includes("南桥阀门"))).toBe(true);
|
||||
expect((await request(`/sessions?topic_id=${first.id}&q=${encodeURIComponent("南桥阀门")}`)).body.sessions.map((item: { id: string }) => item.id)).toEqual(["a"]);
|
||||
expect((await request(`/sessions?topic_id=${first.id}&q=${encodeURIComponent("南桥阀门")}&cursor=${encodeURIComponent(hits.next_cursor)}`)).status).toBe(400);
|
||||
expect((await request(`/sessions?q=${encodeURIComponent("隐藏推理密码")}`)).body.sessions).toEqual([]);
|
||||
expect((await request(`/sessions?q=${encodeURIComponent("工具秘密参数")}`)).body.sessions).toEqual([]);
|
||||
await request("/session/collaboration/topic", "PATCH", { topic_id: first.id });
|
||||
expect((await request(`/sessions?include_collaboration=true&topic_id=${first.id}&q=${encodeURIComponent("南桥阀门")}`)).body.sessions.map((item: { id: string }) => item.id).sort()).toEqual(["a", "collaboration"]);
|
||||
expect((await request("/sessions?include_collaboration=true")).body.sessions.some((item: { id: string }) => item.id === "execution")).toBe(false);
|
||||
await request(`/topics/${first.id}`, "PATCH", { name: "压力异常复盘" });
|
||||
expect((await new SessionMetadataStore(join(root, "metadata"), workspaces).topics.list(context))[0]?.name).toBe("压力异常复盘");
|
||||
const created = await request("/session", "POST", { topic_id: first.id });
|
||||
expect(created.body.topic_id).toBe(first.id);
|
||||
expect((await request(`/topics/${first.id}`, "DELETE")).status).toBe(204);
|
||||
expect((await request("/session/a")).body.topic_id).toBe(null);
|
||||
expect((await request("/session/a")).body.messages).toHaveLength(62);
|
||||
expect((await request("/sessions?topic_id=unclassified")).body.sessions.map((item: { id: string }) => item.id).sort()).toEqual(["a", "new-topic-chat", "unclassified"]);
|
||||
expect((await request(`/session/a/topic`, "PATCH", { topic_id: first.id })).status).toBe(404);
|
||||
});
|
||||
@@ -24,6 +24,7 @@ let aborts: number;
|
||||
let gate: Promise<void> | undefined;
|
||||
let release: () => void = () => {};
|
||||
let bridge: ChatSessionBridge;
|
||||
let states: SessionUiStateStore;
|
||||
const project = "10000000-0000-4000-8000-000000000001";
|
||||
beforeEach(async () => {
|
||||
fail = false; prompted = []; pendingInputs = []; aborts = 0; gate = undefined;
|
||||
@@ -34,7 +35,7 @@ beforeEach(async () => {
|
||||
})) as typeof fetch;
|
||||
const workspaces = new SessionWorkspaceStore(join(root, "workspaces"));
|
||||
const sessions = new SessionMetadataStore(join(root, "metadata"), workspaces);
|
||||
const states = new SessionUiStateStore(join(root, "ui"), workspaces);
|
||||
states = new SessionUiStateStore(join(root, "ui"), workspaces);
|
||||
await sessions.initialize();
|
||||
const context = { actorKey: toActorKey("product:owner"), projectKey: toProjectKey(`product:${project}`), projectId: project, userId: "owner", sessionId: "owned" };
|
||||
await sessions.ensure(context);
|
||||
@@ -44,6 +45,12 @@ beforeEach(async () => {
|
||||
messages: async () => [], abortSession: async () => { aborts++; }, waitForSessionIdle: async () => {},
|
||||
listPendingUserInputs: async () => pendingInputs.map((input) => ({ ...input })),
|
||||
cancelPendingUserInputs: async () => { pendingInputs = []; },
|
||||
cancelPendingUserInput: async (_sessionId: string, id: string) => {
|
||||
const item = pendingInputs.find((input) => input.id === id);
|
||||
if (!item) throw Object.assign(new Error("Consumed"), { status: 409 });
|
||||
pendingInputs = pendingInputs.filter((input) => input.id !== id);
|
||||
return { ...item, status: "cancelled" };
|
||||
},
|
||||
enqueueUserInput: async (_sessionId: string, input: ResponseInput) => { pendingInputs.push({ ...input }); },
|
||||
updatePendingUserInput: async (_sessionId: string, id: string, delivery: "steer" | "queue") => {
|
||||
const item = pendingInputs.find((input) => input.id === id);
|
||||
@@ -144,6 +151,31 @@ it("rejects cross-user conversations, legacy requests and retired URLs before ge
|
||||
expect((await call({ input: "核查压力" }, "owner", "/api/v1/agent/chat/stream")).status).toBe(404);
|
||||
expect(prompted).toEqual([]);
|
||||
});
|
||||
|
||||
it("withdraws only owned pending input without stopping generation or persisting a sent message", async () => {
|
||||
gate = new Promise<void>((resolve) => { release = resolve; });
|
||||
const initial = await call({ input: "原始问题", conversation: "owned", stream: true });
|
||||
const id = `msg_${"3".repeat(32)}`;
|
||||
await call({ input: "撤回后重新编辑", conversation: "owned", tjwater: { delivery: "queue", input_id: id } });
|
||||
const headers = { Authorization: "Bearer owner", "X-Project-Id": project };
|
||||
const url = `${base}/api/v1/session/owned/inputs/${id}`;
|
||||
const baselineAborts = aborts;
|
||||
expect((await http(url, { method: "DELETE", headers: { ...headers, Authorization: "Bearer other" } })).status).toBe(404);
|
||||
expect(pendingInputs).toHaveLength(1);
|
||||
const withdrawn = await http(url, { method: "DELETE", headers });
|
||||
expect(withdrawn.status).toBe(200);
|
||||
expect(await withdrawn.json()).toMatchObject({ id, text: "撤回后重新编辑", delivery: "queue", status: "cancelled" });
|
||||
expect(pendingInputs).toHaveLength(0);
|
||||
expect((await http(url, { method: "DELETE", headers })).status).toBe(409);
|
||||
expect(aborts).toBe(baselineAborts);
|
||||
release();
|
||||
const output = await initial.text();
|
||||
expect(output).toContain('"status":"cancelled"');
|
||||
expect(output).toContain("event: response.completed");
|
||||
const history = await http(`${base}/api/v1/session/owned`, { headers });
|
||||
const messages = (await history.json()).messages as Array<{ role: string }>;
|
||||
expect(JSON.stringify(messages.filter((message) => message.role === "user"))).not.toContain("撤回后重新编辑");
|
||||
});
|
||||
it("returns response.failed when the runtime fails", async () => {
|
||||
fail = true;
|
||||
const response = await call({ input: "核查压力", conversation: "owned", stream: true });
|
||||
@@ -183,3 +215,22 @@ it("marks an explicitly aborted response incomplete and preserves scoped history
|
||||
expect(text).toContain("event: response.incomplete");
|
||||
expect(text).not.toContain("event: response.completed");
|
||||
});
|
||||
|
||||
it("continues a paged conversation from the complete saved history and preserves client message IDs", async () => {
|
||||
const context = { actorKey: toActorKey("product:owner"), projectKey: toProjectKey(`product:${project}`), sessionId: "owned" };
|
||||
const history = Array.from({ length: 105 }, (_, index) => ({ id: `old-${index}`, role: index % 2 ? "assistant" : "user",
|
||||
parts: [{ type: "text", text: `历史证据 ${index}` }] }));
|
||||
await states.write(context, { sessionId: "owned", isTitleManuallyEdited: true, messages: history });
|
||||
const headers = { Authorization: "Bearer owner", "X-Project-Id": project };
|
||||
const first = await http(`${base}/api/v1/session/owned?message_limit=40`, { headers });
|
||||
expect((await first.json()).messages).toHaveLength(40);
|
||||
const response = await call({ input: "继续核查", conversation: "owned", tjwater: { client_message_id: "client-turn-a" } });
|
||||
expect(response.status).toBe(200);
|
||||
await response.json();
|
||||
const saved = (await states.read(context))!.messages as Array<{ id: string }>;
|
||||
expect(saved.slice(0, 105)).toEqual(history);
|
||||
expect(saved).toHaveLength(107);
|
||||
expect(saved[105]?.id).toBe("client-turn-a");
|
||||
expect((await call({ input: "重复提交", conversation: "owned", tjwater: { client_message_id: "client-turn-a" } })).status).toBe(409);
|
||||
expect(prompted).toHaveLength(1);
|
||||
});
|
||||
|
||||
@@ -110,6 +110,28 @@ describe("chat session HTTP isolation", () => {
|
||||
expect(otherUser.body.sessions).toEqual([]);
|
||||
expect(otherProject.body.sessions).toEqual([]);
|
||||
expect((await getJson(address.port, "/api/v1/session/ordinary-session", "token-a", ownerContext.projectId)).body.messages).toEqual([{ type: "text", text: "owner-only answer" }]);
|
||||
const pagedMessages = Array.from({ length: 105 }, (_, index) => ({ id: `message-${index}`, role: "user", parts: [{ type: "text", text: `record ${index}` }] }));
|
||||
await sessionUiStateStore.write({ ...ownerContext, sessionId: "ordinary-session" }, { sessionId: "ordinary-session", messages: pagedMessages });
|
||||
const latestPage = await getJson(address.port, "/api/v1/session/ordinary-session?message_limit=40", "token-a", ownerContext.projectId);
|
||||
expect(latestPage.body.messages).toEqual(pagedMessages.slice(65));
|
||||
const cursor = encodeURIComponent(latestPage.body.message_page.before_cursor);
|
||||
const olderPage = await getJson(address.port, `/api/v1/session/ordinary-session/messages?limit=40&before=${cursor}`, "token-a", ownerContext.projectId);
|
||||
expect(olderPage.body.messages).toEqual(pagedMessages.slice(25, 65));
|
||||
expect((await getJson(address.port, `/api/v1/session/ordinary-session/messages?before=${cursor}`, "token-b", ownerContext.projectId)).status).toBe(404);
|
||||
expect((await getJson(address.port, "/api/v1/session/ordinary-session/messages?limit=0", "token-a", ownerContext.projectId)).status).toBe(400);
|
||||
expect((await getJson(address.port, "/api/v1/session/ordinary-session?message_limit=101", "token-a", ownerContext.projectId)).status).toBe(400);
|
||||
expect((await getJson(address.port, "/api/v1/sessions?q=not-found&limit=30", "token-a", ownerContext.projectId)).body.sessions).toEqual([]);
|
||||
expect((await getJson(address.port, "/api/v1/sessions?limit=1", "token-a", ownerContext.projectId)).body.sessions).toHaveLength(1);
|
||||
const restored = await originalFetch(`http://127.0.0.1:${address.port}/api/v1/session/ordinary-session/stream?message_limit=40`, {
|
||||
headers: { Authorization: "Bearer token-a", "X-Project-Id": ownerContext.projectId },
|
||||
});
|
||||
const snapshot = JSON.parse((await restored.text()).split("data: ")[1]!.trim());
|
||||
expect(snapshot.messages).toEqual(pagedMessages.slice(65));
|
||||
expect(snapshot.message_page.has_more).toBe(true);
|
||||
expect((await sessionUiStateStore.read({ ...ownerContext, sessionId: "ordinary-session" }))?.messages).toHaveLength(105);
|
||||
await sessionUiStateStore.write({ ...ownerContext, sessionId: "ordinary-session" }, {
|
||||
sessionId: "ordinary-session", messages: [{ type: "text", text: "owner-only answer" }],
|
||||
});
|
||||
expect((await getJson(address.port, "/api/v1/session/ordinary-session", "token-b", ownerContext.projectId)).status).toBe(404);
|
||||
expect((await getJson(address.port, "/api/v1/session/ordinary-session", "token-a", "10000000-0000-4000-8000-000000000002")).status).toBe(404);
|
||||
expect((await getJson(address.port, "/api/v1/session/ordinary-session", "token-a")).status).toBe(404);
|
||||
|
||||
@@ -63,6 +63,17 @@ describe("task publication bridge", () => {
|
||||
const report = { title: "报告", current_conclusion: "核验结束", conclusion_boundary: "授权数据范围",
|
||||
stage: "final", outcome: "concluded", stop_reason: null, data_gaps: [] };
|
||||
expect((await post("artifacts", { session_id: contexts[1], request_id: requestId, report })).status).toBe(200);
|
||||
const interactive = { ...report, evidence_refs: ["e1"], ui_blocks: [{ evidence_refs: ["e1"], block: {
|
||||
version: "answer-ui@1", block_id: "summary", fallback_text: "已保存的演练记录。", spec: {
|
||||
root: "text", elements: { text: { type: "Text", children: [], props: { text: "已保存的演练记录。", tone: "neutral" } } },
|
||||
},
|
||||
} }] };
|
||||
expect((await post("artifacts", { session_id: contexts[0], request_id: requestId, report: interactive })).status).toBe(200);
|
||||
expect(published.at(-1)?.args[2]).toMatchObject({ report: { ui_blocks: interactive.ui_blocks }, sourceSessionId: "client-0" });
|
||||
const count = published.length;
|
||||
expect((await post("artifacts", { session_id: contexts[0], request_id: requestId,
|
||||
report: { ...interactive, evidence_refs: [] } })).status).toBe(400);
|
||||
expect(published).toHaveLength(count);
|
||||
} finally {
|
||||
contexts.forEach(removeRuntimeSessionContext);
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { LOCAL_QUERY_TOOL_IDS } from "../../src/runtime/product-mcp-tools.js";
|
||||
import {
|
||||
LOCAL_QUERY_TOOL_IDS,
|
||||
PRODUCT_MCP_TOOL_IDS,
|
||||
} from "../../src/runtime/product-mcp-tools.js";
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import { type OpenCodeClient as OpencodeClient } from "@opencode/client";
|
||||
import { createServer } from "node:net";
|
||||
@@ -222,7 +225,7 @@ describe("resolveEmbeddedOpencodePort", () => {
|
||||
});
|
||||
|
||||
describe("OpencodeRuntimeAdapter.warmup", () => {
|
||||
const toolIds = [...LOCAL_QUERY_TOOL_IDS, "websearch", "shell", "final_answer", "present_answer", "locate_features", "zoom_to_map", "show_chart", "view_history", "memory_manager", "skill_manager", "render_scada_analysis", "clear_scada_analysis", "publish_execution_plan", "publish_task_report", "publish_task_progress", "publish_task_presentation", "propose_task_action", "add_task_human_evidence", "request_task_data_access", "todowrite", "send_task_message", "reply_agent_message"];
|
||||
const toolIds = [...PRODUCT_MCP_TOOL_IDS, ...LOCAL_QUERY_TOOL_IDS, "websearch", "shell", "final_answer", "present_answer", "locate_features", "zoom_to_map", "show_chart", "view_history", "memory_manager", "skill_manager", "render_scada_analysis", "clear_scada_analysis", "publish_execution_plan", "publish_task_report", "publish_task_progress", "publish_task_presentation", "propose_task_action", "add_task_human_evidence", "request_task_data_access", "todowrite", "send_task_message", "reply_agent_message"];
|
||||
async function createWarmupRuntime(missing: string[] = [], invalidModel?: string, delayedMcp = false,
|
||||
search: { providers?: readonly string[]; selection?: false | { provider: string }; unavailable?: boolean; extraTools?: readonly string[] } = {}) {
|
||||
const root = await mkdtemp(join(tmpdir(), "warmup-workspace-test-"));
|
||||
|
||||
@@ -21,8 +21,8 @@ describe("task agent isolation", () => {
|
||||
expect(config.agents[TASK_READER_AGENT].permissions.map((rule) => rule.action)).toEqual([
|
||||
"*", ...PRODUCT_MCP_READ_TOOL_IDS,
|
||||
]);
|
||||
expect(PRODUCT_MCP_READ_TOOL_IDS).toHaveLength(25);
|
||||
expect(PRODUCT_MCP_TOOL_IDS).toHaveLength(35);
|
||||
expect(PRODUCT_MCP_READ_TOOL_IDS).toHaveLength(42);
|
||||
expect(PRODUCT_MCP_TOOL_IDS).toHaveLength(67);
|
||||
expect(PRODUCT_MCP_TOOL_IDS.some((name) => /publish|approve|execute|shell/.test(name))).toBeFalse();
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import { HistoryCursorError, sessionHistoryPage, sessionMessagePage } from "../../src/sessions/history-page.js";
|
||||
|
||||
describe("scoped history pagination", () => {
|
||||
const records = Array.from({ length: 65 }, (_, i) => ({ sessionId: `session-${String(i).padStart(3, "0")}`,
|
||||
updatedAt: "2026-10-22T01:00:00Z", title: i === 0 ? "城南压力复核" : `对话 ${i}` }));
|
||||
it("continues equal timestamps deterministically and searches beyond the first page", () => {
|
||||
const first = sessionHistoryPage(records, { scope: "owner-project", limit: 30 });
|
||||
const second = sessionHistoryPage(records, { scope: "owner-project", limit: 30, cursor: first.next_cursor! });
|
||||
const last = sessionHistoryPage(records, { scope: "owner-project", limit: 30, cursor: second.next_cursor! });
|
||||
expect(first.sessions).toHaveLength(30);
|
||||
expect(last.has_more).toBe(false);
|
||||
expect(new Set([...first.sessions, ...second.sessions, ...last.sessions].map((item) => item.sessionId)).size).toBe(65);
|
||||
expect(sessionHistoryPage(records, { scope: "owner-project", limit: 30, q: "压力" }).sessions[0]?.sessionId).toBe("session-000");
|
||||
expect(() => sessionHistoryPage(records, { scope: "another-project", limit: 30, cursor: first.next_cursor! })).toThrow(HistoryCursorError);
|
||||
expect(() => sessionHistoryPage(records, { scope: "owner-project", q: "压力", limit: 30, cursor: first.next_cursor! })).toThrow(HistoryCursorError);
|
||||
});
|
||||
it("pages older messages without truncating the store or moving when messages arrive", () => {
|
||||
const messages = Array.from({ length: 105 }, (_, i) => ({ id: `message-${i}` }));
|
||||
const latest = sessionMessagePage(messages, { scope: "owner-session", limit: 40 });
|
||||
expect(latest.messages).toEqual(messages.slice(65));
|
||||
messages.push({ id: "message-105" });
|
||||
const older = sessionMessagePage(messages, { scope: "owner-session", limit: 40, before: latest.message_page.before_cursor! });
|
||||
expect(older.messages).toEqual(messages.slice(25, 65));
|
||||
const oldest = sessionMessagePage(messages, { scope: "owner-session", limit: 40, before: older.message_page.before_cursor! });
|
||||
expect(oldest.messages).toEqual(messages.slice(0, 25));
|
||||
expect(oldest.message_page.has_more).toBe(false);
|
||||
expect(messages).toHaveLength(106);
|
||||
expect(() => sessionMessagePage(messages, { scope: "another-session", limit: 40, before: latest.message_page.before_cursor! })).toThrow(HistoryCursorError);
|
||||
messages.splice(0, 1);
|
||||
expect(() => sessionMessagePage(messages, { scope: "owner-session", limit: 40, before: latest.message_page.before_cursor! })).toThrow("history changed");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user