feat(team): merge live lead messages and repair transcript resolution
This commit is contained in:
parent
72f8d4e786
commit
78c6824d69
10 changed files with 1688 additions and 185 deletions
|
|
@ -49,6 +49,7 @@ https://github.com/user-attachments/assets/35e27989-726d-4059-8662-bae610e46b42
|
|||
## Installation
|
||||
|
||||
No prerequisites - the app can detect supported runtimes/providers and guide setup from the UI.
|
||||
If you want the freshest version, clone the repo and run it from the `dev` branch.
|
||||
|
||||
<table align="center">
|
||||
<tr>
|
||||
|
|
|
|||
|
|
@ -99,18 +99,19 @@ import * as path from 'path';
|
|||
import { ConfigManager } from '../services/infrastructure/ConfigManager';
|
||||
import { NotificationManager } from '../services/infrastructure/NotificationManager';
|
||||
import { gitIdentityResolver } from '../services/parsing/GitIdentityResolver';
|
||||
import {
|
||||
getAutoResumeService,
|
||||
initializeAutoResumeService,
|
||||
} from '../services/team/AutoResumeService';
|
||||
import {
|
||||
buildActionModeAgentBlock,
|
||||
isAgentActionMode,
|
||||
} from '../services/team/actionModeInstructions';
|
||||
import {
|
||||
getAutoResumeService,
|
||||
initializeAutoResumeService,
|
||||
} from '../services/team/AutoResumeService';
|
||||
import {
|
||||
buildReplaceMembersDiff,
|
||||
buildReplaceMembersSummaryMessage,
|
||||
} from '../services/team/memberUpdateNotifications';
|
||||
import { mergeLiveLeadProcessMessages } from '../services/team/mergeLiveLeadProcessMessages';
|
||||
import { TeamAttachmentStore } from '../services/team/TeamAttachmentStore';
|
||||
import { TeamMembersMetaStore } from '../services/team/TeamMembersMetaStore';
|
||||
import { TeamMetaStore } from '../services/team/TeamMetaStore';
|
||||
|
|
@ -156,11 +157,9 @@ import type {
|
|||
IpcResult,
|
||||
KanbanColumnId,
|
||||
LeadActivitySnapshot,
|
||||
LeadContextUsage,
|
||||
LeadContextUsageSnapshot,
|
||||
MemberFullStats,
|
||||
MemberLogSummary,
|
||||
MemberSpawnStatusEntry,
|
||||
MemberSpawnStatusesSnapshot,
|
||||
MessagesPage,
|
||||
SendMessageRequest,
|
||||
|
|
@ -823,82 +822,23 @@ async function handleGetData(
|
|||
checkApiErrorMessages(data.messages, tn, displayName, projectPath);
|
||||
return { success: true, data: { ...data, isAlive } };
|
||||
}
|
||||
|
||||
const normalizeText = (text: string): string => text.trim().replace(/\r\n/g, '\n');
|
||||
const isLeadThoughtLike = (msg: { source?: unknown; to?: string }): boolean =>
|
||||
!msg.to && (msg.source === 'lead_process' || msg.source === 'lead_session');
|
||||
const getLeadThoughtFingerprint = (msg: {
|
||||
from: string;
|
||||
text: string;
|
||||
leadSessionId?: string;
|
||||
}): string => `${msg.leadSessionId ?? ''}\0${msg.from}\0${normalizeText(msg.text)}`;
|
||||
|
||||
// Collect fingerprints only for thought-like lead messages. Include leadSessionId so a
|
||||
// repeated thought in a new session does not get collapsed into an old session's history.
|
||||
const existingTextFingerprints = new Set<string>();
|
||||
for (const msg of data.messages) {
|
||||
if (typeof msg.from !== 'string' || typeof msg.text !== 'string') continue;
|
||||
if (!isLeadThoughtLike(msg)) continue;
|
||||
existingTextFingerprints.add(getLeadThoughtFingerprint(msg));
|
||||
let merged = mergeLiveLeadProcessMessages(data.messages, live);
|
||||
if (data.messages.length >= 50) {
|
||||
try {
|
||||
const newestPage = await teamDataService.getMessagesPage(tn, {
|
||||
limit: 50,
|
||||
liveMessages: live,
|
||||
});
|
||||
merged = newestPage.messages;
|
||||
} catch (error) {
|
||||
logger.warn(
|
||||
`[teams:getData] failed to rebuild newest merged messages for ${tn}: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const keyFor = (m: {
|
||||
messageId?: string;
|
||||
timestamp: string;
|
||||
from: string;
|
||||
text: string;
|
||||
}): string => {
|
||||
if (typeof m.messageId === 'string' && m.messageId.trim().length > 0) {
|
||||
return m.messageId;
|
||||
}
|
||||
return `${m.timestamp}\0${m.from}\0${(m.text ?? '').slice(0, 80)}`;
|
||||
};
|
||||
|
||||
// Text-based fingerprints for live lead thoughts to catch duplicates with different
|
||||
// messageIds inside the same session (e.g. lead-turn-* re-emits).
|
||||
const leadProcessTextFingerprints = new Set<string>();
|
||||
|
||||
// Content-based dedup for SendMessage captures: Claude Code CLI and our
|
||||
// persistInboxMessage both write to inboxes/{member}.json, producing two entries
|
||||
// with identical content but different messageIds. Track content fingerprints
|
||||
// (from+to+text) with timestamps to collapse them within a 5-second window.
|
||||
const contentSeen = new Map<string, number>(); // fingerprint → timestamp ms
|
||||
|
||||
const merged: typeof data.messages = [];
|
||||
const seen = new Set<string>();
|
||||
for (const msg of [...data.messages, ...live]) {
|
||||
if ((msg as { source?: unknown }).source === 'lead_process' && !msg.to) {
|
||||
const fp = getLeadThoughtFingerprint(msg);
|
||||
// Skip if the same thought already exists in persisted history for the same session.
|
||||
if (existingTextFingerprints.has(fp)) {
|
||||
continue;
|
||||
}
|
||||
// Dedup live lead_process thoughts with the same text in the same session.
|
||||
if (leadProcessTextFingerprints.has(fp)) {
|
||||
continue;
|
||||
}
|
||||
leadProcessTextFingerprints.add(fp);
|
||||
}
|
||||
|
||||
// Content dedup for directed messages (SendMessage captures):
|
||||
// same from+to+text within 5 seconds = duplicate from CLI + our persist.
|
||||
if (typeof msg.to === 'string' && msg.to.trim().length > 0) {
|
||||
const contentFp = `${msg.from}\0${msg.to}\0${(msg.text ?? '').replace(/\s+/g, ' ').slice(0, 100)}`;
|
||||
const msgMs = Date.parse(msg.timestamp);
|
||||
const existingMs = contentSeen.get(contentFp);
|
||||
if (existingMs !== undefined && Math.abs(msgMs - existingMs) <= 5000) {
|
||||
continue; // duplicate within 5s window — skip
|
||||
}
|
||||
contentSeen.set(contentFp, msgMs);
|
||||
}
|
||||
|
||||
const key = keyFor(msg);
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
merged.push(msg);
|
||||
}
|
||||
merged.sort((a, b) => Date.parse(b.timestamp) - Date.parse(a.timestamp));
|
||||
|
||||
checkRateLimitMessages(merged, tn, displayName, projectPath, isAlive, currentLeadSessionId);
|
||||
checkApiErrorMessages(merged, tn, displayName, projectPath);
|
||||
return { success: true, data: { ...data, isAlive, messages: merged } };
|
||||
|
|
@ -1789,7 +1729,10 @@ async function handleGetMessagesPage(
|
|||
|
||||
return wrapTeamHandler('getMessagesPage', async () => {
|
||||
const service = getTeamDataService();
|
||||
return service.getMessagesPage(vTeam.value!, { beforeTimestamp, limit });
|
||||
const liveMessages = beforeTimestamp
|
||||
? undefined
|
||||
: getTeamProvisioningService().getLiveLeadProcessMessages(vTeam.value!);
|
||||
return service.getMessagesPage(vTeam.value!, { beforeTimestamp, limit, liveMessages });
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,12 +1,5 @@
|
|||
import { yieldToEventLoop } from '@main/utils/asyncYield';
|
||||
import {
|
||||
encodePath,
|
||||
extractBaseDir,
|
||||
getClaudeBasePath,
|
||||
getProjectsBasePath,
|
||||
getTasksBasePath,
|
||||
getTeamsBasePath,
|
||||
} from '@main/utils/pathDecoder';
|
||||
import { getClaudeBasePath, getTasksBasePath, getTeamsBasePath } from '@main/utils/pathDecoder';
|
||||
import { killProcessByPid } from '@main/utils/processKill';
|
||||
import {
|
||||
AGENT_BLOCK_CLOSE,
|
||||
|
|
@ -16,7 +9,7 @@ import {
|
|||
} from '@shared/constants/agentBlocks';
|
||||
import { getMemberColorByName } from '@shared/constants/memberColors';
|
||||
import { classifyIdleNotificationText } from '@shared/utils/idleNotificationSemantics';
|
||||
import { isLeadAgentType, isLeadMember } from '@shared/utils/leadDetection';
|
||||
import { isLeadMember } from '@shared/utils/leadDetection';
|
||||
import { createLogger } from '@shared/utils/logger';
|
||||
import { getKanbanColumnFromReviewState, normalizeReviewState } from '@shared/utils/reviewState';
|
||||
import { buildStandaloneSlashCommandMeta } from '@shared/utils/slashCommands';
|
||||
|
|
@ -39,6 +32,10 @@ import {
|
|||
} from './cache/LeadSessionParseCache';
|
||||
import { atomicWriteAsync } from './atomicWrite';
|
||||
import { extractLeadSessionMessagesFromJsonl } from './leadSessionMessageExtractor';
|
||||
import {
|
||||
getLiveLeadProcessMessageKey,
|
||||
mergeLiveLeadProcessMessages,
|
||||
} from './mergeLiveLeadProcessMessages';
|
||||
import { buildTaskChangePresenceDescriptor } from './taskChangePresenceUtils';
|
||||
import { TeamConfigReader } from './TeamConfigReader';
|
||||
import { TeamInboxReader } from './TeamInboxReader';
|
||||
|
|
@ -52,6 +49,7 @@ import { TeamSentMessagesStore } from './TeamSentMessagesStore';
|
|||
import { TeamTaskCommentNotificationJournal } from './TeamTaskCommentNotificationJournal';
|
||||
import { TeamTaskReader } from './TeamTaskReader';
|
||||
import { TeamTaskWriter } from './TeamTaskWriter';
|
||||
import { TeamTranscriptProjectResolver } from './TeamTranscriptProjectResolver';
|
||||
|
||||
import type { PersistedTaskChangePresenceIndex } from './cache/taskChangePresenceCacheTypes';
|
||||
import type { TaskChangePresenceRepository } from './cache/TaskChangePresenceRepository';
|
||||
|
|
@ -182,7 +180,10 @@ export class TeamDataService {
|
|||
private readonly taskCommentNotificationJournal: TeamTaskCommentNotificationJournal = new TeamTaskCommentNotificationJournal(),
|
||||
private readonly teamMetaStore: TeamMetaStore = new TeamMetaStore(),
|
||||
private memberRuntimeAdvisoryService: TeamMemberRuntimeAdvisoryService = new TeamMemberRuntimeAdvisoryService(),
|
||||
private readonly leadSessionParseCache: LeadSessionParseCache = new LeadSessionParseCache()
|
||||
private readonly leadSessionParseCache: LeadSessionParseCache = new LeadSessionParseCache(),
|
||||
private readonly projectResolver: TeamTranscriptProjectResolver = new TeamTranscriptProjectResolver(
|
||||
configReader
|
||||
)
|
||||
) {}
|
||||
|
||||
private getController(teamName: string): AgentTeamsController {
|
||||
|
|
@ -769,7 +770,7 @@ export class TeamDataService {
|
|||
label: 'leadTexts',
|
||||
createFallback: () => [],
|
||||
warningText: 'Lead session texts failed to load',
|
||||
load: () => this.extractLeadSessionTexts(config),
|
||||
load: () => this.extractLeadSessionTexts(teamName, config),
|
||||
})
|
||||
);
|
||||
|
||||
|
|
@ -1113,7 +1114,7 @@ export class TeamDataService {
|
|||
*/
|
||||
async getMessagesPage(
|
||||
teamName: string,
|
||||
options: { beforeTimestamp?: string; limit: number }
|
||||
options: { beforeTimestamp?: string; limit: number; liveMessages?: InboxMessage[] }
|
||||
): Promise<MessagesPage> {
|
||||
const config = await this.configReader.getConfig(teamName);
|
||||
if (!config) {
|
||||
|
|
@ -1125,7 +1126,7 @@ export class TeamDataService {
|
|||
|
||||
const [inboxMessages, leadTexts, sentMessages] = await Promise.all([
|
||||
this.inboxReader.getMessages(teamName).catch(() => [] as InboxMessage[]),
|
||||
this.extractLeadSessionTexts(config).catch(() => [] as InboxMessage[]),
|
||||
this.extractLeadSessionTexts(teamName, config).catch(() => [] as InboxMessage[]),
|
||||
this.sentMessagesStore.readMessages(teamName).catch(() => [] as InboxMessage[]),
|
||||
]);
|
||||
|
||||
|
|
@ -1190,6 +1191,11 @@ export class TeamDataService {
|
|||
return (a.messageId ?? '').localeCompare(b.messageId ?? '');
|
||||
});
|
||||
|
||||
const newestDurableMessages = messages;
|
||||
const durableMessageIndexByKey = new Map(
|
||||
newestDurableMessages.map((message, index) => [getLiveLeadProcessMessageKey(message), index])
|
||||
);
|
||||
|
||||
// Apply cursor filter. Cursor format: "timestamp|messageId" (compound)
|
||||
// to handle multiple messages sharing the same timestamp.
|
||||
if (options.beforeTimestamp) {
|
||||
|
|
@ -1212,7 +1218,54 @@ export class TeamDataService {
|
|||
const nextCursor =
|
||||
hasMore && lastMsg ? `${lastMsg.timestamp}|${lastMsg.messageId ?? ''}` : null;
|
||||
|
||||
return { messages: page, nextCursor, hasMore };
|
||||
if (options.beforeTimestamp || !options.liveMessages?.length) {
|
||||
return { messages: page, nextCursor, hasMore };
|
||||
}
|
||||
|
||||
// Merge live lead thoughts against the full durable newest-page history so we do not
|
||||
// re-introduce persisted thoughts that have simply paged off the first durable page.
|
||||
const displayMessages = mergeLiveLeadProcessMessages(
|
||||
newestDurableMessages,
|
||||
options.liveMessages
|
||||
).slice(0, options.limit);
|
||||
|
||||
if (displayMessages.length === 0) {
|
||||
return { messages: displayMessages, nextCursor: null, hasMore: false };
|
||||
}
|
||||
|
||||
let lastDurableDisplayed: InboxMessage | null = null;
|
||||
for (let index = displayMessages.length - 1; index >= 0; index -= 1) {
|
||||
const candidate = displayMessages[index];
|
||||
if (durableMessageIndexByKey.has(getLiveLeadProcessMessageKey(candidate))) {
|
||||
lastDurableDisplayed = candidate;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!lastDurableDisplayed) {
|
||||
const boundary = displayMessages[displayMessages.length - 1];
|
||||
return {
|
||||
messages: displayMessages,
|
||||
nextCursor:
|
||||
newestDurableMessages.length > 0
|
||||
? `${boundary.timestamp}|${boundary.messageId ?? ''}`
|
||||
: null,
|
||||
hasMore: newestDurableMessages.length > 0,
|
||||
};
|
||||
}
|
||||
|
||||
const durableIndex =
|
||||
durableMessageIndexByKey.get(getLiveLeadProcessMessageKey(lastDurableDisplayed)) ??
|
||||
Number.POSITIVE_INFINITY;
|
||||
const durableHasMore = durableIndex < newestDurableMessages.length - 1;
|
||||
|
||||
return {
|
||||
messages: displayMessages,
|
||||
nextCursor: durableHasMore
|
||||
? `${lastDurableDisplayed.timestamp}|${lastDurableDisplayed.messageId ?? ''}`
|
||||
: null,
|
||||
hasMore: durableHasMore,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -2614,37 +2667,20 @@ export class TeamDataService {
|
|||
}
|
||||
}
|
||||
|
||||
private getLeadProjectDirCandidates(projectPath: string): string[] {
|
||||
const projectId = encodePath(projectPath);
|
||||
const baseDir = extractBaseDir(projectId);
|
||||
const candidateDirs = [
|
||||
path.join(getProjectsBasePath(), baseDir),
|
||||
// Claude Code encodes underscores as hyphens in project directory names;
|
||||
// our encodePath only handles slashes. Try the underscore-to-hyphen variant.
|
||||
...(baseDir.includes('_')
|
||||
? [path.join(getProjectsBasePath(), baseDir.replace(/_/g, '-'))]
|
||||
: []),
|
||||
];
|
||||
|
||||
return [...new Set(candidateDirs)];
|
||||
}
|
||||
|
||||
private async getLeadSessionJsonlPaths(projectPath: string): Promise<Map<string, string>> {
|
||||
private async getLeadSessionJsonlPaths(projectDir: string): Promise<Map<string, string>> {
|
||||
const jsonlPaths = new Map<string, string>();
|
||||
for (const dirPath of this.getLeadProjectDirCandidates(projectPath)) {
|
||||
let entries: fs.Dirent[];
|
||||
try {
|
||||
entries = await fs.promises.readdir(dirPath, { withFileTypes: true });
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
let entries: fs.Dirent[];
|
||||
try {
|
||||
entries = await fs.promises.readdir(projectDir, { withFileTypes: true });
|
||||
} catch {
|
||||
return jsonlPaths;
|
||||
}
|
||||
|
||||
for (const entry of entries) {
|
||||
if (!entry.isFile() || !entry.name.endsWith('.jsonl')) continue;
|
||||
const sessionId = entry.name.slice(0, -'.jsonl'.length).trim();
|
||||
if (!sessionId || jsonlPaths.has(sessionId)) continue;
|
||||
jsonlPaths.set(sessionId, path.join(dirPath, entry.name));
|
||||
}
|
||||
for (const entry of entries) {
|
||||
if (!entry.isFile() || !entry.name.endsWith('.jsonl')) continue;
|
||||
const sessionId = entry.name.slice(0, -'.jsonl'.length).trim();
|
||||
if (!sessionId || jsonlPaths.has(sessionId)) continue;
|
||||
jsonlPaths.set(sessionId, path.join(projectDir, entry.name));
|
||||
}
|
||||
|
||||
return jsonlPaths;
|
||||
|
|
@ -2890,17 +2926,23 @@ export class TeamDataService {
|
|||
}
|
||||
}
|
||||
|
||||
private async extractLeadSessionTexts(config: TeamConfig): Promise<InboxMessage[]> {
|
||||
if (!config.projectPath) {
|
||||
private async extractLeadSessionTexts(
|
||||
teamName: string,
|
||||
config: TeamConfig
|
||||
): Promise<InboxMessage[]> {
|
||||
const transcriptContext = await this.projectResolver.getContext(teamName);
|
||||
if (!transcriptContext) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const leadName = config.members?.find((m) => isLeadMember(m))?.name ?? 'team-lead';
|
||||
const sessionIds = this.getRecentLeadSessionIds(config);
|
||||
const leadName =
|
||||
transcriptContext.config.members?.find((m) => isLeadMember(m))?.name ?? 'team-lead';
|
||||
const sessionIds = Array.from(
|
||||
new Set([...this.getRecentLeadSessionIds(config), ...transcriptContext.sessionIds])
|
||||
);
|
||||
if (sessionIds.length === 0) {
|
||||
return [];
|
||||
}
|
||||
const availableJsonlPaths = await this.getLeadSessionJsonlPaths(config.projectPath);
|
||||
const availableJsonlPaths = await this.getLeadSessionJsonlPaths(transcriptContext.projectDir);
|
||||
if (availableJsonlPaths.size === 0) {
|
||||
return [];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3109,17 +3109,14 @@ export class TeamProvisioningService {
|
|||
}
|
||||
|
||||
getLiveLeadProcessMessages(teamName: string): InboxMessage[] {
|
||||
const list = this.liveLeadProcessMessages.get(teamName) ?? [];
|
||||
const runId = this.getTrackedRunId(teamName);
|
||||
const sessionId = runId ? this.runs.get(runId)?.detectedSessionId : null;
|
||||
if (sessionId) {
|
||||
for (const message of list) {
|
||||
if (!message.leadSessionId && message.source === 'lead_process') {
|
||||
message.leadSessionId = sessionId;
|
||||
}
|
||||
}
|
||||
}
|
||||
return [...list];
|
||||
const detectedSessionId = runId ? (this.runs.get(runId)?.detectedSessionId ?? null) : null;
|
||||
|
||||
return (this.liveLeadProcessMessages.get(teamName) ?? []).map((message) =>
|
||||
!message.leadSessionId && detectedSessionId
|
||||
? { ...message, leadSessionId: detectedSessionId }
|
||||
: { ...message }
|
||||
);
|
||||
}
|
||||
|
||||
private pruneLiveLeadMessagesForCleanedRun(run: ProvisioningRun): void {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,12 @@
|
|||
import { encodePath, extractBaseDir, getProjectsBasePath } from '@main/utils/pathDecoder';
|
||||
import { extractCwd } from '@main/utils/jsonl';
|
||||
import {
|
||||
encodePath,
|
||||
extractBaseDir,
|
||||
getProjectsBasePath,
|
||||
getTeamsBasePath,
|
||||
} from '@main/utils/pathDecoder';
|
||||
import { atomicWriteAsync } from '@main/utils/atomicWrite';
|
||||
import { isLeadMember } from '@shared/utils/leadDetection';
|
||||
import { createLogger } from '@shared/utils/logger';
|
||||
import { createReadStream, type Dirent } from 'fs';
|
||||
import * as fs from 'fs/promises';
|
||||
|
|
@ -15,6 +23,33 @@ const SESSION_DISCOVERY_CACHE_TTL = 30_000;
|
|||
const TEAM_AFFINITY_SCAN_LINES = 40;
|
||||
const ROOT_DISCOVERY_CONCURRENCY = 12;
|
||||
|
||||
type ProjectEvidenceSource =
|
||||
| 'projectPath'
|
||||
| 'projectPathHistory'
|
||||
| 'leadCwd'
|
||||
| 'memberCwd'
|
||||
| 'projectsScan';
|
||||
|
||||
interface ProjectPathCandidate {
|
||||
projectPath: string;
|
||||
source: Exclude<ProjectEvidenceSource, 'projectsScan'>;
|
||||
}
|
||||
|
||||
interface ProjectDirCandidate {
|
||||
projectPath: string;
|
||||
projectDir: string;
|
||||
projectId: string;
|
||||
source: ProjectEvidenceSource;
|
||||
}
|
||||
|
||||
interface SessionProjectMatch extends ProjectDirCandidate {
|
||||
matchedSessionId: string;
|
||||
}
|
||||
|
||||
type ScannedSessionProjectMatch = Omit<SessionProjectMatch, 'projectPath'> & {
|
||||
projectPath?: string;
|
||||
};
|
||||
|
||||
function trimTrailingSlashes(value: string): string {
|
||||
let end = value.length;
|
||||
while (end > 0) {
|
||||
|
|
@ -32,6 +67,17 @@ function isSessionDirectoryName(name: string): boolean {
|
|||
return name !== 'memory' && !name.startsWith('.');
|
||||
}
|
||||
|
||||
function normalizeProjectPathCandidate(value: unknown): string | null {
|
||||
if (typeof value !== 'string') {
|
||||
return null;
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
return trimTrailingSlashes(trimmed);
|
||||
}
|
||||
|
||||
function extractTextContent(entry: Record<string, unknown>): string | null {
|
||||
if (typeof entry.content === 'string') {
|
||||
return entry.content;
|
||||
|
|
@ -71,6 +117,9 @@ function lineMentionsTeam(text: string, teamName: string): boolean {
|
|||
return false;
|
||||
}
|
||||
return (
|
||||
normalizedText.includes(`team name: ${normalizedTeam}`) ||
|
||||
normalizedText.includes(`team name "${normalizedTeam}"`) ||
|
||||
normalizedText.includes(`team name '${normalizedTeam}'`) ||
|
||||
normalizedText.includes(`on team "${normalizedTeam}"`) ||
|
||||
normalizedText.includes(`on team '${normalizedTeam}'`) ||
|
||||
normalizedText.includes(`team "${normalizedTeam}"`) ||
|
||||
|
|
@ -79,6 +128,28 @@ function lineMentionsTeam(text: string, teamName: string): boolean {
|
|||
);
|
||||
}
|
||||
|
||||
function entryContainsNestedTeamName(value: unknown, teamName: string, depth: number = 0): boolean {
|
||||
if (!value || depth > 8 || typeof value !== 'object') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
return value.some((item) => entryContainsNestedTeamName(item, teamName, depth + 1));
|
||||
}
|
||||
|
||||
const entry = value as Record<string, unknown>;
|
||||
if (typeof entry.teamName === 'string' && entry.teamName.trim().toLowerCase() === teamName) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return Object.entries(entry).some(([key, nested]) => {
|
||||
if (key === 'teamName') {
|
||||
return false;
|
||||
}
|
||||
return entryContainsNestedTeamName(nested, teamName, depth + 1);
|
||||
});
|
||||
}
|
||||
|
||||
function collectKnownSessionIds(config: TeamConfig): string[] {
|
||||
const knownSessionIds = new Set<string>();
|
||||
const push = (value: unknown): void => {
|
||||
|
|
@ -93,7 +164,8 @@ function collectKnownSessionIds(config: TeamConfig): string[] {
|
|||
|
||||
push(config.leadSessionId);
|
||||
if (Array.isArray(config.sessionHistory)) {
|
||||
for (const sessionId of config.sessionHistory) {
|
||||
for (let index = config.sessionHistory.length - 1; index >= 0; index -= 1) {
|
||||
const sessionId = config.sessionHistory[index];
|
||||
push(sessionId);
|
||||
}
|
||||
}
|
||||
|
|
@ -130,13 +202,39 @@ export class TeamTranscriptProjectResolver {
|
|||
}
|
||||
|
||||
const config = await this.configReader.getConfig(teamName);
|
||||
if (!config?.projectPath) {
|
||||
if (!config) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { projectDir, projectId } = await this.resolveProjectDirectory(config);
|
||||
const sessionIds = await this.discoverSessionIds(teamName, projectDir, config);
|
||||
const value = { projectDir, projectId, config, sessionIds };
|
||||
const resolution = await this.resolveProjectDirectory(teamName, config);
|
||||
if (!resolution) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const resolvedConfig =
|
||||
resolution.effectiveProjectPath &&
|
||||
trimTrailingSlashes(resolution.effectiveProjectPath) !==
|
||||
trimTrailingSlashes(config.projectPath ?? '')
|
||||
? {
|
||||
...config,
|
||||
projectPath: resolution.effectiveProjectPath,
|
||||
projectPathHistory: this.buildRepairedProjectPathHistory(
|
||||
config,
|
||||
resolution.effectiveProjectPath
|
||||
),
|
||||
}
|
||||
: config;
|
||||
const sessionIds = await this.discoverSessionIds(
|
||||
teamName,
|
||||
resolution.projectDir,
|
||||
resolvedConfig
|
||||
);
|
||||
const value = {
|
||||
projectDir: resolution.projectDir,
|
||||
projectId: resolution.projectId,
|
||||
config: resolvedConfig,
|
||||
sessionIds,
|
||||
};
|
||||
this.contextCache.set(teamName, {
|
||||
value,
|
||||
expiresAt: Date.now() + SESSION_DISCOVERY_CACHE_TTL,
|
||||
|
|
@ -145,47 +243,391 @@ export class TeamTranscriptProjectResolver {
|
|||
}
|
||||
|
||||
private async resolveProjectDirectory(
|
||||
teamName: string,
|
||||
config: TeamConfig
|
||||
): Promise<{ projectDir: string; projectId: string }> {
|
||||
const normalizedProjectPath = trimTrailingSlashes(config.projectPath ?? '');
|
||||
let projectId = encodePath(normalizedProjectPath);
|
||||
let projectDir = path.join(getProjectsBasePath(), extractBaseDir(projectId));
|
||||
): Promise<{ projectDir: string; projectId: string; effectiveProjectPath?: string } | null> {
|
||||
const sessionIds = collectKnownSessionIds(config);
|
||||
const pathCandidates = this.collectProjectPathCandidates(config);
|
||||
const currentCandidate = pathCandidates[0] ?? null;
|
||||
if (sessionIds.length === 0) {
|
||||
return this.buildFallbackResolution(teamName, pathCandidates);
|
||||
}
|
||||
|
||||
try {
|
||||
const stat = await fs.stat(projectDir);
|
||||
if (!stat.isDirectory()) {
|
||||
throw new Error('not a directory');
|
||||
}
|
||||
return { projectDir, projectId };
|
||||
} catch {
|
||||
const leadSessionId =
|
||||
typeof config.leadSessionId === 'string' && config.leadSessionId.trim().length > 0
|
||||
? config.leadSessionId.trim()
|
||||
: null;
|
||||
if (!leadSessionId) {
|
||||
return { projectDir, projectId };
|
||||
}
|
||||
const rankBySessionId = new Map(sessionIds.map((sessionId, index) => [sessionId, index]));
|
||||
const getMatchRank = (match: { matchedSessionId: string } | null): number =>
|
||||
match
|
||||
? (rankBySessionId.get(match.matchedSessionId) ?? Number.POSITIVE_INFINITY)
|
||||
: Number.POSITIVE_INFINITY;
|
||||
|
||||
try {
|
||||
const projectEntries = await fs.readdir(getProjectsBasePath(), { withFileTypes: true });
|
||||
for (const entry of projectEntries) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
const candidateDir = path.join(getProjectsBasePath(), entry.name);
|
||||
try {
|
||||
await fs.access(path.join(candidateDir, `${leadSessionId}.jsonl`));
|
||||
projectDir = candidateDir;
|
||||
projectId = entry.name;
|
||||
break;
|
||||
} catch {
|
||||
// not this project
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// best-effort fallback
|
||||
const toResolution = (
|
||||
match: Pick<ProjectDirCandidate, 'projectDir' | 'projectId'> & { projectPath?: string }
|
||||
): { projectDir: string; projectId: string; effectiveProjectPath?: string } => ({
|
||||
projectDir: match.projectDir,
|
||||
projectId: match.projectId,
|
||||
...(match.projectPath ? { effectiveProjectPath: match.projectPath } : {}),
|
||||
});
|
||||
|
||||
let currentMatch: SessionProjectMatch | null = null;
|
||||
if (currentCandidate) {
|
||||
const resolvedCurrentMatch = await this.findMatchInProjectPathCandidate(
|
||||
currentCandidate,
|
||||
sessionIds
|
||||
);
|
||||
if (resolvedCurrentMatch && getMatchRank(resolvedCurrentMatch) === 0) {
|
||||
return toResolution(resolvedCurrentMatch);
|
||||
}
|
||||
if (resolvedCurrentMatch) {
|
||||
currentMatch = resolvedCurrentMatch;
|
||||
}
|
||||
}
|
||||
|
||||
return { projectDir, projectId };
|
||||
const configuredMatches =
|
||||
pathCandidates.length > 1
|
||||
? await this.findMatchesInProjectPathCandidates(pathCandidates.slice(1), sessionIds)
|
||||
: [];
|
||||
const scannedMatches = await this.findMatchesByScanningProjects(sessionIds);
|
||||
|
||||
const candidateMatchesByProjectDir = new Map<
|
||||
string,
|
||||
SessionProjectMatch | ScannedSessionProjectMatch
|
||||
>();
|
||||
for (const match of configuredMatches) {
|
||||
if (match.projectDir === currentMatch?.projectDir) {
|
||||
continue;
|
||||
}
|
||||
candidateMatchesByProjectDir.set(match.projectDir, match);
|
||||
}
|
||||
for (const match of scannedMatches) {
|
||||
if (match.projectDir === currentMatch?.projectDir) {
|
||||
continue;
|
||||
}
|
||||
if (!candidateMatchesByProjectDir.has(match.projectDir)) {
|
||||
candidateMatchesByProjectDir.set(match.projectDir, match);
|
||||
}
|
||||
}
|
||||
|
||||
const alternateMatches = [...candidateMatchesByProjectDir.values()];
|
||||
const bestAlternateRank = alternateMatches.reduce(
|
||||
(best, match) => Math.min(best, getMatchRank(match)),
|
||||
Number.POSITIVE_INFINITY
|
||||
);
|
||||
const currentRank = getMatchRank(currentMatch);
|
||||
|
||||
if (currentMatch && currentRank <= bestAlternateRank) {
|
||||
return toResolution(currentMatch);
|
||||
}
|
||||
|
||||
if (bestAlternateRank !== Number.POSITIVE_INFINITY) {
|
||||
const bestAlternates = alternateMatches.filter(
|
||||
(match) => getMatchRank(match) === bestAlternateRank
|
||||
);
|
||||
if (bestAlternates.length === 1) {
|
||||
const winner = bestAlternates[0];
|
||||
if (winner.projectPath) {
|
||||
await this.persistResolvedProjectPath(teamName, config, winner.projectPath);
|
||||
}
|
||||
return toResolution(winner);
|
||||
}
|
||||
logger.warn(
|
||||
`[${teamName}] Transcript project resolution ambiguous across exact-session candidates; keeping current path`
|
||||
);
|
||||
return currentMatch
|
||||
? toResolution(currentMatch)
|
||||
: this.buildFallbackResolution(teamName, pathCandidates);
|
||||
}
|
||||
|
||||
if (currentMatch) {
|
||||
return toResolution(currentMatch);
|
||||
}
|
||||
|
||||
return this.buildFallbackResolution(teamName, pathCandidates);
|
||||
}
|
||||
|
||||
private async buildFallbackResolution(
|
||||
teamName: string,
|
||||
candidates: readonly ProjectPathCandidate[]
|
||||
): Promise<{ projectDir: string; projectId: string; effectiveProjectPath?: string } | null> {
|
||||
let firstResolution: {
|
||||
projectDir: string;
|
||||
projectId: string;
|
||||
effectiveProjectPath?: string;
|
||||
} | null = null;
|
||||
let firstExistingResolution: {
|
||||
projectDir: string;
|
||||
projectId: string;
|
||||
effectiveProjectPath?: string;
|
||||
} | null = null;
|
||||
|
||||
for (const candidate of candidates) {
|
||||
for (const dirCandidate of this.buildProjectDirCandidates(candidate.projectPath)) {
|
||||
const resolution = {
|
||||
projectDir: dirCandidate.projectDir,
|
||||
projectId: dirCandidate.projectId,
|
||||
effectiveProjectPath: candidate.projectPath,
|
||||
};
|
||||
if (!firstResolution) {
|
||||
firstResolution = resolution;
|
||||
}
|
||||
if (!(await this.projectDirExists(dirCandidate.projectDir))) {
|
||||
continue;
|
||||
}
|
||||
if (!firstExistingResolution) {
|
||||
firstExistingResolution = resolution;
|
||||
}
|
||||
const teamRootSessionIds = await this.listTeamRootSessionIds(
|
||||
dirCandidate.projectDir,
|
||||
teamName
|
||||
);
|
||||
if (teamRootSessionIds.length > 0) {
|
||||
return resolution;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return firstExistingResolution ?? firstResolution;
|
||||
}
|
||||
|
||||
private collectProjectPathCandidates(config: TeamConfig): ProjectPathCandidate[] {
|
||||
const candidates: ProjectPathCandidate[] = [];
|
||||
const seen = new Set<string>();
|
||||
const push = (value: unknown, source: Exclude<ProjectEvidenceSource, 'projectsScan'>): void => {
|
||||
const normalized = normalizeProjectPathCandidate(value);
|
||||
if (!normalized || seen.has(normalized)) {
|
||||
return;
|
||||
}
|
||||
seen.add(normalized);
|
||||
candidates.push({ projectPath: normalized, source });
|
||||
};
|
||||
|
||||
push(config.projectPath, 'projectPath');
|
||||
|
||||
if (Array.isArray(config.projectPathHistory)) {
|
||||
for (let index = config.projectPathHistory.length - 1; index >= 0; index -= 1) {
|
||||
push(config.projectPathHistory[index], 'projectPathHistory');
|
||||
}
|
||||
}
|
||||
|
||||
const leadCwd = (config.members ?? []).find((member) => isLeadMember(member))?.cwd;
|
||||
push(leadCwd, 'leadCwd');
|
||||
|
||||
const distinctMemberCwds = Array.from(
|
||||
new Set(
|
||||
(config.members ?? [])
|
||||
.map((member) => normalizeProjectPathCandidate(member.cwd))
|
||||
.filter((cwd): cwd is string => Boolean(cwd))
|
||||
)
|
||||
);
|
||||
if (distinctMemberCwds.length === 1) {
|
||||
push(distinctMemberCwds[0], 'memberCwd');
|
||||
}
|
||||
|
||||
return candidates;
|
||||
}
|
||||
|
||||
private buildProjectDirCandidates(projectPath: string): ProjectDirCandidate[] {
|
||||
const normalizedProjectPath = trimTrailingSlashes(projectPath);
|
||||
const projectId = extractBaseDir(encodePath(normalizedProjectPath));
|
||||
const baseCandidates = [
|
||||
{ projectDir: path.join(getProjectsBasePath(), projectId), projectId },
|
||||
...(projectId.includes('_')
|
||||
? [
|
||||
{
|
||||
projectDir: path.join(getProjectsBasePath(), projectId.replace(/_/g, '-')),
|
||||
projectId: projectId.replace(/_/g, '-'),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
];
|
||||
|
||||
const seen = new Set<string>();
|
||||
return baseCandidates
|
||||
.filter((candidate) => {
|
||||
if (seen.has(candidate.projectDir)) {
|
||||
return false;
|
||||
}
|
||||
seen.add(candidate.projectDir);
|
||||
return true;
|
||||
})
|
||||
.map((candidate) => ({
|
||||
projectPath: normalizedProjectPath,
|
||||
projectDir: candidate.projectDir,
|
||||
projectId: candidate.projectId,
|
||||
source: 'projectPath' as const,
|
||||
}));
|
||||
}
|
||||
|
||||
private async findMatchInProjectPathCandidate(
|
||||
candidate: ProjectPathCandidate,
|
||||
sessionIds: string[]
|
||||
): Promise<SessionProjectMatch | null> {
|
||||
const rankBySessionId = new Map(sessionIds.map((sessionId, index) => [sessionId, index]));
|
||||
let bestMatch: SessionProjectMatch | null = null;
|
||||
|
||||
for (const projectCandidate of this.buildProjectDirCandidates(candidate.projectPath)) {
|
||||
const matchedSessionId = await this.findMatchingSessionId(
|
||||
projectCandidate.projectDir,
|
||||
sessionIds
|
||||
);
|
||||
if (!matchedSessionId) {
|
||||
continue;
|
||||
}
|
||||
const match = {
|
||||
...projectCandidate,
|
||||
source: candidate.source,
|
||||
matchedSessionId,
|
||||
};
|
||||
const matchRank = rankBySessionId.get(match.matchedSessionId) ?? Number.POSITIVE_INFINITY;
|
||||
const bestRank = bestMatch
|
||||
? (rankBySessionId.get(bestMatch.matchedSessionId) ?? Number.POSITIVE_INFINITY)
|
||||
: Number.POSITIVE_INFINITY;
|
||||
if (!bestMatch || matchRank < bestRank) {
|
||||
bestMatch = match;
|
||||
}
|
||||
if (matchRank === 0) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return bestMatch;
|
||||
}
|
||||
|
||||
private async findMatchesInProjectPathCandidates(
|
||||
candidates: ProjectPathCandidate[],
|
||||
sessionIds: string[]
|
||||
): Promise<SessionProjectMatch[]> {
|
||||
const matches: SessionProjectMatch[] = [];
|
||||
const seenProjectDirs = new Set<string>();
|
||||
for (const candidate of candidates) {
|
||||
const match = await this.findMatchInProjectPathCandidate(candidate, sessionIds);
|
||||
if (!match || seenProjectDirs.has(match.projectDir)) {
|
||||
continue;
|
||||
}
|
||||
seenProjectDirs.add(match.projectDir);
|
||||
matches.push(match);
|
||||
}
|
||||
return matches;
|
||||
}
|
||||
|
||||
private async findMatchingSessionId(
|
||||
projectDir: string,
|
||||
sessionIds: string[]
|
||||
): Promise<string | null> {
|
||||
for (const sessionId of sessionIds) {
|
||||
try {
|
||||
const stat = await fs.stat(path.join(projectDir, `${sessionId}.jsonl`));
|
||||
if (stat.isFile()) {
|
||||
return sessionId;
|
||||
}
|
||||
} catch {
|
||||
// continue
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private async findMatchesByScanningProjects(
|
||||
sessionIds: string[]
|
||||
): Promise<ScannedSessionProjectMatch[]> {
|
||||
let projectEntries: Dirent[];
|
||||
try {
|
||||
projectEntries = await fs.readdir(getProjectsBasePath(), { withFileTypes: true });
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
|
||||
const directories = projectEntries.filter((entry) => entry.isDirectory());
|
||||
const matches: ScannedSessionProjectMatch[] = [];
|
||||
let nextIndex = 0;
|
||||
|
||||
const worker = async (): Promise<void> => {
|
||||
while (nextIndex < directories.length) {
|
||||
const index = nextIndex++;
|
||||
const entry = directories[index];
|
||||
const projectDir = path.join(getProjectsBasePath(), entry.name);
|
||||
const matchedSessionId = await this.findMatchingSessionId(projectDir, sessionIds);
|
||||
if (!matchedSessionId) {
|
||||
continue;
|
||||
}
|
||||
const jsonlPath = path.join(projectDir, `${matchedSessionId}.jsonl`);
|
||||
const cwd = await extractCwd(jsonlPath);
|
||||
matches.push({
|
||||
projectPath: cwd ?? undefined,
|
||||
projectDir,
|
||||
projectId: entry.name,
|
||||
source: 'projectsScan',
|
||||
matchedSessionId,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
await Promise.all(
|
||||
Array.from({ length: Math.min(ROOT_DISCOVERY_CONCURRENCY, directories.length) }, () =>
|
||||
worker()
|
||||
)
|
||||
);
|
||||
|
||||
const deduped = new Map<string, ScannedSessionProjectMatch>();
|
||||
for (const match of matches) {
|
||||
if (!deduped.has(match.projectDir)) {
|
||||
deduped.set(match.projectDir, match);
|
||||
}
|
||||
}
|
||||
return [...deduped.values()];
|
||||
}
|
||||
|
||||
private async persistResolvedProjectPath(
|
||||
teamName: string,
|
||||
config: TeamConfig,
|
||||
nextProjectPath: string
|
||||
): Promise<void> {
|
||||
const normalizedNextPath = normalizeProjectPathCandidate(nextProjectPath);
|
||||
if (!normalizedNextPath) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentProjectPath = normalizeProjectPathCandidate(config.projectPath);
|
||||
if (currentProjectPath === normalizedNextPath) {
|
||||
return;
|
||||
}
|
||||
|
||||
const configPath = path.join(getTeamsBasePath(), teamName, 'config.json');
|
||||
try {
|
||||
const raw = await fs.readFile(configPath, 'utf8');
|
||||
const parsed = JSON.parse(raw) as Record<string, unknown>;
|
||||
const rawProjectPath =
|
||||
normalizeProjectPathCandidate(parsed.projectPath) ?? currentProjectPath ?? null;
|
||||
|
||||
parsed.projectPath = normalizedNextPath;
|
||||
|
||||
const history: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
const pushHistory = (value: unknown): void => {
|
||||
const normalized = normalizeProjectPathCandidate(value);
|
||||
if (!normalized || normalized === normalizedNextPath || seen.has(normalized)) {
|
||||
return;
|
||||
}
|
||||
seen.add(normalized);
|
||||
history.push(normalized);
|
||||
};
|
||||
|
||||
if (Array.isArray(parsed.projectPathHistory)) {
|
||||
for (const value of parsed.projectPathHistory) {
|
||||
pushHistory(value);
|
||||
}
|
||||
}
|
||||
pushHistory(rawProjectPath);
|
||||
|
||||
parsed.projectPathHistory = history.slice(-500);
|
||||
await atomicWriteAsync(configPath, JSON.stringify(parsed, null, 2));
|
||||
logger.info(
|
||||
`[${teamName}] Repaired transcript projectPath via exact session match: ${normalizedNextPath}`
|
||||
);
|
||||
} catch (error) {
|
||||
logger.warn(
|
||||
`[${teamName}] Failed to persist repaired transcript projectPath: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async discoverSessionIds(
|
||||
|
|
@ -199,9 +641,58 @@ export class TeamTranscriptProjectResolver {
|
|||
this.listSessionDirIds(projectDir),
|
||||
]);
|
||||
|
||||
return Array.from(new Set([...knownSessionIds, ...teamRootSessionIds, ...sessionDirIds])).sort(
|
||||
(left, right) => left.localeCompare(right)
|
||||
);
|
||||
const orderedSessionIds: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
const push = (sessionId: string): void => {
|
||||
if (seen.has(sessionId)) {
|
||||
return;
|
||||
}
|
||||
seen.add(sessionId);
|
||||
orderedSessionIds.push(sessionId);
|
||||
};
|
||||
|
||||
for (const sessionId of knownSessionIds) {
|
||||
push(sessionId);
|
||||
}
|
||||
for (const sessionId of [...teamRootSessionIds, ...sessionDirIds].sort((left, right) =>
|
||||
left.localeCompare(right)
|
||||
)) {
|
||||
push(sessionId);
|
||||
}
|
||||
|
||||
return orderedSessionIds;
|
||||
}
|
||||
|
||||
private buildRepairedProjectPathHistory(config: TeamConfig, nextProjectPath: string): string[] {
|
||||
const normalizedNextPath = normalizeProjectPathCandidate(nextProjectPath);
|
||||
const history: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
const pushHistory = (value: unknown): void => {
|
||||
const normalized = normalizeProjectPathCandidate(value);
|
||||
if (!normalized || normalized === normalizedNextPath || seen.has(normalized)) {
|
||||
return;
|
||||
}
|
||||
seen.add(normalized);
|
||||
history.push(normalized);
|
||||
};
|
||||
|
||||
if (Array.isArray(config.projectPathHistory)) {
|
||||
for (const value of config.projectPathHistory) {
|
||||
pushHistory(value);
|
||||
}
|
||||
}
|
||||
pushHistory(config.projectPath);
|
||||
|
||||
return history.slice(-500);
|
||||
}
|
||||
|
||||
private async projectDirExists(projectDir: string): Promise<boolean> {
|
||||
try {
|
||||
const stat = await fs.stat(projectDir);
|
||||
return stat.isDirectory();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private async listSessionDirIds(projectDir: string): Promise<string[]> {
|
||||
|
|
@ -272,6 +763,9 @@ export class TeamTranscriptProjectResolver {
|
|||
if (directTeamName === normalizedTeam) {
|
||||
return true;
|
||||
}
|
||||
if (entryContainsNestedTeamName(entry, normalizedTeam)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const textContent = extractTextContent(entry);
|
||||
if (textContent && lineMentionsTeam(textContent, normalizedTeam)) {
|
||||
|
|
|
|||
73
src/main/services/team/mergeLiveLeadProcessMessages.ts
Normal file
73
src/main/services/team/mergeLiveLeadProcessMessages.ts
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
import type { InboxMessage } from '@shared/types';
|
||||
|
||||
export function getLiveLeadProcessMessageKey(message: {
|
||||
messageId?: string;
|
||||
timestamp: string;
|
||||
from: string;
|
||||
text: string;
|
||||
}): string {
|
||||
if (typeof message.messageId === 'string' && message.messageId.trim().length > 0) {
|
||||
return message.messageId;
|
||||
}
|
||||
return `${message.timestamp}\0${message.from}\0${(message.text ?? '').slice(0, 80)}`;
|
||||
}
|
||||
|
||||
export function mergeLiveLeadProcessMessages(
|
||||
durableMessages: InboxMessage[],
|
||||
liveMessages: InboxMessage[]
|
||||
): InboxMessage[] {
|
||||
if (liveMessages.length === 0) {
|
||||
return durableMessages;
|
||||
}
|
||||
|
||||
const normalizeText = (text: string): string => text.trim().replace(/\r\n/g, '\n');
|
||||
const isLeadThoughtLike = (msg: { source?: unknown; to?: string }): boolean =>
|
||||
!msg.to && (msg.source === 'lead_process' || msg.source === 'lead_session');
|
||||
const getLeadThoughtFingerprint = (msg: {
|
||||
from: string;
|
||||
text: string;
|
||||
leadSessionId?: string;
|
||||
}): string => `${msg.leadSessionId ?? ''}\0${msg.from}\0${normalizeText(msg.text)}`;
|
||||
|
||||
const existingTextFingerprints = new Set<string>();
|
||||
for (const msg of durableMessages) {
|
||||
if (typeof msg.from !== 'string' || typeof msg.text !== 'string') continue;
|
||||
if (!isLeadThoughtLike(msg)) continue;
|
||||
existingTextFingerprints.add(getLeadThoughtFingerprint(msg));
|
||||
}
|
||||
|
||||
const leadProcessTextFingerprints = new Set<string>();
|
||||
const contentSeen = new Map<string, number>();
|
||||
const merged: InboxMessage[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
for (const msg of [...durableMessages, ...liveMessages]) {
|
||||
if (msg.source === 'lead_process' && !msg.to) {
|
||||
const fp = getLeadThoughtFingerprint(msg);
|
||||
if (existingTextFingerprints.has(fp) || leadProcessTextFingerprints.has(fp)) {
|
||||
continue;
|
||||
}
|
||||
leadProcessTextFingerprints.add(fp);
|
||||
}
|
||||
|
||||
if (typeof msg.to === 'string' && msg.to.trim().length > 0) {
|
||||
const contentFp = `${msg.from}\0${msg.to}\0${(msg.text ?? '').replace(/\s+/g, ' ').slice(0, 100)}`;
|
||||
const msgMs = Date.parse(msg.timestamp);
|
||||
const existingMs = contentSeen.get(contentFp);
|
||||
if (existingMs !== undefined && Math.abs(msgMs - existingMs) <= 5000) {
|
||||
continue;
|
||||
}
|
||||
contentSeen.set(contentFp, msgMs);
|
||||
}
|
||||
|
||||
const key = getLiveLeadProcessMessageKey(msg);
|
||||
if (seen.has(key)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(key);
|
||||
merged.push(msg);
|
||||
}
|
||||
|
||||
merged.sort((left, right) => Date.parse(right.timestamp) - Date.parse(left.timestamp));
|
||||
return merged;
|
||||
}
|
||||
|
|
@ -7,6 +7,7 @@ import type {
|
|||
BoardTaskExactLogDetailResult,
|
||||
BoardTaskExactLogSummariesResponse,
|
||||
InboxMessage,
|
||||
MessagesPage,
|
||||
TeamCreateRequest,
|
||||
TeamProvisioningProgress,
|
||||
} from '@shared/types/team';
|
||||
|
|
@ -80,6 +81,7 @@ import {
|
|||
TEAM_GET_TASK_EXACT_LOG_SUMMARIES,
|
||||
TEAM_GET_MEMBER_LOGS,
|
||||
TEAM_GET_MEMBER_STATS,
|
||||
TEAM_GET_MESSAGES_PAGE,
|
||||
TEAM_START_TASK,
|
||||
TEAM_UPDATE_CONFIG,
|
||||
TEAM_UPDATE_KANBAN,
|
||||
|
|
@ -140,6 +142,11 @@ describe('ipc teams handlers', () => {
|
|||
kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} },
|
||||
processes: [],
|
||||
})),
|
||||
getMessagesPage: vi.fn(async (..._args: unknown[]): Promise<MessagesPage> => ({
|
||||
messages: [] as InboxMessage[],
|
||||
nextCursor: null,
|
||||
hasMore: false,
|
||||
})),
|
||||
getTaskChangePresence: vi.fn(async () => ({ 'task-1': 'has_changes' })),
|
||||
reconcileTeamArtifacts: vi.fn(async () => undefined),
|
||||
setTaskChangePresenceTracking: vi.fn(() => undefined),
|
||||
|
|
@ -1279,6 +1286,197 @@ describe('ipc teams handlers', () => {
|
|||
}
|
||||
});
|
||||
|
||||
it('rebuilds capped newest messages through getMessagesPage so live duplicates do not leak back in', async () => {
|
||||
service.getTeamData.mockResolvedValueOnce({
|
||||
teamName: 'my-team',
|
||||
config: { name: 'My Team' },
|
||||
tasks: [],
|
||||
members: [],
|
||||
messages: Array.from({ length: 50 }, (_, index) => ({
|
||||
from: 'alice',
|
||||
text: `filler-${index}`,
|
||||
timestamp: `2026-02-23T10:${String(index).padStart(2, '0')}:00.000Z`,
|
||||
read: true,
|
||||
source: 'inbox' as const,
|
||||
messageId: `durable-${index}`,
|
||||
})),
|
||||
kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} },
|
||||
processes: [],
|
||||
});
|
||||
service.getMessagesPage.mockResolvedValueOnce({
|
||||
messages: [
|
||||
{
|
||||
from: 'alice',
|
||||
text: 'filler-0',
|
||||
timestamp: '2026-02-23T10:00:00.000Z',
|
||||
read: true,
|
||||
source: 'inbox' as const,
|
||||
messageId: 'durable-0',
|
||||
},
|
||||
],
|
||||
nextCursor: null,
|
||||
hasMore: false,
|
||||
});
|
||||
provisioningService.getLiveLeadProcessMessages.mockReturnValueOnce([
|
||||
{
|
||||
from: 'team-lead',
|
||||
text: 'Already persisted thought',
|
||||
timestamp: '2026-02-23T11:00:00.000Z',
|
||||
read: true,
|
||||
source: 'lead_process' as const,
|
||||
messageId: 'live-dup',
|
||||
leadSessionId: 'lead-1',
|
||||
},
|
||||
]);
|
||||
|
||||
const getDataHandler = handlers.get(TEAM_GET_DATA)!;
|
||||
const result = (await getDataHandler({} as never, 'my-team')) as {
|
||||
success: boolean;
|
||||
data: { messages: InboxMessage[] };
|
||||
};
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(service.getMessagesPage).toHaveBeenCalledWith('my-team', {
|
||||
limit: 50,
|
||||
liveMessages: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
messageId: 'live-dup',
|
||||
source: 'lead_process',
|
||||
}),
|
||||
]),
|
||||
});
|
||||
expect(result.data.messages.map((message) => message.messageId)).toEqual(['durable-0']);
|
||||
});
|
||||
|
||||
it('overlays live lead_process messages onto the newest messages page', async () => {
|
||||
service.getMessagesPage.mockImplementationOnce(async (...args: unknown[]) => {
|
||||
const { liveMessages = [] } = (args[1] ?? {}) as { liveMessages?: InboxMessage[] };
|
||||
return {
|
||||
messages: [
|
||||
{
|
||||
from: 'user',
|
||||
text: 'Ping',
|
||||
timestamp: '2026-02-23T10:00:00.000Z',
|
||||
read: true,
|
||||
source: 'user_sent' as const,
|
||||
messageId: 'durable-1',
|
||||
},
|
||||
...liveMessages,
|
||||
].sort((left, right) => Date.parse(right.timestamp) - Date.parse(left.timestamp)),
|
||||
nextCursor: '2026-02-23T10:00:00.000Z|durable-1',
|
||||
hasMore: true,
|
||||
};
|
||||
});
|
||||
provisioningService.getLiveLeadProcessMessages.mockReturnValueOnce([
|
||||
{
|
||||
from: 'team-lead',
|
||||
text: 'Команда поднята, приступаю к раздаче задач.',
|
||||
timestamp: '2026-02-23T10:00:01.000Z',
|
||||
read: true,
|
||||
source: 'lead_process' as const,
|
||||
messageId: 'live-1',
|
||||
},
|
||||
]);
|
||||
|
||||
const result = (await handlers.get(TEAM_GET_MESSAGES_PAGE)!({} as never, 'my-team', {
|
||||
limit: 20,
|
||||
})) as {
|
||||
success: boolean;
|
||||
data: { messages: InboxMessage[]; nextCursor: string | null; hasMore: boolean };
|
||||
};
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.data.messages).toHaveLength(2);
|
||||
expect(result.data.messages[0]?.source).toBe('lead_process');
|
||||
expect(result.data.messages[0]?.text).toBe('Команда поднята, приступаю к раздаче задач.');
|
||||
expect(result.data.nextCursor).toBe('2026-02-23T10:00:00.000Z|durable-1');
|
||||
expect(result.data.hasMore).toBe(true);
|
||||
expect(service.getMessagesPage).toHaveBeenCalledWith('my-team', {
|
||||
limit: 20,
|
||||
beforeTimestamp: undefined,
|
||||
liveMessages: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
source: 'lead_process',
|
||||
messageId: 'live-1',
|
||||
}),
|
||||
]),
|
||||
});
|
||||
});
|
||||
|
||||
it('dedups live lead thoughts on the newest messages page when durable lead_session already exists', async () => {
|
||||
service.getMessagesPage.mockImplementationOnce(async (...args: unknown[]) => {
|
||||
const { liveMessages = [] } = (args[1] ?? {}) as { liveMessages?: InboxMessage[] };
|
||||
expect(liveMessages).toHaveLength(1);
|
||||
return {
|
||||
messages: [
|
||||
{
|
||||
from: 'team-lead',
|
||||
text: 'Hello there',
|
||||
timestamp: '2026-02-23T10:00:00.000Z',
|
||||
read: true,
|
||||
source: 'lead_session' as const,
|
||||
leadSessionId: 'lead-1',
|
||||
messageId: 'durable-1',
|
||||
},
|
||||
],
|
||||
nextCursor: null,
|
||||
hasMore: false,
|
||||
};
|
||||
});
|
||||
provisioningService.getLiveLeadProcessMessages.mockReturnValueOnce([
|
||||
{
|
||||
from: 'team-lead',
|
||||
text: 'Hello there',
|
||||
timestamp: '2026-02-23T10:00:01.000Z',
|
||||
read: true,
|
||||
source: 'lead_process' as const,
|
||||
leadSessionId: 'lead-1',
|
||||
messageId: 'live-1',
|
||||
},
|
||||
]);
|
||||
|
||||
const result = (await handlers.get(TEAM_GET_MESSAGES_PAGE)!({} as never, 'my-team', {
|
||||
limit: 20,
|
||||
})) as {
|
||||
success: boolean;
|
||||
data: { messages: InboxMessage[] };
|
||||
};
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.data.messages).toHaveLength(1);
|
||||
expect(result.data.messages[0]?.source).toBe('lead_session');
|
||||
});
|
||||
|
||||
it('does not overlay live lead_process messages onto older paginated pages', async () => {
|
||||
service.getMessagesPage.mockResolvedValueOnce({
|
||||
messages: [
|
||||
{
|
||||
from: 'user',
|
||||
text: 'Older durable message',
|
||||
timestamp: '2026-02-23T09:59:00.000Z',
|
||||
read: true,
|
||||
source: 'user_sent' as const,
|
||||
messageId: 'durable-older-1',
|
||||
},
|
||||
],
|
||||
nextCursor: null,
|
||||
hasMore: false,
|
||||
});
|
||||
|
||||
const result = (await handlers.get(TEAM_GET_MESSAGES_PAGE)!({} as never, 'my-team', {
|
||||
limit: 20,
|
||||
beforeTimestamp: '2026-02-23T10:00:00.000Z|cursor',
|
||||
})) as {
|
||||
success: boolean;
|
||||
data: { messages: InboxMessage[] };
|
||||
};
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(provisioningService.getLiveLeadProcessMessages).not.toHaveBeenCalled();
|
||||
expect(result.data.messages).toHaveLength(1);
|
||||
expect(result.data.messages[0]?.messageId).toBe('durable-older-1');
|
||||
});
|
||||
|
||||
it('keeps TEAM_GET_DATA read-only and never triggers reconcile side effects', async () => {
|
||||
const getDataHandler = handlers.get(TEAM_GET_DATA)!;
|
||||
const result = (await getDataHandler({} as never, 'my-team')) as {
|
||||
|
|
|
|||
|
|
@ -1,10 +1,12 @@
|
|||
import * as nodeFs from 'fs';
|
||||
import * as fs from 'fs/promises';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { setClaudeBasePathOverride } from '../../../../src/main/utils/pathDecoder';
|
||||
import { encodePath, setClaudeBasePathOverride } from '../../../../src/main/utils/pathDecoder';
|
||||
import { TeamConfigReader } from '../../../../src/main/services/team/TeamConfigReader';
|
||||
import { buildTaskChangePresenceDescriptor } from '../../../../src/main/services/team/taskChangePresenceUtils';
|
||||
import { TeamDataService } from '../../../../src/main/services/team/TeamDataService';
|
||||
|
||||
|
|
@ -81,6 +83,102 @@ async function createTempJsonlInNamedDir(
|
|||
return jsonlPath;
|
||||
}
|
||||
|
||||
async function createResolverBackedLeadFixture(options?: {
|
||||
teamName?: string;
|
||||
staleProjectPath?: string;
|
||||
actualProjectPath?: string;
|
||||
leadSessionId?: string;
|
||||
sessionHistory?: string[];
|
||||
sessionFileId?: string;
|
||||
}): Promise<{
|
||||
claudeRoot: string;
|
||||
teamName: string;
|
||||
configPath: string;
|
||||
staleProjectPath: string;
|
||||
actualProjectPath: string;
|
||||
actualProjectDir: string;
|
||||
}> {
|
||||
const teamName = options?.teamName ?? 'my-team';
|
||||
const staleProjectPath = options?.staleProjectPath ?? '/Users/test/hookplex';
|
||||
const actualProjectPath = options?.actualProjectPath ?? '/Users/test/plugin-kit-ai';
|
||||
const leadSessionId = options?.leadSessionId ?? 'lead-1';
|
||||
const sessionFileId = options?.sessionFileId ?? leadSessionId;
|
||||
const claudeRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'team-data-resolver-backed-'));
|
||||
tempPaths.push(claudeRoot);
|
||||
setClaudeBasePathOverride(claudeRoot);
|
||||
|
||||
await fs.mkdir(path.join(claudeRoot, 'teams', teamName), { recursive: true });
|
||||
await fs.mkdir(path.join(claudeRoot, 'projects', encodePath(staleProjectPath)), {
|
||||
recursive: true,
|
||||
});
|
||||
|
||||
const configPath = path.join(claudeRoot, 'teams', teamName, 'config.json');
|
||||
await fs.writeFile(
|
||||
configPath,
|
||||
JSON.stringify(
|
||||
{
|
||||
name: 'My Team',
|
||||
projectPath: staleProjectPath,
|
||||
...(leadSessionId ? { leadSessionId } : {}),
|
||||
...(options?.sessionHistory ? { sessionHistory: options.sessionHistory } : {}),
|
||||
members: [{ name: 'team-lead', agentType: 'team-lead', cwd: actualProjectPath }],
|
||||
},
|
||||
null,
|
||||
2
|
||||
),
|
||||
'utf8'
|
||||
);
|
||||
|
||||
const actualProjectDir = path.join(claudeRoot, 'projects', encodePath(actualProjectPath));
|
||||
await fs.mkdir(actualProjectDir, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(actualProjectDir, `${sessionFileId}.jsonl`),
|
||||
`${JSON.stringify({
|
||||
teamName,
|
||||
type: 'assistant',
|
||||
timestamp: '2026-04-18T10:00:00.000Z',
|
||||
cwd: actualProjectPath,
|
||||
message: {
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'This is a sufficiently long lead thought recovered through the transcript resolver.',
|
||||
},
|
||||
],
|
||||
},
|
||||
})}\n`,
|
||||
'utf8'
|
||||
);
|
||||
|
||||
return {
|
||||
claudeRoot,
|
||||
teamName,
|
||||
configPath,
|
||||
staleProjectPath,
|
||||
actualProjectPath,
|
||||
actualProjectDir,
|
||||
};
|
||||
}
|
||||
|
||||
function createResolverBackedService(): TeamDataService {
|
||||
return new TeamDataService(
|
||||
new TeamConfigReader(),
|
||||
{ getTasks: vi.fn(async () => []) } as never,
|
||||
{
|
||||
listInboxNames: vi.fn(async () => []),
|
||||
getMessages: vi.fn(async () => []),
|
||||
} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{ resolveMembers: vi.fn(() => []) } as never,
|
||||
{ getState: vi.fn(async () => ({ teamName: 'my-team', reviewers: [], tasks: {} })) } as never,
|
||||
{} as never,
|
||||
{ getMembers: vi.fn(async () => []) } as never,
|
||||
{ readMessages: vi.fn(async () => []) } as never
|
||||
);
|
||||
}
|
||||
|
||||
function createLeadSessionCachingService(): TeamDataService {
|
||||
return new TeamDataService(
|
||||
{
|
||||
|
|
@ -3260,6 +3358,70 @@ describe('TeamDataService', () => {
|
|||
expect(secondSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('loads durable lead_session messages through the transcript resolver when projectPath is stale', async () => {
|
||||
const fixture = await createResolverBackedLeadFixture();
|
||||
const service = createResolverBackedService();
|
||||
|
||||
const data = await service.getTeamData(fixture.teamName);
|
||||
const persistedConfig = JSON.parse(await fs.readFile(fixture.configPath, 'utf8')) as TeamConfig;
|
||||
|
||||
expect(
|
||||
data.messages.find(
|
||||
(message) =>
|
||||
message.source === 'lead_session' &&
|
||||
message.text.includes('recovered through the transcript resolver')
|
||||
)
|
||||
).toBeTruthy();
|
||||
expect(persistedConfig.projectPath).toBe(fixture.actualProjectPath);
|
||||
});
|
||||
|
||||
it('still returns lead_session messages when projectPath repair persistence fails', async () => {
|
||||
const fixture = await createResolverBackedLeadFixture();
|
||||
const originalWriteFile = nodeFs.promises.writeFile.bind(nodeFs.promises);
|
||||
const teamTmpPrefix = path.join(fixture.claudeRoot, 'teams', fixture.teamName, '.tmp.');
|
||||
|
||||
vi.spyOn(nodeFs.promises, 'writeFile').mockImplementation(
|
||||
async (...args: Parameters<typeof nodeFs.promises.writeFile>) => {
|
||||
const [targetPath] = args;
|
||||
if (typeof targetPath === 'string' && targetPath.startsWith(teamTmpPrefix)) {
|
||||
throw new Error('simulated atomic write failure');
|
||||
}
|
||||
return originalWriteFile(...args);
|
||||
}
|
||||
);
|
||||
|
||||
const service = createResolverBackedService();
|
||||
const page = await service.getMessagesPage(fixture.teamName, { limit: 10 });
|
||||
const persistedConfig = JSON.parse(await fs.readFile(fixture.configPath, 'utf8')) as TeamConfig;
|
||||
|
||||
expect(
|
||||
page.messages.find(
|
||||
(message) =>
|
||||
message.source === 'lead_session' &&
|
||||
message.text.includes('recovered through the transcript resolver')
|
||||
)
|
||||
).toBeTruthy();
|
||||
expect(persistedConfig.projectPath).toBe(fixture.staleProjectPath);
|
||||
});
|
||||
|
||||
it('uses resolver-discovered session ids when config has no leadSessionId or sessionHistory', async () => {
|
||||
const fixture = await createResolverBackedLeadFixture({
|
||||
leadSessionId: undefined,
|
||||
sessionFileId: 'lead-discovered',
|
||||
});
|
||||
const service = createResolverBackedService();
|
||||
|
||||
const page = await service.getMessagesPage(fixture.teamName, { limit: 10 });
|
||||
|
||||
expect(
|
||||
page.messages.find(
|
||||
(message) =>
|
||||
message.source === 'lead_session' &&
|
||||
message.text.includes('recovered through the transcript resolver')
|
||||
)
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
it('fails fast when config is missing before any read-phase step starts', async () => {
|
||||
const harness = createGetTeamDataHarness({
|
||||
config: null,
|
||||
|
|
@ -3813,5 +3975,89 @@ describe('TeamDataService', () => {
|
|||
const result = page.messages.find((m) => m.messageId === 'resp1');
|
||||
expect(result?.messageKind).toBe('slash_command_result');
|
||||
});
|
||||
|
||||
it('dedups newest-page live overlay against durable lead thoughts that already paged off the first page', async () => {
|
||||
const fillerMessages = Array.from({ length: 55 }, (_, index) => ({
|
||||
from: 'alice',
|
||||
text: `filler-${index}`,
|
||||
timestamp: `2026-01-01T00:00:${String(10 + index).padStart(2, '0')}.000Z`,
|
||||
messageId: `filler-${index}`,
|
||||
source: 'inbox' as const,
|
||||
}));
|
||||
const durableThought = {
|
||||
from: 'team-lead',
|
||||
text: 'Hello there',
|
||||
timestamp: '2026-01-01T00:00:01.000Z',
|
||||
messageId: 'durable-thought',
|
||||
source: 'lead_session' as const,
|
||||
leadSessionId: 'lead-1',
|
||||
};
|
||||
const service = createPaginationService([...fillerMessages, durableThought]);
|
||||
|
||||
const page = await service.getMessagesPage('my-team', {
|
||||
limit: 50,
|
||||
liveMessages: [
|
||||
{
|
||||
from: 'team-lead',
|
||||
text: 'Hello there',
|
||||
timestamp: '2026-01-01T00:01:30.000Z',
|
||||
read: true,
|
||||
source: 'lead_process',
|
||||
messageId: 'live-thought',
|
||||
leadSessionId: 'lead-1',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(page.messages).toHaveLength(50);
|
||||
expect(page.messages.some((message) => message.messageId === 'live-thought')).toBe(false);
|
||||
expect(page.messages.some((message) => message.messageId === 'durable-thought')).toBe(false);
|
||||
});
|
||||
|
||||
it('does not skip durable rows when live overlay fills the newest page', async () => {
|
||||
const msgs = [
|
||||
{
|
||||
from: 'alice',
|
||||
text: 'durable-newest',
|
||||
timestamp: '2026-01-01T00:00:02.000Z',
|
||||
messageId: 'durable-2',
|
||||
source: 'inbox' as const,
|
||||
},
|
||||
{
|
||||
from: 'alice',
|
||||
text: 'durable-older',
|
||||
timestamp: '2026-01-01T00:00:01.000Z',
|
||||
messageId: 'durable-1',
|
||||
source: 'inbox' as const,
|
||||
},
|
||||
];
|
||||
const service = createPaginationService(msgs);
|
||||
|
||||
const page1 = await service.getMessagesPage('my-team', {
|
||||
limit: 1,
|
||||
liveMessages: [
|
||||
{
|
||||
from: 'team-lead',
|
||||
text: 'live-thought',
|
||||
timestamp: '2026-01-01T00:00:03.000Z',
|
||||
read: true,
|
||||
source: 'lead_process',
|
||||
messageId: 'live-1',
|
||||
leadSessionId: 'lead-1',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(page1.messages.map((message) => message.messageId)).toEqual(['live-1']);
|
||||
expect(page1.hasMore).toBe(true);
|
||||
expect(page1.nextCursor).toBe('2026-01-01T00:00:03.000Z|live-1');
|
||||
|
||||
const page2 = await service.getMessagesPage('my-team', {
|
||||
limit: 10,
|
||||
beforeTimestamp: page1.nextCursor!,
|
||||
});
|
||||
|
||||
expect(page2.messages.map((message) => message.messageId)).toEqual(['durable-2', 'durable-1']);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
460
test/main/services/team/TeamTranscriptProjectResolver.test.ts
Normal file
460
test/main/services/team/TeamTranscriptProjectResolver.test.ts
Normal file
|
|
@ -0,0 +1,460 @@
|
|||
import * as fs from 'fs/promises';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { TeamTranscriptProjectResolver } from '../../../../src/main/services/team/TeamTranscriptProjectResolver';
|
||||
import { encodePath, setClaudeBasePathOverride } from '../../../../src/main/utils/pathDecoder';
|
||||
|
||||
import type { TeamConfig } from '../../../../src/shared/types/team';
|
||||
|
||||
describe('TeamTranscriptProjectResolver', () => {
|
||||
let tmpDir: string | null = null;
|
||||
|
||||
afterEach(async () => {
|
||||
setClaudeBasePathOverride(null);
|
||||
if (tmpDir) {
|
||||
await fs.rm(tmpDir, { recursive: true, force: true });
|
||||
tmpDir = null;
|
||||
}
|
||||
});
|
||||
|
||||
async function setupClaudeRoot(): Promise<string> {
|
||||
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'team-transcript-project-resolver-'));
|
||||
setClaudeBasePathOverride(tmpDir);
|
||||
await fs.mkdir(path.join(tmpDir, 'teams'), { recursive: true });
|
||||
await fs.mkdir(path.join(tmpDir, 'projects'), { recursive: true });
|
||||
return tmpDir;
|
||||
}
|
||||
|
||||
async function writeTeamConfig(teamName: string, config: TeamConfig): Promise<void> {
|
||||
const teamDir = path.join(tmpDir!, 'teams', teamName);
|
||||
await fs.mkdir(teamDir, { recursive: true });
|
||||
await fs.writeFile(path.join(teamDir, 'config.json'), JSON.stringify(config, null, 2), 'utf8');
|
||||
}
|
||||
|
||||
async function readTeamConfig(teamName: string): Promise<TeamConfig> {
|
||||
const raw = await fs.readFile(path.join(tmpDir!, 'teams', teamName, 'config.json'), 'utf8');
|
||||
return JSON.parse(raw) as TeamConfig;
|
||||
}
|
||||
|
||||
async function createSessionFile(
|
||||
projectPath: string,
|
||||
sessionId: string,
|
||||
cwd: string = projectPath
|
||||
): Promise<{ projectDir: string; jsonlPath: string }> {
|
||||
const projectDir = path.join(tmpDir!, 'projects', encodePath(projectPath));
|
||||
await fs.mkdir(projectDir, { recursive: true });
|
||||
const jsonlPath = path.join(projectDir, `${sessionId}.jsonl`);
|
||||
await fs.writeFile(
|
||||
jsonlPath,
|
||||
`${JSON.stringify({
|
||||
type: 'assistant',
|
||||
timestamp: '2026-04-18T10:00:00.000Z',
|
||||
cwd,
|
||||
message: {
|
||||
role: 'assistant',
|
||||
content: [{ type: 'text', text: 'Resolver probe output' }],
|
||||
},
|
||||
})}\n`,
|
||||
'utf8'
|
||||
);
|
||||
return { projectDir, jsonlPath };
|
||||
}
|
||||
|
||||
async function createSessionFileInProjectDir(
|
||||
projectDirName: string,
|
||||
sessionId: string,
|
||||
cwd: string
|
||||
): Promise<{ projectDir: string; jsonlPath: string }> {
|
||||
const projectDir = path.join(tmpDir!, 'projects', projectDirName);
|
||||
await fs.mkdir(projectDir, { recursive: true });
|
||||
const jsonlPath = path.join(projectDir, `${sessionId}.jsonl`);
|
||||
await fs.writeFile(
|
||||
jsonlPath,
|
||||
`${JSON.stringify({
|
||||
type: 'assistant',
|
||||
timestamp: '2026-04-18T10:00:00.000Z',
|
||||
cwd,
|
||||
message: {
|
||||
role: 'assistant',
|
||||
content: [{ type: 'text', text: 'Resolver probe output' }],
|
||||
},
|
||||
})}\n`,
|
||||
'utf8'
|
||||
);
|
||||
return { projectDir, jsonlPath };
|
||||
}
|
||||
|
||||
async function createTeamAwareSessionFile(
|
||||
projectPath: string,
|
||||
sessionId: string,
|
||||
teamName: string,
|
||||
mode: 'text' | 'nested'
|
||||
): Promise<{ projectDir: string; jsonlPath: string }> {
|
||||
const projectDir = path.join(tmpDir!, 'projects', encodePath(projectPath));
|
||||
await fs.mkdir(projectDir, { recursive: true });
|
||||
const jsonlPath = path.join(projectDir, `${sessionId}.jsonl`);
|
||||
const lines =
|
||||
mode === 'text'
|
||||
? [
|
||||
{
|
||||
type: 'user',
|
||||
timestamp: '2026-04-18T10:00:00.000Z',
|
||||
cwd: projectPath,
|
||||
message: {
|
||||
role: 'user',
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `Current durable team context:\n- Team name: ${teamName}\n- You are the live team lead "team-lead"`,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
]
|
||||
: [
|
||||
{
|
||||
type: 'assistant',
|
||||
timestamp: '2026-04-18T10:00:00.000Z',
|
||||
cwd: projectPath,
|
||||
message: {
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{
|
||||
type: 'tool_use',
|
||||
id: 'call_probe',
|
||||
name: 'mcp__agent-teams__task_create_from_message',
|
||||
input: {
|
||||
teamName,
|
||||
subject: 'Probe task',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
await fs.writeFile(jsonlPath, `${lines.map((line) => JSON.stringify(line)).join('\n')}\n`, 'utf8');
|
||||
return { projectDir, jsonlPath };
|
||||
}
|
||||
|
||||
it('repairs stale projectPath when exact leadSessionId exists only in the renamed project', async () => {
|
||||
await setupClaudeRoot();
|
||||
|
||||
const teamName = 'my-team';
|
||||
const staleProjectPath = '/Users/test/hookplex';
|
||||
const repairedProjectPath = '/Users/test/plugin-kit-ai';
|
||||
const leadSessionId = 'lead-1';
|
||||
const staleProjectDir = path.join(tmpDir!, 'projects', encodePath(staleProjectPath));
|
||||
await fs.mkdir(staleProjectDir, { recursive: true });
|
||||
const repaired = await createSessionFile(repairedProjectPath, leadSessionId);
|
||||
|
||||
await writeTeamConfig(teamName, {
|
||||
name: 'My Team',
|
||||
projectPath: staleProjectPath,
|
||||
leadSessionId,
|
||||
members: [{ name: 'team-lead', agentType: 'team-lead', cwd: repairedProjectPath }],
|
||||
});
|
||||
|
||||
const resolver = new TeamTranscriptProjectResolver();
|
||||
const context = await resolver.getContext(teamName);
|
||||
const persisted = await readTeamConfig(teamName);
|
||||
|
||||
expect(context).not.toBeNull();
|
||||
expect(context?.projectDir).toBe(repaired.projectDir);
|
||||
expect(context?.config.projectPath).toBe(repairedProjectPath);
|
||||
expect(persisted.projectPath).toBe(repairedProjectPath);
|
||||
expect(persisted.projectPathHistory).toEqual(expect.arrayContaining([staleProjectPath]));
|
||||
});
|
||||
|
||||
it('keeps the current projectPath when it already contains the exact session', async () => {
|
||||
await setupClaudeRoot();
|
||||
|
||||
const teamName = 'my-team';
|
||||
const currentProjectPath = '/Users/test/hookplex';
|
||||
const alternateProjectPath = '/Users/test/plugin-kit-ai';
|
||||
const leadSessionId = 'lead-1';
|
||||
const current = await createSessionFile(currentProjectPath, leadSessionId);
|
||||
await createSessionFile(alternateProjectPath, leadSessionId);
|
||||
|
||||
await writeTeamConfig(teamName, {
|
||||
name: 'My Team',
|
||||
projectPath: currentProjectPath,
|
||||
projectPathHistory: [alternateProjectPath],
|
||||
leadSessionId,
|
||||
members: [{ name: 'team-lead', agentType: 'team-lead', cwd: alternateProjectPath }],
|
||||
});
|
||||
|
||||
const resolver = new TeamTranscriptProjectResolver();
|
||||
const context = await resolver.getContext(teamName);
|
||||
const persisted = await readTeamConfig(teamName);
|
||||
|
||||
expect(context?.projectDir).toBe(current.projectDir);
|
||||
expect(context?.config.projectPath).toBe(currentProjectPath);
|
||||
expect(persisted.projectPath).toBe(currentProjectPath);
|
||||
expect(persisted.projectPathHistory).toEqual([alternateProjectPath]);
|
||||
});
|
||||
|
||||
it('falls back to exact sessionHistory ids when leadSessionId file is missing', async () => {
|
||||
await setupClaudeRoot();
|
||||
|
||||
const teamName = 'my-team';
|
||||
const staleProjectPath = '/Users/test/hookplex';
|
||||
const repairedProjectPath = '/Users/test/plugin-kit-ai';
|
||||
const historicalSessionId = 'lead-old';
|
||||
await fs.mkdir(path.join(tmpDir!, 'projects', encodePath(staleProjectPath)), { recursive: true });
|
||||
const repaired = await createSessionFile(repairedProjectPath, historicalSessionId);
|
||||
|
||||
await writeTeamConfig(teamName, {
|
||||
name: 'My Team',
|
||||
projectPath: staleProjectPath,
|
||||
leadSessionId: 'lead-missing',
|
||||
sessionHistory: [historicalSessionId],
|
||||
members: [{ name: 'team-lead', agentType: 'team-lead', cwd: repairedProjectPath }],
|
||||
});
|
||||
|
||||
const resolver = new TeamTranscriptProjectResolver();
|
||||
const context = await resolver.getContext(teamName);
|
||||
const persisted = await readTeamConfig(teamName);
|
||||
|
||||
expect(context?.projectDir).toBe(repaired.projectDir);
|
||||
expect(context?.config.projectPath).toBe(repairedProjectPath);
|
||||
expect(persisted.projectPath).toBe(repairedProjectPath);
|
||||
});
|
||||
|
||||
it('prefers the newest sessionHistory match when leadSessionId is missing', async () => {
|
||||
await setupClaudeRoot();
|
||||
|
||||
const teamName = 'my-team';
|
||||
const staleProjectPath = '/Users/test/hookplex';
|
||||
const repairedProjectPath = '/Users/test/plugin-kit-ai';
|
||||
const olderSessionId = 'lead-old';
|
||||
const newerSessionId = 'lead-new';
|
||||
await createSessionFile(staleProjectPath, olderSessionId);
|
||||
const repaired = await createSessionFile(repairedProjectPath, newerSessionId);
|
||||
|
||||
await writeTeamConfig(teamName, {
|
||||
name: 'My Team',
|
||||
projectPath: staleProjectPath,
|
||||
leadSessionId: 'lead-missing',
|
||||
sessionHistory: [olderSessionId, newerSessionId],
|
||||
members: [{ name: 'team-lead', agentType: 'team-lead', cwd: repairedProjectPath }],
|
||||
});
|
||||
|
||||
const resolver = new TeamTranscriptProjectResolver();
|
||||
const context = await resolver.getContext(teamName);
|
||||
|
||||
expect(context?.projectDir).toBe(repaired.projectDir);
|
||||
expect(context?.config.projectPath).toBe(repairedProjectPath);
|
||||
});
|
||||
|
||||
it('does not let an old sessionHistory match block repair when the current leadSessionId exists elsewhere', async () => {
|
||||
await setupClaudeRoot();
|
||||
|
||||
const teamName = 'my-team';
|
||||
const staleProjectPath = '/Users/test/hookplex';
|
||||
const repairedProjectPath = '/Users/test/plugin-kit-ai';
|
||||
const leadSessionId = 'lead-current';
|
||||
const historicalSessionId = 'lead-old';
|
||||
await createSessionFile(staleProjectPath, historicalSessionId);
|
||||
const repaired = await createSessionFile(repairedProjectPath, leadSessionId);
|
||||
|
||||
await writeTeamConfig(teamName, {
|
||||
name: 'My Team',
|
||||
projectPath: staleProjectPath,
|
||||
leadSessionId,
|
||||
sessionHistory: [historicalSessionId],
|
||||
members: [{ name: 'team-lead', agentType: 'team-lead', cwd: repairedProjectPath }],
|
||||
});
|
||||
|
||||
const resolver = new TeamTranscriptProjectResolver();
|
||||
const context = await resolver.getContext(teamName);
|
||||
const persisted = await readTeamConfig(teamName);
|
||||
|
||||
expect(context?.projectDir).toBe(repaired.projectDir);
|
||||
expect(context?.config.projectPath).toBe(repairedProjectPath);
|
||||
expect(persisted.projectPath).toBe(repairedProjectPath);
|
||||
});
|
||||
|
||||
it('picks the best exact session match across dir variants for the same projectPath', async () => {
|
||||
await setupClaudeRoot();
|
||||
|
||||
const teamName = 'my-team';
|
||||
const projectPath = '/Users/test/plugin_kit_ai';
|
||||
const staleSessionId = 'lead-old';
|
||||
const currentSessionId = 'lead-current';
|
||||
await createSessionFile(projectPath, staleSessionId);
|
||||
const repaired = await createSessionFileInProjectDir(
|
||||
encodePath(projectPath).replace(/_/g, '-'),
|
||||
currentSessionId,
|
||||
projectPath
|
||||
);
|
||||
|
||||
await writeTeamConfig(teamName, {
|
||||
name: 'My Team',
|
||||
projectPath,
|
||||
leadSessionId: currentSessionId,
|
||||
sessionHistory: [staleSessionId],
|
||||
members: [{ name: 'team-lead', agentType: 'team-lead', cwd: projectPath }],
|
||||
});
|
||||
|
||||
const resolver = new TeamTranscriptProjectResolver();
|
||||
const context = await resolver.getContext(teamName);
|
||||
|
||||
expect(context?.projectDir).toBe(repaired.projectDir);
|
||||
});
|
||||
|
||||
it('does not self-heal when an alternate configured match is not unique across projects scan', async () => {
|
||||
await setupClaudeRoot();
|
||||
|
||||
const teamName = 'my-team';
|
||||
const staleProjectPath = '/Users/test/hookplex';
|
||||
const configuredProjectPath = '/Users/test/plugin-kit-ai';
|
||||
const duplicateProjectPath = '/Users/test/plugin-kit-ai-copy';
|
||||
const leadSessionId = 'lead-1';
|
||||
const staleProjectDir = path.join(tmpDir!, 'projects', encodePath(staleProjectPath));
|
||||
await fs.mkdir(staleProjectDir, { recursive: true });
|
||||
await createSessionFile(configuredProjectPath, leadSessionId);
|
||||
await createSessionFile(duplicateProjectPath, leadSessionId);
|
||||
|
||||
await writeTeamConfig(teamName, {
|
||||
name: 'My Team',
|
||||
projectPath: staleProjectPath,
|
||||
projectPathHistory: [configuredProjectPath],
|
||||
leadSessionId,
|
||||
members: [{ name: 'team-lead', agentType: 'team-lead', cwd: configuredProjectPath }],
|
||||
});
|
||||
|
||||
const resolver = new TeamTranscriptProjectResolver();
|
||||
const warnSpy = vi.mocked(console.warn);
|
||||
const context = await resolver.getContext(teamName);
|
||||
const persisted = await readTeamConfig(teamName);
|
||||
|
||||
expect(context?.projectDir).toBe(staleProjectDir);
|
||||
expect(context?.config.projectPath).toBe(staleProjectPath);
|
||||
expect(persisted.projectPath).toBe(staleProjectPath);
|
||||
expect(warnSpy.mock.calls).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.arrayContaining([
|
||||
expect.stringContaining('Transcript project resolution ambiguous across exact-session candidates'),
|
||||
]),
|
||||
])
|
||||
);
|
||||
warnSpy.mockClear();
|
||||
});
|
||||
|
||||
it('does not self-heal when full scan finds multiple equally valid session matches', async () => {
|
||||
await setupClaudeRoot();
|
||||
|
||||
const teamName = 'my-team';
|
||||
const staleProjectPath = '/Users/test/hookplex';
|
||||
const leadSessionId = 'lead-1';
|
||||
const staleProjectDir = path.join(tmpDir!, 'projects', encodePath(staleProjectPath));
|
||||
await fs.mkdir(staleProjectDir, { recursive: true });
|
||||
await createSessionFile('/Users/test/plugin-kit-ai', leadSessionId);
|
||||
await createSessionFile('/Users/test/plugin-kit-ai-copy', leadSessionId);
|
||||
|
||||
await writeTeamConfig(teamName, {
|
||||
name: 'My Team',
|
||||
projectPath: staleProjectPath,
|
||||
leadSessionId,
|
||||
members: [{ name: 'team-lead', agentType: 'team-lead' }],
|
||||
});
|
||||
|
||||
const resolver = new TeamTranscriptProjectResolver();
|
||||
const warnSpy = vi.mocked(console.warn);
|
||||
const context = await resolver.getContext(teamName);
|
||||
const persisted = await readTeamConfig(teamName);
|
||||
|
||||
expect(context?.projectDir).toBe(staleProjectDir);
|
||||
expect(context?.config.projectPath).toBe(staleProjectPath);
|
||||
expect(persisted.projectPath).toBe(staleProjectPath);
|
||||
expect(warnSpy.mock.calls).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.arrayContaining([
|
||||
expect.stringContaining('Transcript project resolution ambiguous across exact-session candidates'),
|
||||
]),
|
||||
])
|
||||
);
|
||||
warnSpy.mockClear();
|
||||
});
|
||||
|
||||
it('falls back to an existing alternate dir candidate when no session ids are known yet', async () => {
|
||||
await setupClaudeRoot();
|
||||
|
||||
const teamName = 'my-team';
|
||||
const projectPath = '/Users/test/plugin_kit_ai';
|
||||
const alternateDir = encodePath(projectPath).replace(/_/g, '-');
|
||||
const fallback = await createSessionFileInProjectDir(alternateDir, 'lead-1', projectPath);
|
||||
|
||||
await writeTeamConfig(teamName, {
|
||||
name: 'My Team',
|
||||
projectPath,
|
||||
members: [{ name: 'team-lead', agentType: 'team-lead', cwd: projectPath }],
|
||||
});
|
||||
|
||||
const resolver = new TeamTranscriptProjectResolver();
|
||||
const context = await resolver.getContext(teamName);
|
||||
|
||||
expect(context?.projectDir).toBe(fallback.projectDir);
|
||||
expect(context?.config.projectPath).toBe(projectPath);
|
||||
});
|
||||
|
||||
it('prefers a later candidate when the transcript text explicitly names the team and the stale project dir still exists', async () => {
|
||||
await setupClaudeRoot();
|
||||
|
||||
const teamName = 'vector-room-55555551';
|
||||
const staleProjectPath = '/Users/test/hookplex';
|
||||
const repairedProjectPath = '/Users/test/plugin-kit-ai';
|
||||
const staleProjectDir = path.join(tmpDir!, 'projects', encodePath(staleProjectPath));
|
||||
await fs.mkdir(staleProjectDir, { recursive: true });
|
||||
const repaired = await createTeamAwareSessionFile(
|
||||
repairedProjectPath,
|
||||
'lead-1',
|
||||
teamName,
|
||||
'text'
|
||||
);
|
||||
|
||||
await writeTeamConfig(teamName, {
|
||||
name: 'My Team',
|
||||
projectPath: staleProjectPath,
|
||||
members: [{ name: 'team-lead', agentType: 'team-lead', cwd: repairedProjectPath }],
|
||||
});
|
||||
|
||||
const resolver = new TeamTranscriptProjectResolver();
|
||||
const context = await resolver.getContext(teamName);
|
||||
|
||||
expect(context?.projectDir).toBe(repaired.projectDir);
|
||||
expect(context?.config.projectPath).toBe(repairedProjectPath);
|
||||
});
|
||||
|
||||
it('recognizes nested tool input teamName during no-session fallback', async () => {
|
||||
await setupClaudeRoot();
|
||||
|
||||
const teamName = 'vector-room-55555551';
|
||||
const staleProjectPath = '/Users/test/hookplex';
|
||||
const repairedProjectPath = '/Users/test/plugin-kit-ai';
|
||||
const staleProjectDir = path.join(tmpDir!, 'projects', encodePath(staleProjectPath));
|
||||
await fs.mkdir(staleProjectDir, { recursive: true });
|
||||
const repaired = await createTeamAwareSessionFile(
|
||||
repairedProjectPath,
|
||||
'lead-1',
|
||||
teamName,
|
||||
'nested'
|
||||
);
|
||||
|
||||
await writeTeamConfig(teamName, {
|
||||
name: 'My Team',
|
||||
projectPath: staleProjectPath,
|
||||
members: [{ name: 'team-lead', agentType: 'team-lead', cwd: repairedProjectPath }],
|
||||
});
|
||||
|
||||
const resolver = new TeamTranscriptProjectResolver();
|
||||
const context = await resolver.getContext(teamName);
|
||||
|
||||
expect(context?.projectDir).toBe(repaired.projectDir);
|
||||
expect(context?.config.projectPath).toBe(repairedProjectPath);
|
||||
});
|
||||
});
|
||||
|
|
@ -251,6 +251,55 @@ describe('MessagesPanel idle summary invariants', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('clears pending replies when a real member reply arrives after the pending timestamp', async () => {
|
||||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const root = createRoot(host);
|
||||
const onPendingReplyChange = vi.fn();
|
||||
|
||||
const pendingSentAtMs = Date.parse('2026-04-08T12:00:00.000Z');
|
||||
const messages: InboxMessage[] = [
|
||||
makeMessage({
|
||||
messageId: 'lead-reply',
|
||||
from: 'alice',
|
||||
read: true,
|
||||
source: 'lead_process',
|
||||
timestamp: '2026-04-08T12:01:00.000Z',
|
||||
text: 'Starting now.',
|
||||
}),
|
||||
];
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
React.createElement(MessagesPanel, {
|
||||
teamName: 'atlas-hq',
|
||||
position: 'sidebar',
|
||||
onPositionChange: vi.fn(),
|
||||
members: [],
|
||||
tasks: [],
|
||||
messages,
|
||||
timeWindow: null,
|
||||
teamSessionIds: new Set<string>(),
|
||||
pendingRepliesByMember: { alice: pendingSentAtMs },
|
||||
onPendingReplyChange,
|
||||
})
|
||||
);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(onPendingReplyChange.mock.calls.length).toBeGreaterThan(0);
|
||||
const updater = onPendingReplyChange.mock.calls.at(-1)?.[0] as
|
||||
| ((current: Record<string, number>) => Record<string, number>)
|
||||
| undefined;
|
||||
expect(updater?.({ alice: pendingSentAtMs })).toEqual({});
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
await Promise.resolve();
|
||||
});
|
||||
});
|
||||
|
||||
it('renders the bottom-sheet composer before the status block so input stays pinned near the header', async () => {
|
||||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||
const host = document.createElement('div');
|
||||
|
|
|
|||
Loading…
Reference in a new issue