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
|
## Installation
|
||||||
|
|
||||||
No prerequisites - the app can detect supported runtimes/providers and guide setup from the UI.
|
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">
|
<table align="center">
|
||||||
<tr>
|
<tr>
|
||||||
|
|
|
||||||
|
|
@ -99,18 +99,19 @@ import * as path from 'path';
|
||||||
import { ConfigManager } from '../services/infrastructure/ConfigManager';
|
import { ConfigManager } from '../services/infrastructure/ConfigManager';
|
||||||
import { NotificationManager } from '../services/infrastructure/NotificationManager';
|
import { NotificationManager } from '../services/infrastructure/NotificationManager';
|
||||||
import { gitIdentityResolver } from '../services/parsing/GitIdentityResolver';
|
import { gitIdentityResolver } from '../services/parsing/GitIdentityResolver';
|
||||||
import {
|
|
||||||
getAutoResumeService,
|
|
||||||
initializeAutoResumeService,
|
|
||||||
} from '../services/team/AutoResumeService';
|
|
||||||
import {
|
import {
|
||||||
buildActionModeAgentBlock,
|
buildActionModeAgentBlock,
|
||||||
isAgentActionMode,
|
isAgentActionMode,
|
||||||
} from '../services/team/actionModeInstructions';
|
} from '../services/team/actionModeInstructions';
|
||||||
|
import {
|
||||||
|
getAutoResumeService,
|
||||||
|
initializeAutoResumeService,
|
||||||
|
} from '../services/team/AutoResumeService';
|
||||||
import {
|
import {
|
||||||
buildReplaceMembersDiff,
|
buildReplaceMembersDiff,
|
||||||
buildReplaceMembersSummaryMessage,
|
buildReplaceMembersSummaryMessage,
|
||||||
} from '../services/team/memberUpdateNotifications';
|
} from '../services/team/memberUpdateNotifications';
|
||||||
|
import { mergeLiveLeadProcessMessages } from '../services/team/mergeLiveLeadProcessMessages';
|
||||||
import { TeamAttachmentStore } from '../services/team/TeamAttachmentStore';
|
import { TeamAttachmentStore } from '../services/team/TeamAttachmentStore';
|
||||||
import { TeamMembersMetaStore } from '../services/team/TeamMembersMetaStore';
|
import { TeamMembersMetaStore } from '../services/team/TeamMembersMetaStore';
|
||||||
import { TeamMetaStore } from '../services/team/TeamMetaStore';
|
import { TeamMetaStore } from '../services/team/TeamMetaStore';
|
||||||
|
|
@ -156,11 +157,9 @@ import type {
|
||||||
IpcResult,
|
IpcResult,
|
||||||
KanbanColumnId,
|
KanbanColumnId,
|
||||||
LeadActivitySnapshot,
|
LeadActivitySnapshot,
|
||||||
LeadContextUsage,
|
|
||||||
LeadContextUsageSnapshot,
|
LeadContextUsageSnapshot,
|
||||||
MemberFullStats,
|
MemberFullStats,
|
||||||
MemberLogSummary,
|
MemberLogSummary,
|
||||||
MemberSpawnStatusEntry,
|
|
||||||
MemberSpawnStatusesSnapshot,
|
MemberSpawnStatusesSnapshot,
|
||||||
MessagesPage,
|
MessagesPage,
|
||||||
SendMessageRequest,
|
SendMessageRequest,
|
||||||
|
|
@ -823,82 +822,23 @@ async function handleGetData(
|
||||||
checkApiErrorMessages(data.messages, tn, displayName, projectPath);
|
checkApiErrorMessages(data.messages, tn, displayName, projectPath);
|
||||||
return { success: true, data: { ...data, isAlive } };
|
return { success: true, data: { ...data, isAlive } };
|
||||||
}
|
}
|
||||||
|
let merged = mergeLiveLeadProcessMessages(data.messages, live);
|
||||||
const normalizeText = (text: string): string => text.trim().replace(/\r\n/g, '\n');
|
if (data.messages.length >= 50) {
|
||||||
const isLeadThoughtLike = (msg: { source?: unknown; to?: string }): boolean =>
|
try {
|
||||||
!msg.to && (msg.source === 'lead_process' || msg.source === 'lead_session');
|
const newestPage = await teamDataService.getMessagesPage(tn, {
|
||||||
const getLeadThoughtFingerprint = (msg: {
|
limit: 50,
|
||||||
from: string;
|
liveMessages: live,
|
||||||
text: string;
|
});
|
||||||
leadSessionId?: string;
|
merged = newestPage.messages;
|
||||||
}): string => `${msg.leadSessionId ?? ''}\0${msg.from}\0${normalizeText(msg.text)}`;
|
} catch (error) {
|
||||||
|
logger.warn(
|
||||||
// Collect fingerprints only for thought-like lead messages. Include leadSessionId so a
|
`[teams:getData] failed to rebuild newest merged messages for ${tn}: ${
|
||||||
// repeated thought in a new session does not get collapsed into an old session's history.
|
error instanceof Error ? error.message : String(error)
|
||||||
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));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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);
|
checkRateLimitMessages(merged, tn, displayName, projectPath, isAlive, currentLeadSessionId);
|
||||||
checkApiErrorMessages(merged, tn, displayName, projectPath);
|
checkApiErrorMessages(merged, tn, displayName, projectPath);
|
||||||
return { success: true, data: { ...data, isAlive, messages: merged } };
|
return { success: true, data: { ...data, isAlive, messages: merged } };
|
||||||
|
|
@ -1789,7 +1729,10 @@ async function handleGetMessagesPage(
|
||||||
|
|
||||||
return wrapTeamHandler('getMessagesPage', async () => {
|
return wrapTeamHandler('getMessagesPage', async () => {
|
||||||
const service = getTeamDataService();
|
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 { yieldToEventLoop } from '@main/utils/asyncYield';
|
||||||
import {
|
import { getClaudeBasePath, getTasksBasePath, getTeamsBasePath } from '@main/utils/pathDecoder';
|
||||||
encodePath,
|
|
||||||
extractBaseDir,
|
|
||||||
getClaudeBasePath,
|
|
||||||
getProjectsBasePath,
|
|
||||||
getTasksBasePath,
|
|
||||||
getTeamsBasePath,
|
|
||||||
} from '@main/utils/pathDecoder';
|
|
||||||
import { killProcessByPid } from '@main/utils/processKill';
|
import { killProcessByPid } from '@main/utils/processKill';
|
||||||
import {
|
import {
|
||||||
AGENT_BLOCK_CLOSE,
|
AGENT_BLOCK_CLOSE,
|
||||||
|
|
@ -16,7 +9,7 @@ import {
|
||||||
} from '@shared/constants/agentBlocks';
|
} from '@shared/constants/agentBlocks';
|
||||||
import { getMemberColorByName } from '@shared/constants/memberColors';
|
import { getMemberColorByName } from '@shared/constants/memberColors';
|
||||||
import { classifyIdleNotificationText } from '@shared/utils/idleNotificationSemantics';
|
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 { createLogger } from '@shared/utils/logger';
|
||||||
import { getKanbanColumnFromReviewState, normalizeReviewState } from '@shared/utils/reviewState';
|
import { getKanbanColumnFromReviewState, normalizeReviewState } from '@shared/utils/reviewState';
|
||||||
import { buildStandaloneSlashCommandMeta } from '@shared/utils/slashCommands';
|
import { buildStandaloneSlashCommandMeta } from '@shared/utils/slashCommands';
|
||||||
|
|
@ -39,6 +32,10 @@ import {
|
||||||
} from './cache/LeadSessionParseCache';
|
} from './cache/LeadSessionParseCache';
|
||||||
import { atomicWriteAsync } from './atomicWrite';
|
import { atomicWriteAsync } from './atomicWrite';
|
||||||
import { extractLeadSessionMessagesFromJsonl } from './leadSessionMessageExtractor';
|
import { extractLeadSessionMessagesFromJsonl } from './leadSessionMessageExtractor';
|
||||||
|
import {
|
||||||
|
getLiveLeadProcessMessageKey,
|
||||||
|
mergeLiveLeadProcessMessages,
|
||||||
|
} from './mergeLiveLeadProcessMessages';
|
||||||
import { buildTaskChangePresenceDescriptor } from './taskChangePresenceUtils';
|
import { buildTaskChangePresenceDescriptor } from './taskChangePresenceUtils';
|
||||||
import { TeamConfigReader } from './TeamConfigReader';
|
import { TeamConfigReader } from './TeamConfigReader';
|
||||||
import { TeamInboxReader } from './TeamInboxReader';
|
import { TeamInboxReader } from './TeamInboxReader';
|
||||||
|
|
@ -52,6 +49,7 @@ import { TeamSentMessagesStore } from './TeamSentMessagesStore';
|
||||||
import { TeamTaskCommentNotificationJournal } from './TeamTaskCommentNotificationJournal';
|
import { TeamTaskCommentNotificationJournal } from './TeamTaskCommentNotificationJournal';
|
||||||
import { TeamTaskReader } from './TeamTaskReader';
|
import { TeamTaskReader } from './TeamTaskReader';
|
||||||
import { TeamTaskWriter } from './TeamTaskWriter';
|
import { TeamTaskWriter } from './TeamTaskWriter';
|
||||||
|
import { TeamTranscriptProjectResolver } from './TeamTranscriptProjectResolver';
|
||||||
|
|
||||||
import type { PersistedTaskChangePresenceIndex } from './cache/taskChangePresenceCacheTypes';
|
import type { PersistedTaskChangePresenceIndex } from './cache/taskChangePresenceCacheTypes';
|
||||||
import type { TaskChangePresenceRepository } from './cache/TaskChangePresenceRepository';
|
import type { TaskChangePresenceRepository } from './cache/TaskChangePresenceRepository';
|
||||||
|
|
@ -182,7 +180,10 @@ export class TeamDataService {
|
||||||
private readonly taskCommentNotificationJournal: TeamTaskCommentNotificationJournal = new TeamTaskCommentNotificationJournal(),
|
private readonly taskCommentNotificationJournal: TeamTaskCommentNotificationJournal = new TeamTaskCommentNotificationJournal(),
|
||||||
private readonly teamMetaStore: TeamMetaStore = new TeamMetaStore(),
|
private readonly teamMetaStore: TeamMetaStore = new TeamMetaStore(),
|
||||||
private memberRuntimeAdvisoryService: TeamMemberRuntimeAdvisoryService = new TeamMemberRuntimeAdvisoryService(),
|
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 {
|
private getController(teamName: string): AgentTeamsController {
|
||||||
|
|
@ -769,7 +770,7 @@ export class TeamDataService {
|
||||||
label: 'leadTexts',
|
label: 'leadTexts',
|
||||||
createFallback: () => [],
|
createFallback: () => [],
|
||||||
warningText: 'Lead session texts failed to load',
|
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(
|
async getMessagesPage(
|
||||||
teamName: string,
|
teamName: string,
|
||||||
options: { beforeTimestamp?: string; limit: number }
|
options: { beforeTimestamp?: string; limit: number; liveMessages?: InboxMessage[] }
|
||||||
): Promise<MessagesPage> {
|
): Promise<MessagesPage> {
|
||||||
const config = await this.configReader.getConfig(teamName);
|
const config = await this.configReader.getConfig(teamName);
|
||||||
if (!config) {
|
if (!config) {
|
||||||
|
|
@ -1125,7 +1126,7 @@ export class TeamDataService {
|
||||||
|
|
||||||
const [inboxMessages, leadTexts, sentMessages] = await Promise.all([
|
const [inboxMessages, leadTexts, sentMessages] = await Promise.all([
|
||||||
this.inboxReader.getMessages(teamName).catch(() => [] as InboxMessage[]),
|
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[]),
|
this.sentMessagesStore.readMessages(teamName).catch(() => [] as InboxMessage[]),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|
@ -1190,6 +1191,11 @@ export class TeamDataService {
|
||||||
return (a.messageId ?? '').localeCompare(b.messageId ?? '');
|
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)
|
// Apply cursor filter. Cursor format: "timestamp|messageId" (compound)
|
||||||
// to handle multiple messages sharing the same timestamp.
|
// to handle multiple messages sharing the same timestamp.
|
||||||
if (options.beforeTimestamp) {
|
if (options.beforeTimestamp) {
|
||||||
|
|
@ -1212,7 +1218,54 @@ export class TeamDataService {
|
||||||
const nextCursor =
|
const nextCursor =
|
||||||
hasMore && lastMsg ? `${lastMsg.timestamp}|${lastMsg.messageId ?? ''}` : null;
|
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[] {
|
private async getLeadSessionJsonlPaths(projectDir: string): Promise<Map<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>> {
|
|
||||||
const jsonlPaths = new Map<string, string>();
|
const jsonlPaths = new Map<string, string>();
|
||||||
for (const dirPath of this.getLeadProjectDirCandidates(projectPath)) {
|
let entries: fs.Dirent[];
|
||||||
let entries: fs.Dirent[];
|
try {
|
||||||
try {
|
entries = await fs.promises.readdir(projectDir, { withFileTypes: true });
|
||||||
entries = await fs.promises.readdir(dirPath, { withFileTypes: true });
|
} catch {
|
||||||
} catch {
|
return jsonlPaths;
|
||||||
continue;
|
}
|
||||||
}
|
|
||||||
|
|
||||||
for (const entry of entries) {
|
for (const entry of entries) {
|
||||||
if (!entry.isFile() || !entry.name.endsWith('.jsonl')) continue;
|
if (!entry.isFile() || !entry.name.endsWith('.jsonl')) continue;
|
||||||
const sessionId = entry.name.slice(0, -'.jsonl'.length).trim();
|
const sessionId = entry.name.slice(0, -'.jsonl'.length).trim();
|
||||||
if (!sessionId || jsonlPaths.has(sessionId)) continue;
|
if (!sessionId || jsonlPaths.has(sessionId)) continue;
|
||||||
jsonlPaths.set(sessionId, path.join(dirPath, entry.name));
|
jsonlPaths.set(sessionId, path.join(projectDir, entry.name));
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return jsonlPaths;
|
return jsonlPaths;
|
||||||
|
|
@ -2890,17 +2926,23 @@ export class TeamDataService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async extractLeadSessionTexts(config: TeamConfig): Promise<InboxMessage[]> {
|
private async extractLeadSessionTexts(
|
||||||
if (!config.projectPath) {
|
teamName: string,
|
||||||
|
config: TeamConfig
|
||||||
|
): Promise<InboxMessage[]> {
|
||||||
|
const transcriptContext = await this.projectResolver.getContext(teamName);
|
||||||
|
if (!transcriptContext) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
const leadName =
|
||||||
const leadName = config.members?.find((m) => isLeadMember(m))?.name ?? 'team-lead';
|
transcriptContext.config.members?.find((m) => isLeadMember(m))?.name ?? 'team-lead';
|
||||||
const sessionIds = this.getRecentLeadSessionIds(config);
|
const sessionIds = Array.from(
|
||||||
|
new Set([...this.getRecentLeadSessionIds(config), ...transcriptContext.sessionIds])
|
||||||
|
);
|
||||||
if (sessionIds.length === 0) {
|
if (sessionIds.length === 0) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
const availableJsonlPaths = await this.getLeadSessionJsonlPaths(config.projectPath);
|
const availableJsonlPaths = await this.getLeadSessionJsonlPaths(transcriptContext.projectDir);
|
||||||
if (availableJsonlPaths.size === 0) {
|
if (availableJsonlPaths.size === 0) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3109,17 +3109,14 @@ export class TeamProvisioningService {
|
||||||
}
|
}
|
||||||
|
|
||||||
getLiveLeadProcessMessages(teamName: string): InboxMessage[] {
|
getLiveLeadProcessMessages(teamName: string): InboxMessage[] {
|
||||||
const list = this.liveLeadProcessMessages.get(teamName) ?? [];
|
|
||||||
const runId = this.getTrackedRunId(teamName);
|
const runId = this.getTrackedRunId(teamName);
|
||||||
const sessionId = runId ? this.runs.get(runId)?.detectedSessionId : null;
|
const detectedSessionId = runId ? (this.runs.get(runId)?.detectedSessionId ?? null) : null;
|
||||||
if (sessionId) {
|
|
||||||
for (const message of list) {
|
return (this.liveLeadProcessMessages.get(teamName) ?? []).map((message) =>
|
||||||
if (!message.leadSessionId && message.source === 'lead_process') {
|
!message.leadSessionId && detectedSessionId
|
||||||
message.leadSessionId = sessionId;
|
? { ...message, leadSessionId: detectedSessionId }
|
||||||
}
|
: { ...message }
|
||||||
}
|
);
|
||||||
}
|
|
||||||
return [...list];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private pruneLiveLeadMessagesForCleanedRun(run: ProvisioningRun): void {
|
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 { createLogger } from '@shared/utils/logger';
|
||||||
import { createReadStream, type Dirent } from 'fs';
|
import { createReadStream, type Dirent } from 'fs';
|
||||||
import * as fs from 'fs/promises';
|
import * as fs from 'fs/promises';
|
||||||
|
|
@ -15,6 +23,33 @@ const SESSION_DISCOVERY_CACHE_TTL = 30_000;
|
||||||
const TEAM_AFFINITY_SCAN_LINES = 40;
|
const TEAM_AFFINITY_SCAN_LINES = 40;
|
||||||
const ROOT_DISCOVERY_CONCURRENCY = 12;
|
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 {
|
function trimTrailingSlashes(value: string): string {
|
||||||
let end = value.length;
|
let end = value.length;
|
||||||
while (end > 0) {
|
while (end > 0) {
|
||||||
|
|
@ -32,6 +67,17 @@ function isSessionDirectoryName(name: string): boolean {
|
||||||
return name !== 'memory' && !name.startsWith('.');
|
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 {
|
function extractTextContent(entry: Record<string, unknown>): string | null {
|
||||||
if (typeof entry.content === 'string') {
|
if (typeof entry.content === 'string') {
|
||||||
return entry.content;
|
return entry.content;
|
||||||
|
|
@ -71,6 +117,9 @@ function lineMentionsTeam(text: string, teamName: string): boolean {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
return (
|
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(`on team '${normalizedTeam}'`) ||
|
normalizedText.includes(`on team '${normalizedTeam}'`) ||
|
||||||
normalizedText.includes(`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[] {
|
function collectKnownSessionIds(config: TeamConfig): string[] {
|
||||||
const knownSessionIds = new Set<string>();
|
const knownSessionIds = new Set<string>();
|
||||||
const push = (value: unknown): void => {
|
const push = (value: unknown): void => {
|
||||||
|
|
@ -93,7 +164,8 @@ function collectKnownSessionIds(config: TeamConfig): string[] {
|
||||||
|
|
||||||
push(config.leadSessionId);
|
push(config.leadSessionId);
|
||||||
if (Array.isArray(config.sessionHistory)) {
|
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);
|
push(sessionId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -130,13 +202,39 @@ export class TeamTranscriptProjectResolver {
|
||||||
}
|
}
|
||||||
|
|
||||||
const config = await this.configReader.getConfig(teamName);
|
const config = await this.configReader.getConfig(teamName);
|
||||||
if (!config?.projectPath) {
|
if (!config) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { projectDir, projectId } = await this.resolveProjectDirectory(config);
|
const resolution = await this.resolveProjectDirectory(teamName, config);
|
||||||
const sessionIds = await this.discoverSessionIds(teamName, projectDir, config);
|
if (!resolution) {
|
||||||
const value = { projectDir, projectId, config, sessionIds };
|
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, {
|
this.contextCache.set(teamName, {
|
||||||
value,
|
value,
|
||||||
expiresAt: Date.now() + SESSION_DISCOVERY_CACHE_TTL,
|
expiresAt: Date.now() + SESSION_DISCOVERY_CACHE_TTL,
|
||||||
|
|
@ -145,47 +243,391 @@ export class TeamTranscriptProjectResolver {
|
||||||
}
|
}
|
||||||
|
|
||||||
private async resolveProjectDirectory(
|
private async resolveProjectDirectory(
|
||||||
|
teamName: string,
|
||||||
config: TeamConfig
|
config: TeamConfig
|
||||||
): Promise<{ projectDir: string; projectId: string }> {
|
): Promise<{ projectDir: string; projectId: string; effectiveProjectPath?: string } | null> {
|
||||||
const normalizedProjectPath = trimTrailingSlashes(config.projectPath ?? '');
|
const sessionIds = collectKnownSessionIds(config);
|
||||||
let projectId = encodePath(normalizedProjectPath);
|
const pathCandidates = this.collectProjectPathCandidates(config);
|
||||||
let projectDir = path.join(getProjectsBasePath(), extractBaseDir(projectId));
|
const currentCandidate = pathCandidates[0] ?? null;
|
||||||
|
if (sessionIds.length === 0) {
|
||||||
|
return this.buildFallbackResolution(teamName, pathCandidates);
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
const rankBySessionId = new Map(sessionIds.map((sessionId, index) => [sessionId, index]));
|
||||||
const stat = await fs.stat(projectDir);
|
const getMatchRank = (match: { matchedSessionId: string } | null): number =>
|
||||||
if (!stat.isDirectory()) {
|
match
|
||||||
throw new Error('not a directory');
|
? (rankBySessionId.get(match.matchedSessionId) ?? Number.POSITIVE_INFINITY)
|
||||||
}
|
: Number.POSITIVE_INFINITY;
|
||||||
return { projectDir, projectId };
|
|
||||||
} catch {
|
|
||||||
const leadSessionId =
|
|
||||||
typeof config.leadSessionId === 'string' && config.leadSessionId.trim().length > 0
|
|
||||||
? config.leadSessionId.trim()
|
|
||||||
: null;
|
|
||||||
if (!leadSessionId) {
|
|
||||||
return { projectDir, projectId };
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
const toResolution = (
|
||||||
const projectEntries = await fs.readdir(getProjectsBasePath(), { withFileTypes: true });
|
match: Pick<ProjectDirCandidate, 'projectDir' | 'projectId'> & { projectPath?: string }
|
||||||
for (const entry of projectEntries) {
|
): { projectDir: string; projectId: string; effectiveProjectPath?: string } => ({
|
||||||
if (!entry.isDirectory()) continue;
|
projectDir: match.projectDir,
|
||||||
const candidateDir = path.join(getProjectsBasePath(), entry.name);
|
projectId: match.projectId,
|
||||||
try {
|
...(match.projectPath ? { effectiveProjectPath: match.projectPath } : {}),
|
||||||
await fs.access(path.join(candidateDir, `${leadSessionId}.jsonl`));
|
});
|
||||||
projectDir = candidateDir;
|
|
||||||
projectId = entry.name;
|
let currentMatch: SessionProjectMatch | null = null;
|
||||||
break;
|
if (currentCandidate) {
|
||||||
} catch {
|
const resolvedCurrentMatch = await this.findMatchInProjectPathCandidate(
|
||||||
// not this project
|
currentCandidate,
|
||||||
}
|
sessionIds
|
||||||
}
|
);
|
||||||
} catch {
|
if (resolvedCurrentMatch && getMatchRank(resolvedCurrentMatch) === 0) {
|
||||||
// best-effort fallback
|
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(
|
private async discoverSessionIds(
|
||||||
|
|
@ -199,9 +641,58 @@ export class TeamTranscriptProjectResolver {
|
||||||
this.listSessionDirIds(projectDir),
|
this.listSessionDirIds(projectDir),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return Array.from(new Set([...knownSessionIds, ...teamRootSessionIds, ...sessionDirIds])).sort(
|
const orderedSessionIds: string[] = [];
|
||||||
(left, right) => left.localeCompare(right)
|
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[]> {
|
private async listSessionDirIds(projectDir: string): Promise<string[]> {
|
||||||
|
|
@ -272,6 +763,9 @@ export class TeamTranscriptProjectResolver {
|
||||||
if (directTeamName === normalizedTeam) {
|
if (directTeamName === normalizedTeam) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
if (entryContainsNestedTeamName(entry, normalizedTeam)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
const textContent = extractTextContent(entry);
|
const textContent = extractTextContent(entry);
|
||||||
if (textContent && lineMentionsTeam(textContent, normalizedTeam)) {
|
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,
|
BoardTaskExactLogDetailResult,
|
||||||
BoardTaskExactLogSummariesResponse,
|
BoardTaskExactLogSummariesResponse,
|
||||||
InboxMessage,
|
InboxMessage,
|
||||||
|
MessagesPage,
|
||||||
TeamCreateRequest,
|
TeamCreateRequest,
|
||||||
TeamProvisioningProgress,
|
TeamProvisioningProgress,
|
||||||
} from '@shared/types/team';
|
} from '@shared/types/team';
|
||||||
|
|
@ -80,6 +81,7 @@ import {
|
||||||
TEAM_GET_TASK_EXACT_LOG_SUMMARIES,
|
TEAM_GET_TASK_EXACT_LOG_SUMMARIES,
|
||||||
TEAM_GET_MEMBER_LOGS,
|
TEAM_GET_MEMBER_LOGS,
|
||||||
TEAM_GET_MEMBER_STATS,
|
TEAM_GET_MEMBER_STATS,
|
||||||
|
TEAM_GET_MESSAGES_PAGE,
|
||||||
TEAM_START_TASK,
|
TEAM_START_TASK,
|
||||||
TEAM_UPDATE_CONFIG,
|
TEAM_UPDATE_CONFIG,
|
||||||
TEAM_UPDATE_KANBAN,
|
TEAM_UPDATE_KANBAN,
|
||||||
|
|
@ -140,6 +142,11 @@ describe('ipc teams handlers', () => {
|
||||||
kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} },
|
kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} },
|
||||||
processes: [],
|
processes: [],
|
||||||
})),
|
})),
|
||||||
|
getMessagesPage: vi.fn(async (..._args: unknown[]): Promise<MessagesPage> => ({
|
||||||
|
messages: [] as InboxMessage[],
|
||||||
|
nextCursor: null,
|
||||||
|
hasMore: false,
|
||||||
|
})),
|
||||||
getTaskChangePresence: vi.fn(async () => ({ 'task-1': 'has_changes' })),
|
getTaskChangePresence: vi.fn(async () => ({ 'task-1': 'has_changes' })),
|
||||||
reconcileTeamArtifacts: vi.fn(async () => undefined),
|
reconcileTeamArtifacts: vi.fn(async () => undefined),
|
||||||
setTaskChangePresenceTracking: vi.fn(() => 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 () => {
|
it('keeps TEAM_GET_DATA read-only and never triggers reconcile side effects', async () => {
|
||||||
const getDataHandler = handlers.get(TEAM_GET_DATA)!;
|
const getDataHandler = handlers.get(TEAM_GET_DATA)!;
|
||||||
const result = (await getDataHandler({} as never, 'my-team')) as {
|
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 fs from 'fs/promises';
|
||||||
import * as os from 'os';
|
import * as os from 'os';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
|
|
||||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
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 { buildTaskChangePresenceDescriptor } from '../../../../src/main/services/team/taskChangePresenceUtils';
|
||||||
import { TeamDataService } from '../../../../src/main/services/team/TeamDataService';
|
import { TeamDataService } from '../../../../src/main/services/team/TeamDataService';
|
||||||
|
|
||||||
|
|
@ -81,6 +83,102 @@ async function createTempJsonlInNamedDir(
|
||||||
return jsonlPath;
|
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 {
|
function createLeadSessionCachingService(): TeamDataService {
|
||||||
return new TeamDataService(
|
return new TeamDataService(
|
||||||
{
|
{
|
||||||
|
|
@ -3260,6 +3358,70 @@ describe('TeamDataService', () => {
|
||||||
expect(secondSpy).toHaveBeenCalledTimes(1);
|
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 () => {
|
it('fails fast when config is missing before any read-phase step starts', async () => {
|
||||||
const harness = createGetTeamDataHarness({
|
const harness = createGetTeamDataHarness({
|
||||||
config: null,
|
config: null,
|
||||||
|
|
@ -3813,5 +3975,89 @@ describe('TeamDataService', () => {
|
||||||
const result = page.messages.find((m) => m.messageId === 'resp1');
|
const result = page.messages.find((m) => m.messageId === 'resp1');
|
||||||
expect(result?.messageKind).toBe('slash_command_result');
|
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 () => {
|
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);
|
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||||
const host = document.createElement('div');
|
const host = document.createElement('div');
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue