feat(team): merge live lead messages and repair transcript resolution

This commit is contained in:
777genius 2026-04-18 11:02:21 +03:00
parent 72f8d4e786
commit 78c6824d69
10 changed files with 1688 additions and 185 deletions

View file

@ -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>

View file

@ -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 });
});
}

View file

@ -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 [];
}

View file

@ -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 {

View file

@ -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)) {

View 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;
}

View file

@ -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 {

View file

@ -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']);
});
});
});

View 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);
});
});

View file

@ -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');