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:
@@ -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),
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user