feat: add scoped history and artifacts
Generic Container CI/CD / test-build-publish (push) Failing after 8m25s
Agent CI/CD / build-test-publish-and-deploy (push) Failing after 8m25s

This commit is contained in:
2026-10-22 18:40:20 +08:00
parent bc4e9834e5
commit 7d47a2df86
48 changed files with 2632 additions and 78 deletions
+69
View File
@@ -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 });
}
});
+77
View File
@@ -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);
});
+52 -1
View File
@@ -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);
});
+22
View File
@@ -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()));