Files
makelore/src/lib/agent-session-sync.ts
brother7 16f5c57f17 fix: scope observation to current turn
Require assistant natural language after the latest user message so a completed prompt cannot reuse historical replies when queuing the observation snapshot.
2026-08-24 12:55:01 +08:00

431 lines
15 KiB
TypeScript

import { extractText } from '@/pages/Chat/message-utils';
import { useAuthStore } from '@/stores/auth';
import {
getProfileAccountKey,
isUserProfileComplete,
useUserProfileStore,
} from '@/stores/user-profile';
import type { RawMessage } from '@/types/chat';
import type { ConversationSnapshot } from '@/types/coding-conversation';
import type {
AgentSessionData,
AgentSessionMessage,
AgentSessionMessageRole,
} from '../../shared/agent-session';
const PENDING_AGENT_SESSION_SYNC_STORAGE_KEY = 'niancode-agent-session-sync-pending';
const SESSION_UPLOAD_DEBOUNCE_MS = 1_000;
const SESSION_UPLOAD_RETRY_DELAYS_MS = [5_000, 30_000, 120_000, 300_000];
type PendingUpload = {
userId: string;
data: AgentSessionData;
retryCount: number;
};
const pendingUploads = new Map<string, PendingUpload>();
const uploadTimers = new Map<string, ReturnType<typeof setTimeout>>();
const inFlightUploads = new Map<string, Promise<void>>();
let pendingUploadsHydrated = false;
const ANSI_ESCAPE_PATTERN = new RegExp(`${String.fromCharCode(27)}\\[[0-?]*[ -/]*[@-~]`, 'g');
const FENCED_CODE_PATTERN = /```[\s\S]*?(?:```|$)/g;
const TILDE_FENCED_CODE_PATTERN = /~~~[\s\S]*?(?:~~~|$)/g;
const INLINE_CODE_PATTERN = /`[^`\n]*`/g;
const MEDIA_MARKER_PATTERN = /\b(?:MEDIA|media):(?:\/|~\/)[^\s"'<>()[\]{},。;:!?、]+/g;
const ARTIFACT_URL_PATTERN = /https?:\/\/[^\s"'<>()[\]{},。;:!?、]+\.(?:png|jpe?g|gif|webp|bmp|avif|svg|pdf|docx?|xlsx?|pptx?|txt|csv|md|zip|tar|gz|rar|7z|mp3|wav|ogg|aac|flac|m4a|mp4|mov|avi|mkv|webm|m4v)(?:[?#][^\s"'<>()[\]{},。;:!?、]+)?/gi;
const ABSOLUTE_PATH_PATTERN = /(?<![A-Za-z0-9_])(?:~\/|\/(?:Users|home|private|tmp|var|opt|mnt|Volumes|Documents|Desktop|Downloads|workspace|workspaces|repo|project|src|dist|build)(?:\/|[^\s"'`<>()[\]{},。;:!?、])[^\s"'`<>()[\]{},。;:!?、]*|[A-Za-z]:[\\/][^\s"'`<>()[\]{},。;:!?、]*|\\\\[^\s"'`<>()[\]{},。;:!?、]+)/g;
const RELATIVE_PATH_PATTERN = /(?<![A-Za-z0-9_])(?:\.\.?[\\/]|(?:src|dist|build|public|app|lib|components|pages|tests|electron|shared|resources)[\\/])[^\s"'`<>()[\]{},。;:!?、]+/gi;
const SOURCE_FILE_NAME_PATTERN = /(?<![A-Za-z0-9_])[A-Za-z0-9_-]+\.(?:c|cc|cpp|cs|css|go|h|hpp|html?|java|js|json|jsx|kt|md|php|py|rb|rs|scss|sh|sql|swift|ts|tsx|vue|xml|yaml|yml)(?![A-Za-z0-9_])/gi;
const CODE_LIKE_LINE_PATTERN = /^\s*(?:#include\b|(?:const|let|var|function|class|interface|type|import|export|def|async\s+function)\b|(?:SELECT|INSERT\s+INTO|UPDATE\s+.+\s+SET|DELETE\s+FROM)\b)/;
function normalizeWhitespace(value: string): string {
return value
.replace(/[ \t]+\n/g, '\n')
.replace(/\n{3,}/g, '\n\n')
.trim();
}
function stripLogLines(value: string): string {
return value
.split('\n')
.filter((line) => {
const trimmed = line.trim();
if (!trimmed) return true;
return !(
/^\[(?:\d{4}-\d{2}-\d{2}[T ][^\]]+|(?:DEBUG|INFO|WARN|ERROR|TRACE)[^\]]*)\]/i.test(trimmed)
|| /^(?:DEBUG|INFO|WARN|ERROR|TRACE)\s*(?:\||:)/i.test(trimmed)
|| /^at\s+[^\s(]+\s*\([^)]*\)/.test(trimmed)
|| /^(?:GET|POST|PUT|PATCH|DELETE|OPTIONS|HEAD)\s+\//.test(trimmed)
);
})
.join('\n');
}
function stripCodeLikeLines(value: string): string {
return value
.split('\n')
.filter((line) => !CODE_LIKE_LINE_PATTERN.test(line))
.join('\n');
}
/**
* Keep only the natural-language part of a user/assistant message before it
* leaves the local workspace. Runtime metadata and tool parts are removed by
* the existing message projection; this second pass removes code, paths,
* logs, and artifact links from visible text as well.
*/
export function sanitizeAgentSessionText(value: string): string {
const withoutRuntimeArtifacts = value
.replace(ANSI_ESCAPE_PATTERN, '')
.replace(FENCED_CODE_PATTERN, '\n')
.replace(TILDE_FENCED_CODE_PATTERN, '\n')
.replace(INLINE_CODE_PATTERN, '')
.replace(MEDIA_MARKER_PATTERN, '')
.replace(ARTIFACT_URL_PATTERN, '')
.replace(ABSOLUTE_PATH_PATTERN, '')
.replace(RELATIVE_PATH_PATTERN, '')
.replace(SOURCE_FILE_NAME_PATTERN, '');
return normalizeWhitespace(stripCodeLikeLines(stripLogLines(withoutRuntimeArtifacts)));
}
function getMessageText(message: RawMessage): string {
// `extractText` keeps text blocks while ignoring thinking/tool/image parts;
// using the Codex final-answer projection here would incorrectly discard
// natural-language narration that appears before a tool call.
const visibleText = extractText(message);
return sanitizeAgentSessionText(visibleText);
}
function toIsoTimestamp(value: unknown): string | undefined {
if (typeof value === 'number' && Number.isFinite(value)) {
const milliseconds = Math.abs(value) < 1_000_000_000_000 ? value * 1_000 : value;
const date = new Date(milliseconds);
return Number.isNaN(date.getTime()) ? undefined : date.toISOString();
}
if (typeof value === 'string' && value.trim()) {
const date = new Date(value);
return Number.isNaN(date.getTime()) ? undefined : date.toISOString();
}
return undefined;
}
function getMessageTimestamp(message: RawMessage): string | undefined {
const record = message as RawMessage & {
createdAt?: unknown;
created_at?: unknown;
updatedAt?: unknown;
updated_at?: unknown;
};
return toIsoTimestamp(message.timestamp)
?? toIsoTimestamp(record.createdAt)
?? toIsoTimestamp(record.created_at)
?? toIsoTimestamp(record.updatedAt)
?? toIsoTimestamp(record.updated_at);
}
function getMessageId(message: RawMessage): string | undefined {
return typeof message.id === 'string' && message.id.trim()
? message.id.trim()
: undefined;
}
function isSyncableRole(role: RawMessage['role']): role is AgentSessionMessageRole {
return role === 'user' || role === 'assistant';
}
/** Build a wire-safe full snapshot for one locally completed session. */
export function buildAgentSessionData(
projectId: string,
sessionId: string,
messages: readonly RawMessage[],
updatedAt = new Date().toISOString(),
): AgentSessionData | null {
const normalizedProjectId = projectId.trim();
const normalizedSessionId = sessionId.trim();
if (!normalizedProjectId || !normalizedSessionId) return null;
const normalizedMessages: AgentSessionMessage[] = [];
const indexById = new Map<string, number>();
for (const message of messages) {
if (!isSyncableRole(message.role) || message.isError) continue;
const text = getMessageText(message);
if (!text) continue;
const next: AgentSessionMessage = {
...(getMessageId(message) ? { id: getMessageId(message) } : {}),
role: message.role,
...(getMessageTimestamp(message) ? { created_at: getMessageTimestamp(message) } : {}),
text,
};
const messageId = next.id;
if (messageId && indexById.has(messageId)) {
normalizedMessages[indexById.get(messageId)!] = next;
continue;
}
if (messageId) indexById.set(messageId, normalizedMessages.length);
normalizedMessages.push(next);
}
if (normalizedMessages.length === 0) return null;
return {
project_id: normalizedProjectId,
session_id: normalizedSessionId,
updated_at: updatedAt,
messages: normalizedMessages,
};
}
function getStorage(): Storage | null {
if (typeof window === 'undefined') return null;
try {
return window.localStorage;
} catch {
return null;
}
}
function isAgentSessionData(value: unknown): value is AgentSessionData {
if (!value || typeof value !== 'object' || Array.isArray(value)) return false;
const record = value as Record<string, unknown>;
if (
typeof record.project_id !== 'string'
|| typeof record.session_id !== 'string'
|| typeof record.updated_at !== 'string'
|| !Array.isArray(record.messages)
) return false;
return record.messages.every((message) => (
Boolean(message)
&& typeof message === 'object'
&& !Array.isArray(message)
&& ((message as Record<string, unknown>).role === 'user'
|| (message as Record<string, unknown>).role === 'assistant')
&& typeof (message as Record<string, unknown>).text === 'string'
));
}
function getCurrentAccountKey(): string | null {
const authUser = useAuthStore.getState().user;
return getProfileAccountKey(authUser?.userId ?? authUser?.username);
}
function getUploadKey(userId: string, data: AgentSessionData): string {
return `${userId}:${data.project_id}:${data.session_id}`;
}
function hydratePendingUploads(): void {
if (pendingUploadsHydrated) return;
pendingUploadsHydrated = true;
const storage = getStorage();
if (!storage) return;
try {
const parsed = JSON.parse(storage.getItem(PENDING_AGENT_SESSION_SYNC_STORAGE_KEY) ?? '[]') as unknown;
if (!Array.isArray(parsed)) return;
for (const value of parsed) {
const record = value && typeof value === 'object' && !Array.isArray(value)
? value as Record<string, unknown>
: null;
const userId = getProfileAccountKey(
typeof record?.userId === 'string' ? record.userId : undefined,
);
const data = record?.data;
// Queue entries are account-bound. Do not guess an owner for records
// written by an older build, otherwise a different login could receive
// another account's local conversation data.
if (!userId || !isAgentSessionData(data)) continue;
const retryCount = typeof record?.retryCount === 'number' && Number.isFinite(record.retryCount)
? Math.max(0, Math.floor(record.retryCount))
: 0;
pendingUploads.set(getUploadKey(userId, data), { userId, data, retryCount });
}
} catch {
// A malformed retry queue must not prevent the app from starting.
}
}
function persistPendingUploads(): void {
const storage = getStorage();
if (!storage) return;
try {
storage.setItem(
PENDING_AGENT_SESSION_SYNC_STORAGE_KEY,
JSON.stringify([...pendingUploads.values()]),
);
} catch {
// Quota/private-mode failures should not affect the active conversation.
}
}
function clearUploadTimer(key: string): void {
const timer = uploadTimers.get(key);
if (timer === undefined) return;
clearTimeout(timer);
uploadTimers.delete(key);
}
function scheduleUpload(key: string, delayMs: number): void {
clearUploadTimer(key);
uploadTimers.set(key, setTimeout(() => {
uploadTimers.delete(key);
void flushUpload(key);
}, delayMs));
}
function getRetryDelay(retryCount: number): number {
return SESSION_UPLOAD_RETRY_DELAYS_MS[
Math.min(Math.max(retryCount - 1, 0), SESSION_UPLOAD_RETRY_DELAYS_MS.length - 1)
];
}
async function uploadWithCurrentProfile(userId: string, data: AgentSessionData): Promise<void> {
if (getCurrentAccountKey() !== userId) {
throw new Error('Authenticated account changed during Agent session sync');
}
const accessToken = await useAuthStore.getState().getValidAccessToken();
if (!accessToken) throw new Error('No authenticated session for Agent session sync');
if (getCurrentAccountKey() !== userId) {
throw new Error('Authenticated account changed during Agent session sync');
}
const profile = useUserProfileStore.getState().profilesByUserId[userId];
// Session observation must never pull cloud profile data into the local
// workspace. The normal profile bootstrap owns that read; until it has
// produced a local profile, this upload remains queued for a retry.
if (!isUserProfileComplete(profile)) {
throw new Error('Agent profile is not complete');
}
await useUserProfileStore.getState().pushSessionData(userId, accessToken, data);
}
async function flushUpload(key: string): Promise<void> {
const existing = inFlightUploads.get(key);
if (existing) return existing;
const task = (async () => {
hydratePendingUploads();
const pending = pendingUploads.get(key);
if (!pending) return;
if (getCurrentAccountKey() !== pending.userId) return;
pendingUploads.delete(key);
persistPendingUploads();
try {
await uploadWithCurrentProfile(pending.userId, pending.data);
} catch {
if (!pendingUploads.has(key)) {
const retryCount = pending.retryCount + 1;
pendingUploads.set(key, { userId: pending.userId, data: pending.data, retryCount });
persistPendingUploads();
if (getCurrentAccountKey() === pending.userId) {
scheduleUpload(key, getRetryDelay(retryCount));
}
}
return;
}
if (pendingUploads.has(key)) {
scheduleUpload(key, SESSION_UPLOAD_DEBOUNCE_MS);
}
})();
inFlightUploads.set(key, task);
try {
await task;
} finally {
if (inFlightUploads.get(key) === task) inFlightUploads.delete(key);
}
}
function queueAgentSessionData(data: AgentSessionData): void {
const userId = getCurrentAccountKey();
if (!userId) return;
hydratePendingUploads();
const key = getUploadKey(userId, data);
pendingUploads.set(key, { userId, data, retryCount: 0 });
persistPendingUploads();
scheduleUpload(key, SESSION_UPLOAD_DEBOUNCE_MS);
}
/** Queue a completed local session snapshot without blocking the chat turn. */
export function queueAgentSessionSync(
projectId: string,
sessionId: string,
messages: readonly RawMessage[],
): void {
const data = buildAgentSessionData(projectId, sessionId, messages);
if (!data) return;
queueAgentSessionData(data);
}
/** Queue the final product Conversation snapshot after a completed prompt turn. */
export function queueCodingConversationSessionSync(snapshot: ConversationSnapshot): void {
const messageNodes = snapshot.nodes.filter((node) => node.kind === 'message');
let lastUserIndex = -1;
for (let index = messageNodes.length - 1; index >= 0; index -= 1) {
if (messageNodes[index].role === 'user') {
lastUserIndex = index;
break;
}
}
if (lastUserIndex < 0) return;
const currentTurnAssistantIds = new Set(messageNodes
.slice(lastUserIndex + 1)
.filter((node) => node.role === 'assistant')
.map((node) => node.id));
if (currentTurnAssistantIds.size === 0) return;
const messages: RawMessage[] = messageNodes.flatMap((node) => {
return [{
id: node.id,
role: node.role,
content: node.blocks.flatMap((block) => (
block.kind === 'text' ? [{ type: 'text', text: block.text }] : []
)),
isError: node.status === 'error' || node.status === 'aborted',
} satisfies RawMessage];
});
const settledAt = snapshot.run.settledAt;
const updatedAt = typeof settledAt === 'number' && Number.isFinite(settledAt)
? new Date(settledAt).toISOString()
: new Date().toISOString();
const data = buildAgentSessionData(
snapshot.conversation.projectId,
snapshot.conversation.id,
messages,
updatedAt,
);
if (!data?.messages.some((message) => (
message.role === 'assistant'
&& typeof message.id === 'string'
&& currentTurnAssistantIds.has(message.id)
))) return;
queueAgentSessionData(data);
}
/** Flush persisted session snapshots after login or profile bootstrap. */
export async function flushPendingAgentSessionSync(): Promise<void> {
hydratePendingUploads();
const userId = getCurrentAccountKey();
if (!userId) return;
const keys = [...pendingUploads.entries()]
.filter(([, pending]) => pending.userId === userId)
.map(([key]) => key);
await Promise.all(keys.map((key) => {
clearUploadTimer(key);
return flushUpload(key);
}));
}
/** Reset only in-memory sync state for isolated unit tests. */
export function resetAgentSessionSyncForTests(): void {
for (const timer of uploadTimers.values()) clearTimeout(timer);
uploadTimers.clear();
pendingUploads.clear();
inFlightUploads.clear();
pendingUploadsHydrated = false;
}