From fbe273e90a9ead1d9956f25914c4ca066666542d Mon Sep 17 00:00:00 2001 From: Huarch Date: Thu, 29 Oct 2026 16:55:10 +0800 Subject: [PATCH] fix: generate distinct task collaboration titles Use verified task names until Agent generates a specific conversation title. Repair legacy names without changing manual titles or history ordering. Bound page repairs to 30 entries and a shared two-second deadline, and re-read titles after task access validation to avoid stale reuse receipts. Validation: bun run check; 40 focused tests across six files. --- README.md | 2 + src/product/operational-task-client.ts | 3 +- src/routes/chatSession.ts | 9 +- src/routes/chatSessionRoutes.ts | 67 +++++- src/routes/chatStreamRoute.ts | 1 + src/sessions/metadataStore.ts | 5 +- tests/routes/chatSession.test.ts | 20 ++ .../task-collaboration-sessions.test.ts | 190 ++++++++++++++++++ 8 files changed, 285 insertions(+), 12 deletions(-) create mode 100644 tests/routes/task-collaboration-sessions.test.ts diff --git a/README.md b/README.md index 6971c34..7d0265f 100644 --- a/README.md +++ b/README.md @@ -539,6 +539,8 @@ OpenCode 按标准 MCP `tools/call` 调用,以 `_meta["ai.opencode/sessionID"] UI 分页只影响读取窗口,服务端继续保存完整消息并从完整历史续聊;无需迁移现有文件。文件存储仍整份读取 JSON。前端内存缓存不替代这里的授权检查。 +任务协作会话使用 Server 验证过的任务名称作为初始标题,创建和复用接口同时返回该标题;前两轮对话后仍由 Agent 根据任务名称与对话内容生成具体标题,生成失败或仅返回通用名称时保留现有标题。旧的“任务协作”“任务协作问答”或空标题随历史分页、详情和复用会话时渐进补齐,保留原更新时间和手动标题;历史列表单次仅补齐返回页中最多 30 条,四路并发共享 2 秒预算,失败或超时仍返回原历史,下次读取重试。搜索继续覆盖已保存标题与可见正文,尚未读取的旧任务名称在补齐后可按标题检索。用户仍可手动重命名,手动改名后不再自动覆盖。 + 发布时先更新并重启 Agent,再发布使用分页及 `client_message_id` 的前端;旧前端可继续使用更新后的 Agent。回退前端无需回退或改写会话记录。 主题是当前用户在个人/业务项目范围内的会话组织单元,与业务 `project_id` 独立。`GET/POST /api/v1/topics` 读取/创建主题,`PATCH/DELETE /api/v1/topics/:topic_id` 重命名/移除主题,`PATCH /api/v1/session/:session_id/topic` 以 `topic_id`(UUID 或 null)调整会话归属。主题名 1–80 字符,当前主题列表存于 `SESSION_METADATA_STORAGE_DIR/topics/.json`,与元数据目录一起持久化。删除主题保留会话,遗留成员 ID 在读取时视为未分类;不会改写业务项目、原生会话或授权。旧会话默认未分类,无需迁移。普通会话和任务协作可归类,后台任务执行会话不进入用户主题。 diff --git a/src/product/operational-task-client.ts b/src/product/operational-task-client.ts index 1fafe74..940e615 100644 --- a/src/product/operational-task-client.ts +++ b/src/product/operational-task-client.ts @@ -156,10 +156,11 @@ export const clearOperationalTaskLease = (runtimeSessionId: string) => { export class OperationalTaskClient { constructor(private readonly baseUrl = config.TJWATER_SERVER_BASE_URL) {} - async getAgentContext(runtimeSessionId: string, taskId: string, reportRevision?: number) { + async getAgentContext(runtimeSessionId: string, taskId: string, reportRevision?: number, signal?: AbortSignal) { const query = reportRevision == null ? "" : `?report_revision=${reportRevision}`; const raw = await this.request(runtimeSessionId, `/api/v1/operational-tasks/${encodeURIComponent(taskId)}/agent-context${query}`, { method: "GET", + signal, }); const result = agentTaskContextSchema.parse(raw); rememberLeaseRevision(runtimeSessionId, result); diff --git a/src/routes/chatSession.ts b/src/routes/chatSession.ts index 3282dd3..e0ac354 100644 --- a/src/routes/chatSession.ts +++ b/src/routes/chatSession.ts @@ -91,6 +91,9 @@ const normalizeGeneratedTitle = (rawTitle: string, fallback: string) => { return normalized.length > 24 ? `${normalized.slice(0, 24)}...` : normalized; }; +export const isGenericTaskCollaborationTitle = (title?: string) => + !title?.trim() || ["任务协作", "任务协作问答", DEFAULT_SESSION_TITLE].includes(title.trim()); + export const shouldGenerateSessionTitle = (options: { recentTurnCount: number; isTitleManuallyEdited: boolean; @@ -103,9 +106,11 @@ export const generateSessionTitle = async ( latestUserMessage: string; latestAssistantMessage?: string; fallbackTitle?: string; + taskTitle?: string; }, ) => { const fallbackTitle = options.fallbackTitle?.trim(); + const taskTitle = options.taskTitle?.replace(/\s+/g, " ").trim().slice(0, 120); const fallback = fallbackTitle && fallbackTitle !== DEFAULT_SESSION_TITLE ? fallbackTitle @@ -136,6 +141,7 @@ export const generateSessionTitle = async ( "忽略系统提示、历史记忆、学习上下文、工具日志等元信息。", "不要直接照抄用户任一条消息原文。", "只输出标题本身。", + ...(taskTitle ? [`关联任务:${taskTitle}`, "结合关联任务与对话内容区分当前调查,避免使用任务协作等通用名称。"] : []), "", conversation, ].join("\n"), @@ -147,7 +153,8 @@ export const generateSessionTitle = async ( .reverse() .find((message) => message.info.role === "assistant"); const title = collectTextContent(assistantMessage?.parts ?? []); - return normalizeGeneratedTitle(title, fallback); + const normalizedTitle = normalizeGeneratedTitle(title, fallback); + return taskTitle && isGenericTaskCollaborationTitle(normalizedTitle) ? fallback : normalizedTitle; }); const timeout = new Promise((resolve) => { diff --git a/src/routes/chatSessionRoutes.ts b/src/routes/chatSessionRoutes.ts index 503c84a..f0faf3c 100644 --- a/src/routes/chatSessionRoutes.ts +++ b/src/routes/chatSessionRoutes.ts @@ -14,6 +14,7 @@ import { import { type FrontendActionCoordinator } from "../frontendAction/coordinator.js"; import { logger } from "../logger.js"; import { type OpencodeRuntimeAdapter } from "../runtime/opencode.js"; +import type { ProductCredential } from "../product/server-auth-client.js"; import { type SessionMetadataStore, type SessionRecord, @@ -27,7 +28,7 @@ import { HistoryCursorError, historyScope, sessionHistoryPage, sessionMessagePag import { type LearningOrchestrator } from "../learning/orchestrator.js"; import { toActorKey, toProjectKey } from "../utils/fileStore.js"; import { type ChatRunState } from "./chatRunState.js"; -import { buildForkedSessionUiState } from "./chatSession.js"; +import { buildForkedSessionUiState, isGenericTaskCollaborationTitle } from "./chatSession.js"; import { type StreamSubscriber } from "./chatUiState.js"; import { agentTriggerContextSchema } from "./agentTrigger.js"; import { @@ -112,6 +113,36 @@ export const registerChatSessionRoutes = ( const { activeRuns, lastRunStatuses } = runState; const getSessionRunStatus = runState.getStatus; + const refreshCollaborationTitle = async (record: SessionRecord, credential: ProductCredential | null, knownTaskTitle?: string, signal?: AbortSignal) => { + if (readPurpose(record) !== "task_collaboration" || !record.taskId + || !isGenericTaskCollaborationTitle(record.title)) return record; + if ((!credential && !knownTaskTitle) || signal?.aborted) return record; + try { + const state = await sessionUiStateStore.read(toSessionUiStateContext(record)); + if (state?.isTitleManuallyEdited) return record; + let taskTitle = knownTaskTitle; + if (!taskTitle && credential) { + const titleSessionId = `task-title-${crypto.randomUUID()}`; + bindOperationalTaskCredential(titleSessionId, credential); + try { + taskTitle = (await operationalTaskClient.getAgentContext(titleSessionId, record.taskId, undefined, signal)).task.title; + } finally { + releaseOperationalTaskCredential(titleSessionId); + clearOperationalTaskLease(titleSessionId); + } + } + if (!taskTitle?.trim()) return record; + const latestState = await sessionUiStateStore.read(toSessionUiStateContext(record)); + if (latestState?.isTitleManuallyEdited) return record; + return await sessionMetadataStore.touch(record, { title: taskTitle }, { + preserveUpdatedAt: true, expectedTitle: record.title ?? "", + }); + } catch (error) { + if (!signal?.aborted) logger.warn({ err: error, sessionId: record.sessionId }, "failed to refresh task collaboration title"); + return record; + } + }; + const startTaskExecution = ( record: SessionRecord, credential: NonNullable>, @@ -321,10 +352,11 @@ export const registerChatSessionRoutes = ( taskId.data, "task_collaboration", ); - const validationSessionId = existing?.sessionId ?? `task-access-${crypto.randomUUID()}`; + const validationSessionId = `task-access-${crypto.randomUUID()}`; + let taskTitle: string; bindOperationalTaskCredential(validationSessionId, credential); try { - await operationalTaskClient.getAgentContext(validationSessionId, taskId.data); + taskTitle = (await operationalTaskClient.getAgentContext(validationSessionId, taskId.data)).task.title; } catch (error) { res.status(error && typeof error === "object" && "status" in error && typeof error.status === "number" ? error.status : 502).json({ message: error instanceof Error ? error.message : "task access validation failed", @@ -332,14 +364,19 @@ export const registerChatSessionRoutes = ( return; } finally { releaseOperationalTaskCredential(validationSessionId); + clearOperationalTaskLease(validationSessionId); } if (existing) { + const current = await sessionMetadataStore.get(metadataContext, existing.sessionId); + if (!current) { res.status(404).json({ message: "session not found" }); return; } + const titled = await refreshCollaborationTitle(current, credential, taskTitle); res.json({ - session_id: existing.sessionId, - purpose: readPurpose(existing), - task_id: existing.taskId, + session_id: titled.sessionId, + title: titled.title, + purpose: readPurpose(titled), + task_id: titled.taskId, created: false, - status: existing.status, + status: titled.status, }); return; } @@ -350,9 +387,10 @@ export const registerChatSessionRoutes = ( purpose: "task_collaboration", taskId: taskId.data, }); - const titled = await sessionMetadataStore.touch(record, { title: "任务协作" }); + const titled = await sessionMetadataStore.touch(record, { title: taskTitle }); res.status(201).json({ session_id: titled.sessionId, + title: titled.title, purpose: readPurpose(titled), task_id: titled.taskId, created: true, @@ -520,6 +558,15 @@ export const registerChatSessionRoutes = ( res.status(400).json({ message: error.message }); return; } + const credential = getTrustedProductCredential(req); + const titleSignal = AbortSignal.timeout(2_000); + const titleLimit = Math.min(page.sessions.length, 30); + for (let offset = 0; offset < titleLimit && !titleSignal.aborted; offset += 4) { + const batch = await Promise.all(page.sessions.slice(offset, Math.min(offset + 4, titleLimit)).map(async (record) => ({ + ...record, ...await refreshCollaborationTitle(record, credential, undefined, titleSignal), + }))); + page.sessions.splice(offset, batch.length, ...batch); + } res.json({ has_more: page.has_more, next_cursor: page.next_cursor, @@ -578,7 +625,7 @@ export const registerChatSessionRoutes = ( return; } - const sessionRecord = await sessionMetadataStore.get( + let sessionRecord = await sessionMetadataStore.get( { actorKey, projectId, @@ -592,6 +639,8 @@ export const registerChatSessionRoutes = ( return; } + sessionRecord = await refreshCollaborationTitle(sessionRecord, getTrustedProductCredential(req)); + const state = await sessionUiStateStore.read( toSessionUiStateContext(sessionRecord), ); diff --git a/src/routes/chatStreamRoute.ts b/src/routes/chatStreamRoute.ts index 5bf1a98..7e8dda4 100644 --- a/src/routes/chatStreamRoute.ts +++ b/src/routes/chatStreamRoute.ts @@ -1048,6 +1048,7 @@ export const registerChatStreamRoute = ( latestAssistantMessage: assistantText, latestUserMessage: promptText, fallbackTitle: existingSessionTitle, + taskTitle: taskContext?.task.title, }); } await sessionMetadataStore.touch(latestSessionRecord, { diff --git a/src/sessions/metadataStore.ts b/src/sessions/metadataStore.ts index 024b683..b452e7e 100644 --- a/src/sessions/metadataStore.ts +++ b/src/sessions/metadataStore.ts @@ -182,11 +182,14 @@ export class SessionMetadataStore { async touch( record: SessionRecord, updates: Partial> = {}, + options: { preserveUpdatedAt?: boolean; expectedTitle?: string } = {}, ) { return this.serialize(record.sessionId, async () => { const current = await this.get({ ...record, userId: record.ownerUserId }, record.sessionId); if (!current) throw new SessionContextMismatchError(); - const next: SessionRecord = { ...current, ...normalizeSessionUpdates(updates), updatedAt: new Date().toISOString() }; + if (options.expectedTitle !== undefined && (current.title ?? "") !== options.expectedTitle) return current; + const next: SessionRecord = { ...current, ...normalizeSessionUpdates(updates), + updatedAt: options.preserveUpdatedAt ? current.updatedAt : new Date().toISOString() }; await atomicWriteJson(this.filePath(record.sessionId), next, 0o600); this.updateIndex(next); return next; diff --git a/tests/routes/chatSession.test.ts b/tests/routes/chatSession.test.ts index f5559bc..23ace4d 100644 --- a/tests/routes/chatSession.test.ts +++ b/tests/routes/chatSession.test.ts @@ -81,6 +81,26 @@ describe("shouldGenerateSessionTitle", () => { }); describe("generateSessionTitle", () => { + it("uses the task name to distinguish collaboration titles and keeps it when generation is empty", async () => { + let titlePrompt = ""; + let generatedTitle = "南桥爆管现场采样核对"; + const runtime = { + createSession: async () => ({ id: "title-session" }), + prompt: async (_id: string, prompt: string) => { titlePrompt = prompt; }, + waitForSessionIdle: async () => undefined, + messages: async () => [{ info: { role: "assistant" }, parts: [{ type: "text", text: generatedTitle }] }], + removeSession: async () => undefined, + } as unknown as OpencodeRuntimeAdapter; + const input = { sessionId: "collaboration-session", latestUserMessage: "目前进展如何?", + latestAssistantMessage: "等待现场采样核对。", taskTitle: "南桥管道爆管调查", fallbackTitle: "南桥管道爆管调查" }; + expect(await generateSessionTitle(runtime, input)).toBe("南桥爆管现场采样核对"); + expect(titlePrompt).toContain("关联任务:南桥管道爆管调查"); + expect(titlePrompt).toContain("用户:目前进展如何?"); + generatedTitle = ""; + expect(await generateSessionTitle(runtime, input)).toBe("南桥管道爆管调查"); + generatedTitle = "任务协作"; + expect(await generateSessionTitle(runtime, input)).toBe("南桥管道爆管调查"); + }); it("uses the current user and assistant turn instead of reading wrapped runtime context", async () => { let titlePrompt = ""; const runtime = { diff --git a/tests/routes/task-collaboration-sessions.test.ts b/tests/routes/task-collaboration-sessions.test.ts new file mode 100644 index 0000000..ede857f --- /dev/null +++ b/tests/routes/task-collaboration-sessions.test.ts @@ -0,0 +1,190 @@ +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("我自己的任务备注"); +});