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"; let root: string | undefined; let server: Server | undefined; afterEach(async () => { globalThis.fetch = http; if (server) { server.closeAllConnections(); await new Promise((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) | 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((_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((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") => { const response = await http(`http://127.0.0.1:${address.port}/api/v1${path}`, { method, headers: { Authorization: `Bearer ${user}`, "X-Project-Id": projectId }, }); return { status: response.status, body: await response.json() as Record }; }; 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; }; return { request, seed, sessions, context, taskReads, setAvailable: (value: boolean) => { available = value; }, setTaskReadHook: (hook: () => Promise) => { 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("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("我自己的任务备注"); });