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.
This commit is contained in:
@@ -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/<scope-hash>.json`,与元数据目录一起持久化。删除主题保留会话,遗留成员 ID 在读取时视为未分类;不会改写业务项目、原生会话或授权。旧会话默认未分类,无需迁移。普通会话和任务协作可归类,后台任务执行会话不进入用户主题。
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<string>((resolve) => {
|
||||
|
||||
@@ -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<ReturnType<typeof getTrustedProductCredential>>,
|
||||
@@ -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),
|
||||
);
|
||||
|
||||
@@ -1048,6 +1048,7 @@ export const registerChatStreamRoute = (
|
||||
latestAssistantMessage: assistantText,
|
||||
latestUserMessage: promptText,
|
||||
fallbackTitle: existingSessionTitle,
|
||||
taskTitle: taskContext?.task.title,
|
||||
});
|
||||
}
|
||||
await sessionMetadataStore.touch(latestSessionRecord, {
|
||||
|
||||
@@ -182,11 +182,14 @@ export class SessionMetadataStore {
|
||||
async touch(
|
||||
record: SessionRecord,
|
||||
updates: Partial<Pick<SessionRecord, "title" | "status" | "topicId">> = {},
|
||||
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;
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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<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") => {
|
||||
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<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;
|
||||
};
|
||||
return { request, seed, 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("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("我自己的任务备注");
|
||||
});
|
||||
Reference in New Issue
Block a user