277 lines
14 KiB
TypeScript
277 lines
14 KiB
TypeScript
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;
|
|
const projectId = "10000000-0000-4000-8000-000000000001";
|
|
const firstTask = "50000000-0000-4000-8000-000000000001";
|
|
const secondTask = "50000000-0000-4000-8000-000000000002";
|
|
const firstRun = "70000000-0000-4000-8000-000000000001";
|
|
let root: string | undefined;
|
|
let server: Server | undefined;
|
|
|
|
afterEach(async () => {
|
|
globalThis.fetch = http;
|
|
if (server) {
|
|
server.closeAllConnections();
|
|
await new Promise<void>((resolve) => server!.close(() => resolve()));
|
|
server = undefined;
|
|
}
|
|
if (root) { await rm(root, { recursive: true, force: true }); root = undefined; }
|
|
});
|
|
|
|
async function fixture() {
|
|
root = await mkdtemp(join(tmpdir(), "tjwater-collaboration-titles-"));
|
|
const context = { actorKey: toActorKey("product:owner"), projectKey: toProjectKey(`product:${projectId}`), projectId, userId: "owner" };
|
|
const workspaces = new SessionWorkspaceStore(join(root, "workspaces"));
|
|
const sessions = new SessionMetadataStore(join(root, "metadata"), workspaces);
|
|
const states = new SessionUiStateStore(join(root, "states"), workspaces);
|
|
const taskNames = new Map([[firstTask, "南桥管道爆管调查"], [secondTask, "城南供水压力复核"]]);
|
|
const taskReads: string[] = [];
|
|
let available = true;
|
|
let onTaskRead: (() => Promise<unknown>) | undefined;
|
|
let waitForAbort = false;
|
|
let observedAbort = false;
|
|
globalThis.fetch = (async (input, init) => {
|
|
const path = new URL(String(input)).pathname;
|
|
const taskId = path.match(/\/operational-tasks\/([^/]+)\/agent-context$/)?.[1];
|
|
if (taskId) {
|
|
taskReads.push(taskId);
|
|
await onTaskRead?.();
|
|
if (waitForAbort) return await new Promise<Response>((_resolve, reject) => {
|
|
const signal = init?.signal;
|
|
if (!signal) { reject(new Error("expected title lookup cancellation signal")); return; }
|
|
const abort = () => { observedAbort = true; reject(new DOMException("aborted", "AbortError")); };
|
|
if (signal.aborted) abort(); else signal.addEventListener("abort", abort, { once: true });
|
|
});
|
|
if (!available) return Response.json({ message: "task context unavailable" }, { status: 503 });
|
|
return Response.json({ task: {
|
|
task_id: taskId, task_type: "event_investigation", workflow: "burst-report",
|
|
lifecycle_status: "waiting_human", active_run: null, status: "waiting_human", revision: 1,
|
|
execution_request_id: "60000000-0000-4000-8000-000000000001",
|
|
title: taskNames.get(taskId) ?? "其他现场调查", summary: "核对现场事实与模型结果",
|
|
latest_plan: null, latest_report: null, evidence: [], actions: [], permissions: {},
|
|
}, approved_data_scopes: [] });
|
|
}
|
|
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" });
|
|
}) as typeof fetch;
|
|
let created = 0;
|
|
const app = express();
|
|
app.use(express.json());
|
|
app.use("/api/v1", buildChatRouter({ sessionMetadataStore: sessions, sessionUiStateStore: states,
|
|
runtime: { createSession: async () => ({ id: `collaboration-${++created}` }) } 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 server address");
|
|
const request = async (path: string, method = "GET", user = "owner", body?: unknown) => {
|
|
const response = await http(`http://127.0.0.1:${address.port}/api/v1${path}`, {
|
|
method,
|
|
headers: {
|
|
Authorization: `Bearer ${user}`,
|
|
"X-Project-Id": projectId,
|
|
...(body === undefined ? {} : { "Content-Type": "application/json" }),
|
|
},
|
|
body: body === undefined ? undefined : JSON.stringify(body),
|
|
});
|
|
return { status: response.status, body: await response.json() as Record<string, any> };
|
|
};
|
|
const seed = async (sessionId: string, taskId: string, title: string, manuallyEdited = false) => {
|
|
const { record } = await sessions.ensure({ ...context, sessionId, taskId, purpose: "task_collaboration" });
|
|
const titled = await sessions.touch(record, { title });
|
|
await states.write({ ...context, sessionId }, { sessionId, messages: [], isTitleManuallyEdited: manuallyEdited });
|
|
return titled;
|
|
};
|
|
const seedExecution = async (sessionId: string, taskId: string, runId: string) => {
|
|
const workerContext = {
|
|
actorKey: toActorKey("service:operational-worker"),
|
|
projectKey: context.projectKey,
|
|
projectId,
|
|
userId: "operational-worker",
|
|
};
|
|
await sessions.ensure({ ...workerContext, sessionId, taskId, runId, purpose: "task_execution" });
|
|
await states.write({ ...workerContext, sessionId }, { sessionId, messages: [
|
|
{ id: "execution-user", role: "user", parts: [{ type: "text", text: "执行后台调查任务" }] },
|
|
{ id: "execution-assistant", role: "assistant", parts: [{ type: "text", text: "已完成压力与流量证据核对" }] },
|
|
] });
|
|
};
|
|
return { request, seed, seedExecution, sessions, context, taskReads, setAvailable: (value: boolean) => { available = value; },
|
|
setTaskReadHook: (hook: () => Promise<unknown>) => { onTaskRead = hook; },
|
|
waitForTitleAbort: () => { waitForAbort = true; }, didAbort: () => observedAbort };
|
|
}
|
|
|
|
it("names new collaboration sessions after their verified tasks", async () => {
|
|
const { request, taskReads } = await fixture();
|
|
const first = await request(`/task/${firstTask}/collaboration/session`, "POST");
|
|
const second = await request(`/task/${secondTask}/collaboration/session`, "POST");
|
|
expect(first.status).toBe(201);
|
|
expect(second.status).toBe(201);
|
|
expect(first.body.title).toBe("南桥管道爆管调查");
|
|
expect(second.body.title).toBe("城南供水压力复核");
|
|
const history = await request("/sessions?include_collaboration=true");
|
|
expect(history.body.sessions.map((item: { title: string }) => item.title).sort()).toEqual(["南桥管道爆管调查", "城南供水压力复核"]);
|
|
expect((await request(`/session/${first.body.session_id}`)).body.title).toBe("南桥管道爆管调查");
|
|
expect(taskReads).toHaveLength(2);
|
|
});
|
|
|
|
it("opens an exact task execution binding as a read-only cross-actor history", async () => {
|
|
const { request, seedExecution } = await fixture();
|
|
await seedExecution("worker-execution", firstTask, firstRun);
|
|
|
|
expect((await request("/session/worker-execution")).status).toBe(404);
|
|
const result = await request(`/task/${firstTask}/execution-session?run_id=${firstRun}&session_id=worker-execution`);
|
|
|
|
expect(result.status).toBe(200);
|
|
expect(result.body).toMatchObject({
|
|
session_id: "worker-execution",
|
|
title: "南桥管道爆管调查 · 执行记录",
|
|
purpose: "task_execution",
|
|
task_id: firstTask,
|
|
read_only: true,
|
|
is_streaming: false,
|
|
});
|
|
expect(result.body.messages.map((message: { id: string }) => message.id)).toEqual([
|
|
"execution-user",
|
|
"execution-assistant",
|
|
]);
|
|
expect((await request(`/task/${firstTask}/execution-session?run_id=${secondTask}&session_id=worker-execution`)).status).toBe(404);
|
|
expect((await request(`/task/${secondTask}/execution-session?run_id=${firstRun}&session_id=worker-execution`)).status).toBe(404);
|
|
});
|
|
|
|
it("creates one user-owned debug branch from an exact task execution history", async () => {
|
|
const { request, seedExecution } = await fixture();
|
|
await seedExecution("worker-execution", firstTask, firstRun);
|
|
|
|
const first = await request(`/task/${firstTask}/execution-session/debug`, "POST", "owner", {
|
|
run_id: firstRun,
|
|
session_id: "worker-execution",
|
|
});
|
|
|
|
expect(first.status).toBe(201);
|
|
expect(first.body).toMatchObject({
|
|
title: "南桥管道爆管调查 · 调试",
|
|
purpose: "task_collaboration",
|
|
task_id: firstTask,
|
|
parent_session_id: "worker-execution",
|
|
created: true,
|
|
});
|
|
const debug = await request(`/session/${first.body.session_id}`);
|
|
expect(debug.body.messages.map((message: { id: string }) => message.id)).toEqual([
|
|
"execution-user",
|
|
"execution-assistant",
|
|
]);
|
|
|
|
const reused = await request(`/task/${firstTask}/execution-session/debug`, "POST", "owner", {
|
|
run_id: firstRun,
|
|
session_id: "worker-execution",
|
|
});
|
|
expect(reused.status).toBe(200);
|
|
expect(reused.body).toMatchObject({ session_id: first.body.session_id, created: false });
|
|
});
|
|
|
|
it("validates current task access before revealing an execution history", async () => {
|
|
const { request, seedExecution, setAvailable } = await fixture();
|
|
await seedExecution("protected-execution", firstTask, firstRun);
|
|
setAvailable(false);
|
|
|
|
const result = await request(`/task/${firstTask}/execution-session?run_id=${firstRun}&session_id=protected-execution`);
|
|
|
|
expect(result.status).toBe(503);
|
|
expect(result.body.message).toBe("task context unavailable");
|
|
});
|
|
|
|
it("repairs returned legacy history without changing timestamps or custom names, then searches the repaired titles", async () => {
|
|
const { request, seed, sessions, context, taskReads } = await fixture();
|
|
const first = await seed("legacy-first", firstTask, "任务协作");
|
|
const second = await seed("legacy-second", secondTask, "任务协作问答");
|
|
await seed("custom", firstTask, "现场沟通记录");
|
|
await seed("manual-generic", secondTask, "任务协作", true);
|
|
await request("/sessions?include_collaboration=true");
|
|
const result = await request(`/sessions?include_collaboration=true&q=${encodeURIComponent("城南供水压力复核")}`);
|
|
expect(result.body.sessions.map((item: { id: string }) => item.id)).toEqual(["legacy-second"]);
|
|
expect(await sessions.get(context, first.sessionId)).toMatchObject({ title: "南桥管道爆管调查", updatedAt: first.updatedAt });
|
|
expect(await sessions.get(context, second.sessionId)).toMatchObject({ title: "城南供水压力复核", updatedAt: second.updatedAt });
|
|
expect((await sessions.get(context, "custom"))?.title).toBe("现场沟通记录");
|
|
expect((await sessions.get(context, "manual-generic"))?.title).toBe("任务协作");
|
|
expect(taskReads.sort()).toEqual([firstTask, secondTask]);
|
|
expect((await request("/sessions?include_collaboration=true", "GET", "other")).body.sessions).toEqual([]);
|
|
expect((await request("/session/legacy-first", "GET", "other")).status).toBe(404);
|
|
});
|
|
|
|
it("repairs only the returned history page and continues on the next page", async () => {
|
|
const { request, seed, taskReads } = await fixture();
|
|
for (let index = 0; index < 12; index += 1) await seed(`legacy-${index}`, firstTask, "任务协作");
|
|
const first = await request("/sessions?include_collaboration=true&limit=1");
|
|
expect(first.body.sessions).toHaveLength(1);
|
|
expect(first.body.sessions[0].title).toBe("南桥管道爆管调查");
|
|
expect(taskReads).toHaveLength(1);
|
|
const next = await request(`/sessions?include_collaboration=true&limit=1&cursor=${encodeURIComponent(first.body.next_cursor)}`);
|
|
expect(next.body.sessions[0].id).not.toBe(first.body.sessions[0].id);
|
|
expect(next.body.sessions[0].title).toBe("南桥管道爆管调查");
|
|
expect(taskReads).toHaveLength(2);
|
|
});
|
|
|
|
it("cancels slow title lookups within the history request budget", async () => {
|
|
const { request, seed, waitForTitleAbort, didAbort, taskReads } = await fixture();
|
|
await seed("slow-title", firstTask, "任务协作");
|
|
waitForTitleAbort();
|
|
const started = Date.now();
|
|
const result = await request("/sessions?include_collaboration=true&limit=1");
|
|
expect(result.status).toBe(200);
|
|
expect(result.body.sessions[0].title).toBe("任务协作");
|
|
expect(didAbort()).toBe(true);
|
|
expect(taskReads).toHaveLength(1);
|
|
expect(Date.now() - started).toBeLessThan(3_500);
|
|
});
|
|
|
|
it("returns the current title when it changes while task access is being validated", async () => {
|
|
const { request, seed, sessions, setTaskReadHook } = await fixture();
|
|
const record = await seed("changing-title", firstTask, "南桥管道爆管调查");
|
|
setTaskReadHook(() => sessions.touch(record, { title: "Agent生成的新标题" }));
|
|
const result = await request(`/task/${firstTask}/collaboration/session`, "POST");
|
|
expect(result.body.title).toBe("Agent生成的新标题");
|
|
});
|
|
|
|
it("repairs legacy sessions when loaded or reused and returns the updated title", async () => {
|
|
const { request, seed } = await fixture();
|
|
const first = await seed("legacy-load", firstTask, "任务协作");
|
|
await seed("legacy-reuse", secondTask, "任务协作问答");
|
|
const loaded = await request("/session/legacy-load");
|
|
expect(loaded.body.title).toBe("南桥管道爆管调查");
|
|
expect(loaded.body.updated_at).toBe(first.updatedAt);
|
|
const reused = await request(`/task/${secondTask}/collaboration/session`, "POST");
|
|
expect(reused.status).toBe(200);
|
|
expect(reused.body).toMatchObject({ session_id: "legacy-reuse", title: "城南供水压力复核", created: false });
|
|
});
|
|
|
|
it("keeps history readable during task API failures and retries title repair later", async () => {
|
|
const { request, seed, setAvailable } = await fixture();
|
|
await seed("legacy-unavailable", firstTask, "任务协作");
|
|
setAvailable(false);
|
|
const result = await request("/sessions?include_collaboration=true");
|
|
expect(result.status).toBe(200);
|
|
expect(result.body.sessions[0].title).toBe("任务协作");
|
|
setAvailable(true);
|
|
expect((await request("/sessions?include_collaboration=true")).body.sessions[0].title).toBe("南桥管道爆管调查");
|
|
});
|
|
|
|
it("does not overwrite a title changed after a legacy record was read", async () => {
|
|
const { seed, sessions, context } = await fixture();
|
|
const stale = await seed("renamed", firstTask, "任务协作");
|
|
await sessions.touch(stale, { title: "我自己的任务备注" });
|
|
await sessions.touch(stale, { title: "南桥管道爆管调查" }, { preserveUpdatedAt: true, expectedTitle: stale.title });
|
|
expect((await sessions.get(context, stale.sessionId))?.title).toBe("我自己的任务备注");
|
|
});
|