fix(team): deduplicate permission_request processing across all entry paths
Root cause: handleTeammatePermissionRequest was called from 3 paths (early inbox scan, Category 4 relay scan, stdout/native) but only the early scan checked processedPermissionRequestIds, causing duplicate ToolApprovalRequests and extra permission_responses. Fix: - Move processedPermissionRequestIds check INTO handleTeammatePermissionRequest so ALL callers are protected by the same dedup gate - Remove duplicate Category 4 scan that re-processed inbox messages (early scan already covers all messages including read=true) - Category 4 now only builds a filter Set to exclude permission_request from relay to lead
This commit is contained in:
parent
f286468dac
commit
e431cfd02c
23 changed files with 1404 additions and 77 deletions
|
|
@ -63,7 +63,7 @@ export interface GraphNode {
|
|||
startedAt: string;
|
||||
finishedAt?: string;
|
||||
resultPreview?: string;
|
||||
source: 'runtime' | 'inbox';
|
||||
source: 'runtime' | 'member_log' | 'inbox';
|
||||
};
|
||||
/** Recent completed tool activity for popovers and secondary UI */
|
||||
recentTools?: Array<{
|
||||
|
|
@ -73,7 +73,7 @@ export interface GraphNode {
|
|||
startedAt: string;
|
||||
finishedAt: string;
|
||||
resultPreview?: string;
|
||||
source: 'runtime' | 'inbox';
|
||||
source: 'runtime' | 'member_log' | 'inbox';
|
||||
}>;
|
||||
|
||||
// ─── Task-specific ─────────────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -106,6 +106,7 @@ import {
|
|||
TeamDataService,
|
||||
TeamLogSourceTracker,
|
||||
TeamMemberLogsFinder,
|
||||
TeammateToolTracker,
|
||||
TeamProvisioningService,
|
||||
UpdaterService,
|
||||
} from './services';
|
||||
|
|
@ -784,6 +785,7 @@ function initializeServices(): void {
|
|||
const teamMemberLogsFinder = new TeamMemberLogsFinder();
|
||||
const taskChangePresenceRepository = new JsonTaskChangePresenceRepository();
|
||||
const teamLogSourceTracker = new TeamLogSourceTracker(teamMemberLogsFinder);
|
||||
let teammateToolTracker: TeammateToolTracker | null = null;
|
||||
const memberStatsComputer = new MemberStatsComputer(teamMemberLogsFinder);
|
||||
const taskBoundaryParser = new TaskBoundaryParser();
|
||||
const changeExtractor = new ChangeExtractorService(teamMemberLogsFinder, taskBoundaryParser);
|
||||
|
|
@ -839,13 +841,27 @@ function initializeServices(): void {
|
|||
return getTeamControlApiBaseUrl();
|
||||
});
|
||||
|
||||
// Allow TeamProvisioningService to trigger team refresh events (e.g. live lead replies).
|
||||
const teamChangeEmitter = (event: TeamChangeEvent): void => {
|
||||
const forwardTeamChange = (event: TeamChangeEvent): void => {
|
||||
safeSendToRenderer(mainWindow, TEAM_CHANGE, event);
|
||||
httpServer?.broadcast('team-change', event);
|
||||
};
|
||||
teammateToolTracker = new TeammateToolTracker(
|
||||
teamMemberLogsFinder,
|
||||
teamLogSourceTracker,
|
||||
forwardTeamChange
|
||||
);
|
||||
// Allow TeamProvisioningService to trigger team refresh events (e.g. live lead replies).
|
||||
const teamChangeEmitter = (event: TeamChangeEvent): void => {
|
||||
forwardTeamChange(event);
|
||||
if (event.type === 'lead-activity' && event.detail === 'offline') {
|
||||
teammateToolTracker?.handleTeamOffline(event.teamName);
|
||||
}
|
||||
};
|
||||
teamProvisioningService.setTeamChangeEmitter(teamChangeEmitter);
|
||||
teamLogSourceTracker.setEmitter(teamChangeEmitter);
|
||||
teamLogSourceTracker.onLogSourceChange((teamName) => {
|
||||
teammateToolTracker?.handleLogSourceChange(teamName);
|
||||
});
|
||||
|
||||
// Allow SchedulerService to push schedule events to renderer
|
||||
schedulerService.setChangeEmitter((event) => {
|
||||
|
|
@ -874,6 +890,7 @@ function initializeServices(): void {
|
|||
teamProvisioningService,
|
||||
teamMemberLogsFinder,
|
||||
memberStatsComputer,
|
||||
teammateToolTracker ?? undefined,
|
||||
{
|
||||
rewire: rewireContextEvents,
|
||||
full: onContextSwitched,
|
||||
|
|
|
|||
|
|
@ -100,6 +100,7 @@ import type {
|
|||
SshConnectionManager,
|
||||
TeamDataService,
|
||||
TeamMemberLogsFinder,
|
||||
TeammateToolTracker,
|
||||
TeamProvisioningService,
|
||||
UpdaterService,
|
||||
} from '../services';
|
||||
|
|
@ -127,6 +128,7 @@ export function initializeIpcHandlers(
|
|||
teamProvisioningService: TeamProvisioningService,
|
||||
teamMemberLogsFinder: TeamMemberLogsFinder,
|
||||
memberStatsComputer: MemberStatsComputer,
|
||||
teammateToolTracker: TeammateToolTracker | undefined,
|
||||
contextCallbacks: {
|
||||
rewire: (context: ServiceContext) => void;
|
||||
full: (context: ServiceContext) => void;
|
||||
|
|
@ -167,7 +169,8 @@ export function initializeIpcHandlers(
|
|||
teamProvisioningService,
|
||||
teamMemberLogsFinder,
|
||||
memberStatsComputer,
|
||||
teamBackupService
|
||||
teamBackupService,
|
||||
teammateToolTracker
|
||||
);
|
||||
initializeConfigHandlers({
|
||||
onClaudeRootPathUpdated: contextCallbacks.onClaudeRootPathUpdated,
|
||||
|
|
|
|||
|
|
@ -48,6 +48,7 @@ import {
|
|||
TEAM_SAVE_TASK_ATTACHMENT,
|
||||
TEAM_SEND_MESSAGE,
|
||||
TEAM_SET_CHANGE_PRESENCE_TRACKING,
|
||||
TEAM_SET_TOOL_ACTIVITY_TRACKING,
|
||||
TEAM_SET_TASK_CLARIFICATION,
|
||||
TEAM_SHOW_MESSAGE_NOTIFICATION,
|
||||
TEAM_SOFT_DELETE_TASK,
|
||||
|
|
@ -110,6 +111,7 @@ import {
|
|||
|
||||
import type {
|
||||
MemberStatsComputer,
|
||||
TeammateToolTracker,
|
||||
TeamDataService,
|
||||
TeamMemberLogsFinder,
|
||||
TeamProvisioningService,
|
||||
|
|
@ -272,6 +274,7 @@ let teamProvisioningService: TeamProvisioningService | null = null;
|
|||
let teamMemberLogsFinder: TeamMemberLogsFinder | null = null;
|
||||
let memberStatsComputer: MemberStatsComputer | null = null;
|
||||
let teamBackupService: TeamBackupService | null = null;
|
||||
let teammateToolTracker: TeammateToolTracker | null = null;
|
||||
|
||||
const attachmentStore = new TeamAttachmentStore();
|
||||
const taskAttachmentStore = new TeamTaskAttachmentStore();
|
||||
|
|
@ -300,13 +303,15 @@ export function initializeTeamHandlers(
|
|||
provisioningService: TeamProvisioningService,
|
||||
logsFinder?: TeamMemberLogsFinder,
|
||||
statsComputer?: MemberStatsComputer,
|
||||
backupService?: TeamBackupService
|
||||
backupService?: TeamBackupService,
|
||||
toolTracker?: TeammateToolTracker
|
||||
): void {
|
||||
teamDataService = service;
|
||||
teamProvisioningService = provisioningService;
|
||||
teamMemberLogsFinder = logsFinder ?? null;
|
||||
memberStatsComputer = statsComputer ?? null;
|
||||
teamBackupService = backupService ?? null;
|
||||
teammateToolTracker = toolTracker ?? null;
|
||||
}
|
||||
|
||||
export function registerTeamHandlers(ipcMain: IpcMain): void {
|
||||
|
|
@ -314,6 +319,7 @@ export function registerTeamHandlers(ipcMain: IpcMain): void {
|
|||
ipcMain.handle(TEAM_GET_DATA, handleGetData);
|
||||
ipcMain.handle(TEAM_GET_TASK_CHANGE_PRESENCE, handleGetTaskChangePresence);
|
||||
ipcMain.handle(TEAM_SET_CHANGE_PRESENCE_TRACKING, handleSetChangePresenceTracking);
|
||||
ipcMain.handle(TEAM_SET_TOOL_ACTIVITY_TRACKING, handleSetToolActivityTracking);
|
||||
ipcMain.handle(TEAM_GET_CLAUDE_LOGS, handleGetClaudeLogs);
|
||||
ipcMain.handle(TEAM_PREPARE_PROVISIONING, handlePrepareProvisioning);
|
||||
ipcMain.handle(TEAM_CREATE, handleCreateTeam);
|
||||
|
|
@ -378,6 +384,7 @@ export function removeTeamHandlers(ipcMain: IpcMain): void {
|
|||
ipcMain.removeHandler(TEAM_GET_DATA);
|
||||
ipcMain.removeHandler(TEAM_GET_TASK_CHANGE_PRESENCE);
|
||||
ipcMain.removeHandler(TEAM_SET_CHANGE_PRESENCE_TRACKING);
|
||||
ipcMain.removeHandler(TEAM_SET_TOOL_ACTIVITY_TRACKING);
|
||||
ipcMain.removeHandler(TEAM_GET_CLAUDE_LOGS);
|
||||
ipcMain.removeHandler(TEAM_PREPARE_PROVISIONING);
|
||||
ipcMain.removeHandler(TEAM_CREATE);
|
||||
|
|
@ -450,6 +457,13 @@ function getTeamProvisioningService(): TeamProvisioningService {
|
|||
return teamProvisioningService;
|
||||
}
|
||||
|
||||
function getTeammateToolTracker(): TeammateToolTracker {
|
||||
if (!teammateToolTracker) {
|
||||
throw new Error('Teammate tool tracker is not initialized');
|
||||
}
|
||||
return teammateToolTracker;
|
||||
}
|
||||
|
||||
async function wrapTeamHandler<T>(
|
||||
operation: string,
|
||||
handler: () => Promise<T>
|
||||
|
|
@ -655,6 +669,24 @@ async function handleSetChangePresenceTracking(
|
|||
});
|
||||
}
|
||||
|
||||
async function handleSetToolActivityTracking(
|
||||
_event: IpcMainInvokeEvent,
|
||||
teamName: unknown,
|
||||
enabled: unknown
|
||||
): Promise<IpcResult<void>> {
|
||||
const validated = validateTeamName(teamName);
|
||||
if (!validated.valid) {
|
||||
return { success: false, error: validated.error ?? 'Invalid teamName' };
|
||||
}
|
||||
if (typeof enabled !== 'boolean') {
|
||||
return { success: false, error: 'enabled must be a boolean' };
|
||||
}
|
||||
|
||||
return wrapTeamHandler('setToolActivityTracking', async () => {
|
||||
await getTeammateToolTracker().setTracking(validated.value!, enabled);
|
||||
});
|
||||
}
|
||||
|
||||
async function handleDeleteTeam(
|
||||
_event: IpcMainInvokeEvent,
|
||||
teamName: unknown
|
||||
|
|
|
|||
|
|
@ -197,7 +197,7 @@ export class TeamDataService {
|
|||
|
||||
if (enabled) {
|
||||
void this.teamLogSourceTracker
|
||||
.ensureTracking(teamName)
|
||||
.setTracking(teamName, 'change_presence', true)
|
||||
.catch((error) =>
|
||||
logger.debug(`Failed to start change-presence tracking for ${teamName}: ${String(error)}`)
|
||||
);
|
||||
|
|
@ -205,7 +205,7 @@ export class TeamDataService {
|
|||
}
|
||||
|
||||
void this.teamLogSourceTracker
|
||||
.stopTracking(teamName)
|
||||
.setTracking(teamName, 'change_presence', false)
|
||||
.catch((error) =>
|
||||
logger.debug(`Failed to stop change-presence tracking for ${teamName}: ${String(error)}`)
|
||||
);
|
||||
|
|
|
|||
|
|
@ -20,6 +20,8 @@ interface TeamLogSourceSnapshot {
|
|||
logSourceGeneration: string | null;
|
||||
}
|
||||
|
||||
export type TeamLogSourceTrackingConsumer = 'change_presence' | 'tool_activity';
|
||||
|
||||
interface TrackingState {
|
||||
watcher: FSWatcher | null;
|
||||
projectDir: string | null;
|
||||
|
|
@ -29,13 +31,14 @@ interface TrackingState {
|
|||
recomputePromise: Promise<TeamLogSourceSnapshot> | null;
|
||||
recomputeVersion: number | null;
|
||||
snapshot: TeamLogSourceSnapshot;
|
||||
desiredTracking: boolean;
|
||||
consumers: Set<TeamLogSourceTrackingConsumer>;
|
||||
lifecycleVersion: number;
|
||||
}
|
||||
|
||||
export class TeamLogSourceTracker {
|
||||
private readonly stateByTeam = new Map<string, TrackingState>();
|
||||
private emitter: ((event: TeamChangeEvent) => void) | null = null;
|
||||
private readonly changeListeners = new Set<(teamName: string) => void>();
|
||||
|
||||
constructor(private readonly logsFinder: TeamMemberLogsFinder) {}
|
||||
|
||||
|
|
@ -43,22 +46,46 @@ export class TeamLogSourceTracker {
|
|||
this.emitter = emitter;
|
||||
}
|
||||
|
||||
onLogSourceChange(listener: (teamName: string) => void): () => void {
|
||||
this.changeListeners.add(listener);
|
||||
return () => {
|
||||
this.changeListeners.delete(listener);
|
||||
};
|
||||
}
|
||||
|
||||
getSnapshot(teamName: string): TeamLogSourceSnapshot | null {
|
||||
const state = this.stateByTeam.get(teamName);
|
||||
return state ? { ...state.snapshot } : null;
|
||||
}
|
||||
|
||||
async setTracking(
|
||||
teamName: string,
|
||||
consumer: TeamLogSourceTrackingConsumer,
|
||||
enabled: boolean
|
||||
): Promise<TeamLogSourceSnapshot> {
|
||||
return enabled
|
||||
? this.enableTracking(teamName, consumer)
|
||||
: this.disableTracking(teamName, consumer);
|
||||
}
|
||||
|
||||
async ensureTracking(teamName: string): Promise<TeamLogSourceSnapshot> {
|
||||
return this.enableTracking(teamName, 'change_presence');
|
||||
}
|
||||
|
||||
private async enableTracking(
|
||||
teamName: string,
|
||||
consumer: TeamLogSourceTrackingConsumer
|
||||
): Promise<TeamLogSourceSnapshot> {
|
||||
const state = this.getOrCreateState(teamName);
|
||||
if (!state.desiredTracking) {
|
||||
state.desiredTracking = true;
|
||||
if (!state.consumers.has(consumer)) {
|
||||
state.consumers.add(consumer);
|
||||
state.lifecycleVersion += 1;
|
||||
}
|
||||
|
||||
if (
|
||||
state.initializePromise &&
|
||||
state.initializeVersion === state.lifecycleVersion &&
|
||||
state.desiredTracking
|
||||
state.consumers.size > 0
|
||||
) {
|
||||
return state.initializePromise;
|
||||
}
|
||||
|
|
@ -101,7 +128,7 @@ export class TeamLogSourceTracker {
|
|||
recomputePromise: null,
|
||||
recomputeVersion: null,
|
||||
snapshot: { projectFingerprint: null, logSourceGeneration: null },
|
||||
desiredTracking: false,
|
||||
consumers: new Set(),
|
||||
lifecycleVersion: 0,
|
||||
};
|
||||
this.stateByTeam.set(teamName, created);
|
||||
|
|
@ -109,16 +136,27 @@ export class TeamLogSourceTracker {
|
|||
}
|
||||
|
||||
async stopTracking(teamName: string): Promise<void> {
|
||||
await this.disableTracking(teamName, 'change_presence');
|
||||
}
|
||||
|
||||
private async disableTracking(
|
||||
teamName: string,
|
||||
consumer: TeamLogSourceTrackingConsumer
|
||||
): Promise<TeamLogSourceSnapshot> {
|
||||
const state = this.stateByTeam.get(teamName);
|
||||
if (!state) {
|
||||
return;
|
||||
return { projectFingerprint: null, logSourceGeneration: null };
|
||||
}
|
||||
|
||||
if (state.desiredTracking) {
|
||||
state.desiredTracking = false;
|
||||
if (state.consumers.has(consumer)) {
|
||||
state.consumers.delete(consumer);
|
||||
state.lifecycleVersion += 1;
|
||||
}
|
||||
|
||||
if (state.consumers.size > 0) {
|
||||
return { ...state.snapshot };
|
||||
}
|
||||
|
||||
if (state.refreshTimer) {
|
||||
clearTimeout(state.refreshTimer);
|
||||
state.refreshTimer = null;
|
||||
|
|
@ -131,11 +169,12 @@ export class TeamLogSourceTracker {
|
|||
|
||||
state.projectDir = null;
|
||||
state.snapshot = { projectFingerprint: null, logSourceGeneration: null };
|
||||
return { ...state.snapshot };
|
||||
}
|
||||
|
||||
private isTrackingCurrent(teamName: string, expectedVersion: number): boolean {
|
||||
const state = this.stateByTeam.get(teamName);
|
||||
return !!state && state.desiredTracking && state.lifecycleVersion === expectedVersion;
|
||||
return !!state && state.consumers.size > 0 && state.lifecycleVersion === expectedVersion;
|
||||
}
|
||||
|
||||
private async initializeTeam(
|
||||
|
|
@ -167,10 +206,7 @@ export class TeamLogSourceTracker {
|
|||
state.snapshot.logSourceGeneration &&
|
||||
previousGeneration !== state.snapshot.logSourceGeneration
|
||||
) {
|
||||
this.emitter?.({
|
||||
type: 'log-source-change',
|
||||
teamName,
|
||||
});
|
||||
this.emitLogSourceChange(teamName);
|
||||
}
|
||||
return snapshot;
|
||||
}
|
||||
|
|
@ -181,7 +217,7 @@ export class TeamLogSourceTracker {
|
|||
expectedVersion: number
|
||||
): Promise<void> {
|
||||
const state = this.stateByTeam.get(teamName);
|
||||
if (!state || !state.desiredTracking || state.lifecycleVersion !== expectedVersion) {
|
||||
if (!state || state.consumers.size === 0 || state.lifecycleVersion !== expectedVersion) {
|
||||
return;
|
||||
}
|
||||
if (state.projectDir === projectDir && state.watcher) {
|
||||
|
|
@ -216,7 +252,7 @@ export class TeamLogSourceTracker {
|
|||
|
||||
const scheduleRecompute = (): void => {
|
||||
const current = this.stateByTeam.get(teamName);
|
||||
if (!current?.desiredTracking) {
|
||||
if (!current || current.consumers.size === 0) {
|
||||
return;
|
||||
}
|
||||
if (current.refreshTimer) {
|
||||
|
|
@ -240,13 +276,13 @@ export class TeamLogSourceTracker {
|
|||
|
||||
private async recompute(teamName: string): Promise<TeamLogSourceSnapshot> {
|
||||
const state = this.getOrCreateState(teamName);
|
||||
if (!state.desiredTracking) {
|
||||
if (state.consumers.size === 0) {
|
||||
return state.snapshot;
|
||||
}
|
||||
if (
|
||||
state.recomputePromise &&
|
||||
state.recomputeVersion === state.lifecycleVersion &&
|
||||
state.desiredTracking
|
||||
state.consumers.size > 0
|
||||
) {
|
||||
return state.recomputePromise;
|
||||
}
|
||||
|
|
@ -278,10 +314,7 @@ export class TeamLogSourceTracker {
|
|||
state.snapshot.logSourceGeneration &&
|
||||
previousGeneration !== state.snapshot.logSourceGeneration
|
||||
) {
|
||||
this.emitter?.({
|
||||
type: 'log-source-change',
|
||||
teamName,
|
||||
});
|
||||
this.emitLogSourceChange(teamName);
|
||||
}
|
||||
|
||||
return state.snapshot;
|
||||
|
|
@ -298,6 +331,20 @@ export class TeamLogSourceTracker {
|
|||
return recomputePromise;
|
||||
}
|
||||
|
||||
private emitLogSourceChange(teamName: string): void {
|
||||
this.emitter?.({
|
||||
type: 'log-source-change',
|
||||
teamName,
|
||||
});
|
||||
for (const listener of this.changeListeners) {
|
||||
try {
|
||||
listener(teamName);
|
||||
} catch (error) {
|
||||
logger.warn(`Log-source listener failed for ${teamName}: ${String(error)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async computeSnapshot(context: {
|
||||
projectDir: string;
|
||||
projectPath?: string;
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ const ATTRIBUTION_SCAN_LINES = 50;
|
|||
/** Grace before task creation — logs cannot reference a task before it exists. */
|
||||
const TASK_SINCE_GRACE_MS = 2 * 60 * 1000;
|
||||
const FILE_MENTIONS_CACHE_MAX = 10_000;
|
||||
const ATTRIBUTION_CACHE_MAX = 5_000;
|
||||
|
||||
/** Max concurrent file reads during parallel scan phases. */
|
||||
const SCAN_CONCURRENCY = 15;
|
||||
|
|
@ -87,6 +88,7 @@ function trimTrailingSlashes(value: string): string {
|
|||
|
||||
export class TeamMemberLogsFinder {
|
||||
private readonly fileMentionsCache = new Map<string, boolean>();
|
||||
private readonly attributionCache = new Map<string, SubagentAttribution | null>();
|
||||
private readonly discoveryCache = new Map<
|
||||
string,
|
||||
{
|
||||
|
|
@ -660,7 +662,17 @@ export class TeamMemberLogsFinder {
|
|||
|
||||
const filePath = path.join(subagentsDir, file);
|
||||
// Quick attribution check — only Phase 1 (no full-file streaming)
|
||||
const attribution = await this.attributeSubagent(filePath, knownMembers);
|
||||
let mtimeMs = 0;
|
||||
try {
|
||||
mtimeMs = (await fs.stat(filePath)).mtimeMs;
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
const attribution = await this.getCachedSubagentAttribution(
|
||||
filePath,
|
||||
knownMembers,
|
||||
mtimeMs
|
||||
);
|
||||
if (attribution?.detectedMember.toLowerCase() === memberName.trim().toLowerCase()) {
|
||||
paths.push(filePath);
|
||||
}
|
||||
|
|
@ -670,6 +682,50 @@ export class TeamMemberLogsFinder {
|
|||
return paths;
|
||||
}
|
||||
|
||||
async listAttributedSubagentFiles(
|
||||
teamName: string
|
||||
): Promise<Array<{ memberName: string; sessionId: string; filePath: string; mtimeMs: number }>> {
|
||||
const discovery = await this.discoverProjectSessions(teamName);
|
||||
if (!discovery) return [];
|
||||
|
||||
const { projectDir, sessionIds, knownMembers } = discovery;
|
||||
const candidates = await this.collectSubagentCandidates(projectDir, sessionIds);
|
||||
const results: Array<{
|
||||
memberName: string;
|
||||
sessionId: string;
|
||||
filePath: string;
|
||||
mtimeMs: number;
|
||||
}> = [];
|
||||
|
||||
const settled = await Promise.all(
|
||||
candidates.map(async (candidate) => {
|
||||
try {
|
||||
const stat = await fs.stat(candidate.filePath);
|
||||
const attribution = await this.getCachedSubagentAttribution(
|
||||
candidate.filePath,
|
||||
knownMembers,
|
||||
stat.mtimeMs
|
||||
);
|
||||
if (!attribution) return null;
|
||||
return {
|
||||
memberName: attribution.detectedMember,
|
||||
sessionId: candidate.sessionId,
|
||||
filePath: candidate.filePath,
|
||||
mtimeMs: stat.mtimeMs,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
for (const item of settled) {
|
||||
if (item) results.push(item);
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fast marker probe for task-related logs.
|
||||
* Prefer structured MCP/TaskUpdate markers for modern sessions.
|
||||
|
|
@ -1150,6 +1206,24 @@ export class TeamMemberLogsFinder {
|
|||
}
|
||||
}
|
||||
|
||||
private async getCachedSubagentAttribution(
|
||||
filePath: string,
|
||||
knownMembers: Set<string>,
|
||||
mtimeMs: number
|
||||
): Promise<SubagentAttribution | null> {
|
||||
const cacheKey = `${filePath}:${mtimeMs}`;
|
||||
if (this.attributionCache.has(cacheKey)) {
|
||||
return this.attributionCache.get(cacheKey) ?? null;
|
||||
}
|
||||
const attribution = await this.attributeSubagent(filePath, knownMembers);
|
||||
this.attributionCache.set(cacheKey, attribution);
|
||||
if (this.attributionCache.size > ATTRIBUTION_CACHE_MAX) {
|
||||
const oldestKey = this.attributionCache.keys().next().value;
|
||||
if (oldestKey) this.attributionCache.delete(oldestKey);
|
||||
}
|
||||
return attribution;
|
||||
}
|
||||
|
||||
private async parseSubagentSummary(
|
||||
filePath: string,
|
||||
projectId: string,
|
||||
|
|
|
|||
|
|
@ -4046,11 +4046,7 @@ export class TeamProvisioningService {
|
|||
if (typeof msg.text !== 'string') continue;
|
||||
const perm = parsePermissionRequest(msg.text);
|
||||
if (!perm) continue;
|
||||
if (run.processedPermissionRequestIds.has(perm.requestId)) continue;
|
||||
run.processedPermissionRequestIds.add(perm.requestId);
|
||||
logger.warn(
|
||||
`[${run.teamName}] [PERM-TRACE] Intercepted permission_request from inbox scan (read=${String(msg.read)}): agent=${perm.agentId} tool=${perm.toolName} requestId=${perm.requestId}`
|
||||
);
|
||||
// Dedup is handled inside handleTeammatePermissionRequest via processedPermissionRequestIds
|
||||
this.handleTeammatePermissionRequest(run, perm, msg.timestamp);
|
||||
}
|
||||
} catch {
|
||||
|
|
@ -4193,32 +4189,19 @@ export class TeamProvisioningService {
|
|||
);
|
||||
const deferredIds = new Set(deferredByAge.map((m) => m.messageId));
|
||||
|
||||
// Category 4: teammate permission requests — intercept and convert to tool approvals.
|
||||
// Don't relay these to the lead agent (it can't handle them).
|
||||
// NOTE: We intentionally do NOT exclude nativeMatchedMessageIds here — even if
|
||||
// Claude Code runtime natively delivered the message to the lead, we still need
|
||||
// to intercept permission_request and show the ToolApprovalSheet for the user.
|
||||
const permissionRequestMsgs = unread.filter(
|
||||
(m) =>
|
||||
!permanentlyIgnoredIds.has(m.messageId) &&
|
||||
!deferredIds.has(m.messageId) &&
|
||||
parsePermissionRequest(m.text) !== null
|
||||
// Category 4: teammate permission requests — filter from actionable so they're
|
||||
// NOT relayed to the lead. The actual interception + ToolApprovalRequest emission
|
||||
// is handled by the early scan above (which checks processedPermissionRequestIds).
|
||||
const permissionRequestIds = new Set(
|
||||
unread
|
||||
.filter(
|
||||
(m) =>
|
||||
!permanentlyIgnoredIds.has(m.messageId) &&
|
||||
!deferredIds.has(m.messageId) &&
|
||||
parsePermissionRequest(m.text) !== null
|
||||
)
|
||||
.map((m) => m.messageId)
|
||||
);
|
||||
const permissionRequestIds = new Set(permissionRequestMsgs.map((m) => m.messageId));
|
||||
if (permissionRequestMsgs.length > 0) {
|
||||
logger.warn(
|
||||
`[${run.teamName}] [PERM-TRACE] relay intercepted ${permissionRequestMsgs.length} permission_request(s) from inbox`
|
||||
);
|
||||
for (const msg of permissionRequestMsgs) {
|
||||
const perm = parsePermissionRequest(msg.text)!;
|
||||
this.handleTeammatePermissionRequest(run, perm, msg.timestamp);
|
||||
}
|
||||
try {
|
||||
await this.markInboxMessagesRead(teamName, leadName, permissionRequestMsgs);
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
}
|
||||
|
||||
// Actionable: everything not in any category.
|
||||
const actionableUnread = unread.filter(
|
||||
|
|
@ -5777,13 +5760,11 @@ export class TeamProvisioningService {
|
|||
perm: ParsedPermissionRequest,
|
||||
messageTimestamp: string
|
||||
): void {
|
||||
// Skip if already tracked (idempotency — relay can be called multiple times)
|
||||
if (run.pendingApprovals.has(perm.requestId)) {
|
||||
logger.warn(
|
||||
`[${run.teamName}] [PERM-TRACE] Duplicate permission_request skipped: ${perm.requestId}`
|
||||
);
|
||||
return;
|
||||
}
|
||||
// Skip if already tracked (idempotency — multiple paths can trigger this:
|
||||
// early inbox scan, stdout parsing, native message blocks, relay Category 4)
|
||||
if (run.processedPermissionRequestIds.has(perm.requestId)) return;
|
||||
if (run.pendingApprovals.has(perm.requestId)) return;
|
||||
run.processedPermissionRequestIds.add(perm.requestId);
|
||||
|
||||
logger.warn(
|
||||
`[${run.teamName}] [PERM-TRACE] handleTeammatePermissionRequest: agent=${perm.agentId} tool=${perm.toolName} requestId=${perm.requestId}`
|
||||
|
|
|
|||
520
src/main/services/team/TeammateToolTracker.ts
Normal file
520
src/main/services/team/TeammateToolTracker.ts
Normal file
|
|
@ -0,0 +1,520 @@
|
|||
import { extractToolPreview, extractToolResultPreview } from '@shared/utils/toolSummary';
|
||||
import * as fs from 'fs/promises';
|
||||
|
||||
import type { TeamLogSourceTracker } from './TeamLogSourceTracker';
|
||||
import type { TeamMemberLogsFinder } from './TeamMemberLogsFinder';
|
||||
import type { ActiveToolCall, TeamChangeEvent, ToolActivityEventPayload } from '@shared/types';
|
||||
|
||||
const MAX_SEEN_FINISHED_IDS = 512;
|
||||
|
||||
interface FileState {
|
||||
memberName: string;
|
||||
sessionId: string;
|
||||
lastSize: number;
|
||||
lastMtimeMs: number;
|
||||
lineCarry: string;
|
||||
activeTools: Map<string, ActiveToolCall>;
|
||||
seenFinished: Set<string>;
|
||||
}
|
||||
|
||||
interface TeamState {
|
||||
enabled: boolean;
|
||||
epoch: number;
|
||||
filesByPath: Map<string, FileState>;
|
||||
refreshInFlight: boolean;
|
||||
refreshQueued: boolean;
|
||||
}
|
||||
|
||||
interface AttributedSubagentFile {
|
||||
memberName: string;
|
||||
sessionId: string;
|
||||
filePath: string;
|
||||
mtimeMs: number;
|
||||
}
|
||||
|
||||
interface ParsedFileSnapshot {
|
||||
lastSize: number;
|
||||
lastMtimeMs: number;
|
||||
lineCarry: string;
|
||||
activeTools: Map<string, ActiveToolCall>;
|
||||
seenFinished: Set<string>;
|
||||
}
|
||||
|
||||
export class TeammateToolTracker {
|
||||
private readonly stateByTeam = new Map<string, TeamState>();
|
||||
|
||||
constructor(
|
||||
private readonly logsFinder: TeamMemberLogsFinder,
|
||||
private readonly logSourceTracker: TeamLogSourceTracker,
|
||||
private readonly emitTeamChange: (event: TeamChangeEvent) => void
|
||||
) {}
|
||||
|
||||
async setTracking(teamName: string, enabled: boolean): Promise<void> {
|
||||
if (enabled) {
|
||||
await this.enableTracking(teamName);
|
||||
return;
|
||||
}
|
||||
await this.disableTracking(teamName);
|
||||
}
|
||||
|
||||
async dispose(): Promise<void> {
|
||||
await Promise.all(
|
||||
[...this.stateByTeam.keys()].map((teamName) => this.disableTracking(teamName))
|
||||
);
|
||||
}
|
||||
|
||||
handleLogSourceChange(teamName: string): void {
|
||||
const state = this.stateByTeam.get(teamName);
|
||||
if (!state?.enabled) return;
|
||||
void this.refreshTeam(teamName);
|
||||
}
|
||||
|
||||
handleTeamOffline(teamName: string): void {
|
||||
const state = this.stateByTeam.get(teamName);
|
||||
if (!state?.enabled) return;
|
||||
state.epoch += 1;
|
||||
this.resetAllTrackedTools(teamName, state.filesByPath);
|
||||
state.filesByPath.clear();
|
||||
state.refreshQueued = false;
|
||||
}
|
||||
|
||||
private getOrCreateState(teamName: string): TeamState {
|
||||
const existing = this.stateByTeam.get(teamName);
|
||||
if (existing) return existing;
|
||||
const created: TeamState = {
|
||||
enabled: false,
|
||||
epoch: 0,
|
||||
filesByPath: new Map(),
|
||||
refreshInFlight: false,
|
||||
refreshQueued: false,
|
||||
};
|
||||
this.stateByTeam.set(teamName, created);
|
||||
return created;
|
||||
}
|
||||
|
||||
private async enableTracking(teamName: string): Promise<void> {
|
||||
const state = this.getOrCreateState(teamName);
|
||||
if (state.enabled) {
|
||||
await this.refreshTeam(teamName);
|
||||
return;
|
||||
}
|
||||
state.enabled = true;
|
||||
state.epoch += 1;
|
||||
state.filesByPath.clear();
|
||||
state.refreshQueued = false;
|
||||
await this.logSourceTracker.setTracking(teamName, 'tool_activity', true);
|
||||
await this.refreshTeam(teamName);
|
||||
}
|
||||
|
||||
private async disableTracking(teamName: string): Promise<void> {
|
||||
const state = this.stateByTeam.get(teamName);
|
||||
if (!state) {
|
||||
await this.logSourceTracker.setTracking(teamName, 'tool_activity', false);
|
||||
return;
|
||||
}
|
||||
state.enabled = false;
|
||||
state.epoch += 1;
|
||||
this.resetAllTrackedTools(teamName, state.filesByPath);
|
||||
state.filesByPath.clear();
|
||||
state.refreshQueued = false;
|
||||
await this.logSourceTracker.setTracking(teamName, 'tool_activity', false);
|
||||
}
|
||||
|
||||
private async refreshTeam(teamName: string): Promise<void> {
|
||||
const state = this.getOrCreateState(teamName);
|
||||
if (!state.enabled) return;
|
||||
|
||||
if (state.refreshInFlight) {
|
||||
state.refreshQueued = true;
|
||||
return;
|
||||
}
|
||||
|
||||
state.refreshInFlight = true;
|
||||
try {
|
||||
do {
|
||||
state.refreshQueued = false;
|
||||
const expectedEpoch = state.epoch;
|
||||
await this.performRefresh(teamName, expectedEpoch);
|
||||
} while (state.enabled && state.refreshQueued);
|
||||
} finally {
|
||||
state.refreshInFlight = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async performRefresh(teamName: string, expectedEpoch: number): Promise<void> {
|
||||
const state = this.stateByTeam.get(teamName);
|
||||
if (!state?.enabled || state.epoch !== expectedEpoch) return;
|
||||
|
||||
const attributedFiles = await this.logsFinder.listAttributedSubagentFiles(teamName);
|
||||
const currentState = this.stateByTeam.get(teamName);
|
||||
if (!currentState?.enabled || currentState.epoch !== expectedEpoch) return;
|
||||
|
||||
const fileByPath = new Map(attributedFiles.map((file) => [file.filePath, file]));
|
||||
|
||||
for (const [filePath, fileState] of currentState.filesByPath.entries()) {
|
||||
if (fileByPath.has(filePath)) continue;
|
||||
this.emitTargetedReset(teamName, fileState.memberName, [...fileState.activeTools.keys()]);
|
||||
currentState.filesByPath.delete(filePath);
|
||||
}
|
||||
|
||||
for (const file of attributedFiles) {
|
||||
const liveState = this.stateByTeam.get(teamName);
|
||||
if (!liveState?.enabled || liveState.epoch !== expectedEpoch) return;
|
||||
|
||||
const existing = liveState.filesByPath.get(file.filePath);
|
||||
let stat;
|
||||
try {
|
||||
stat = await fs.stat(file.filePath);
|
||||
} catch {
|
||||
if (existing) {
|
||||
this.emitTargetedReset(teamName, existing.memberName, [...existing.activeTools.keys()]);
|
||||
liveState.filesByPath.delete(file.filePath);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (!stat.isFile()) continue;
|
||||
|
||||
const attributionChanged =
|
||||
existing &&
|
||||
(existing.memberName !== file.memberName || existing.sessionId !== file.sessionId);
|
||||
|
||||
if (!existing || attributionChanged) {
|
||||
const parsed = await this.parseFileSnapshot(file, stat.size, stat.mtimeMs);
|
||||
const latestState = this.stateByTeam.get(teamName);
|
||||
if (!latestState?.enabled || latestState.epoch !== expectedEpoch) return;
|
||||
if (existing) {
|
||||
this.emitTargetedReset(teamName, existing.memberName, [...existing.activeTools.keys()]);
|
||||
}
|
||||
latestState.filesByPath.set(
|
||||
file.filePath,
|
||||
this.applyParsedSnapshot(
|
||||
teamName,
|
||||
file,
|
||||
attributionChanged ? null : (existing ?? null),
|
||||
parsed
|
||||
)
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (stat.size < existing.lastSize) {
|
||||
const parsed = await this.parseFileSnapshot(file, stat.size, stat.mtimeMs);
|
||||
const latestState = this.stateByTeam.get(teamName);
|
||||
if (!latestState?.enabled || latestState.epoch !== expectedEpoch) return;
|
||||
latestState.filesByPath.set(
|
||||
file.filePath,
|
||||
this.applyParsedSnapshot(teamName, file, existing, parsed)
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (stat.size === existing.lastSize && stat.mtimeMs === existing.lastMtimeMs) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (stat.size === existing.lastSize) {
|
||||
const parsed = await this.parseFileSnapshot(file, stat.size, stat.mtimeMs);
|
||||
const latestState = this.stateByTeam.get(teamName);
|
||||
if (!latestState?.enabled || latestState.epoch !== expectedEpoch) return;
|
||||
latestState.filesByPath.set(
|
||||
file.filePath,
|
||||
this.applyParsedSnapshot(teamName, file, existing, parsed)
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
const nextState = await this.applyDelta(teamName, file, existing, stat.size, stat.mtimeMs);
|
||||
const latestState = this.stateByTeam.get(teamName);
|
||||
if (!latestState?.enabled || latestState.epoch !== expectedEpoch) return;
|
||||
latestState.filesByPath.set(file.filePath, nextState);
|
||||
}
|
||||
}
|
||||
|
||||
private async parseFileSnapshot(
|
||||
file: AttributedSubagentFile,
|
||||
size: number,
|
||||
mtimeMs: number
|
||||
): Promise<ParsedFileSnapshot> {
|
||||
const content = await fs.readFile(file.filePath, 'utf8').catch(() => '');
|
||||
const { lines, carry } = splitJsonLines(content);
|
||||
const activeTools = new Map<string, ActiveToolCall>();
|
||||
const seenFinished = new Set<string>();
|
||||
|
||||
for (const line of lines) {
|
||||
this.consumeJsonLine(line, file, activeTools, seenFinished);
|
||||
}
|
||||
|
||||
return {
|
||||
lastSize: size,
|
||||
lastMtimeMs: mtimeMs,
|
||||
lineCarry: carry,
|
||||
activeTools,
|
||||
seenFinished,
|
||||
};
|
||||
}
|
||||
|
||||
private applyParsedSnapshot(
|
||||
teamName: string,
|
||||
file: AttributedSubagentFile,
|
||||
existing: FileState | null,
|
||||
parsed: ParsedFileSnapshot
|
||||
): FileState {
|
||||
const previousActive = existing?.activeTools ?? new Map<string, ActiveToolCall>();
|
||||
const nextActiveIds = new Set(parsed.activeTools.keys());
|
||||
const removedIds = [...previousActive.keys()].filter(
|
||||
(toolUseId) => !nextActiveIds.has(toolUseId)
|
||||
);
|
||||
if (removedIds.length > 0 && existing) {
|
||||
this.emitTargetedReset(teamName, existing.memberName, removedIds);
|
||||
}
|
||||
|
||||
for (const [toolUseId, activity] of parsed.activeTools.entries()) {
|
||||
if (previousActive.has(toolUseId)) continue;
|
||||
this.emitStart(teamName, activity);
|
||||
}
|
||||
|
||||
return {
|
||||
memberName: file.memberName,
|
||||
sessionId: file.sessionId,
|
||||
lastSize: parsed.lastSize,
|
||||
lastMtimeMs: parsed.lastMtimeMs,
|
||||
lineCarry: parsed.lineCarry,
|
||||
activeTools: parsed.activeTools,
|
||||
seenFinished: parsed.seenFinished,
|
||||
};
|
||||
}
|
||||
|
||||
private async applyDelta(
|
||||
teamName: string,
|
||||
file: AttributedSubagentFile,
|
||||
fileState: FileState,
|
||||
nextSize: number,
|
||||
nextMtimeMs: number
|
||||
): Promise<FileState> {
|
||||
const nextActiveTools = new Map(fileState.activeTools);
|
||||
const nextSeenFinished = new Set(fileState.seenFinished);
|
||||
const appendedChunk = await readAppendedChunk(file.filePath, fileState.lastSize, nextSize);
|
||||
const { lines, carry } = splitJsonLines(fileState.lineCarry + appendedChunk);
|
||||
|
||||
for (const line of lines) {
|
||||
this.consumeJsonLine(line, file, nextActiveTools, nextSeenFinished, {
|
||||
emitStart: (activity) => this.emitStart(teamName, activity),
|
||||
emitFinish: (activity, result) => this.emitFinish(teamName, activity, result),
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
memberName: fileState.memberName,
|
||||
sessionId: fileState.sessionId,
|
||||
lastSize: nextSize,
|
||||
lastMtimeMs: nextMtimeMs,
|
||||
lineCarry: carry,
|
||||
activeTools: nextActiveTools,
|
||||
seenFinished: nextSeenFinished,
|
||||
};
|
||||
}
|
||||
|
||||
private consumeJsonLine(
|
||||
line: string,
|
||||
file: AttributedSubagentFile,
|
||||
activeTools: Map<string, ActiveToolCall>,
|
||||
seenFinished: Set<string>,
|
||||
emitters?: {
|
||||
emitStart?: (activity: ActiveToolCall) => void;
|
||||
emitFinish?: (activity: ActiveToolCall, result: FinishPayload) => void;
|
||||
}
|
||||
): void {
|
||||
let entry: Record<string, unknown>;
|
||||
try {
|
||||
entry = JSON.parse(line) as Record<string, unknown>;
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
const timestamp = extractEntryTimestamp(entry) ?? new Date().toISOString();
|
||||
const content = extractEntryContent(entry);
|
||||
if (!content) return;
|
||||
|
||||
for (const block of content) {
|
||||
if (!block || typeof block !== 'object') continue;
|
||||
const typedBlock = block as Record<string, unknown>;
|
||||
if (typedBlock.type === 'tool_use') {
|
||||
const rawId = typeof typedBlock.id === 'string' ? typedBlock.id.trim() : '';
|
||||
if (!rawId) continue;
|
||||
const toolUseId = buildCompositeToolUseId(file.sessionId, rawId);
|
||||
if (activeTools.has(toolUseId) || seenFinished.has(toolUseId)) continue;
|
||||
const toolName = typeof typedBlock.name === 'string' ? typedBlock.name : 'Tool';
|
||||
const input =
|
||||
typedBlock.input && typeof typedBlock.input === 'object'
|
||||
? (typedBlock.input as Record<string, unknown>)
|
||||
: {};
|
||||
const activity: ActiveToolCall = {
|
||||
memberName: file.memberName,
|
||||
toolUseId,
|
||||
toolName,
|
||||
preview: extractToolPreview(toolName, input),
|
||||
startedAt: timestamp,
|
||||
source: 'member_log',
|
||||
state: 'running',
|
||||
};
|
||||
activeTools.set(toolUseId, activity);
|
||||
emitters?.emitStart?.(activity);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (typedBlock.type !== 'tool_result' || typeof typedBlock.tool_use_id !== 'string') continue;
|
||||
const toolUseId = buildCompositeToolUseId(file.sessionId, typedBlock.tool_use_id);
|
||||
const active = activeTools.get(toolUseId);
|
||||
if (active) {
|
||||
activeTools.delete(toolUseId);
|
||||
pushBoundedSetValue(seenFinished, toolUseId, MAX_SEEN_FINISHED_IDS);
|
||||
emitters?.emitFinish?.(active, {
|
||||
finishedAt: timestamp,
|
||||
resultPreview: extractToolResultPreview(typedBlock.content),
|
||||
isError: typedBlock.is_error === true,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
pushBoundedSetValue(seenFinished, toolUseId, MAX_SEEN_FINISHED_IDS);
|
||||
}
|
||||
}
|
||||
|
||||
private emitStart(teamName: string, activity: ActiveToolCall): void {
|
||||
const payload: ToolActivityEventPayload = {
|
||||
action: 'start',
|
||||
activity: {
|
||||
memberName: activity.memberName,
|
||||
toolUseId: activity.toolUseId,
|
||||
toolName: activity.toolName,
|
||||
preview: activity.preview,
|
||||
startedAt: activity.startedAt,
|
||||
source: activity.source,
|
||||
},
|
||||
};
|
||||
this.emitTeamChange({
|
||||
type: 'tool-activity',
|
||||
teamName,
|
||||
detail: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
private emitFinish(teamName: string, activity: ActiveToolCall, result: FinishPayload): void {
|
||||
const payload: ToolActivityEventPayload = {
|
||||
action: 'finish',
|
||||
memberName: activity.memberName,
|
||||
toolUseId: activity.toolUseId,
|
||||
finishedAt: result.finishedAt,
|
||||
resultPreview: result.resultPreview,
|
||||
isError: result.isError,
|
||||
};
|
||||
this.emitTeamChange({
|
||||
type: 'tool-activity',
|
||||
teamName,
|
||||
detail: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
private emitTargetedReset(teamName: string, memberName: string, toolUseIds: string[]): void {
|
||||
if (toolUseIds.length === 0) return;
|
||||
const payload: ToolActivityEventPayload = {
|
||||
action: 'reset',
|
||||
memberName,
|
||||
toolUseIds,
|
||||
};
|
||||
this.emitTeamChange({
|
||||
type: 'tool-activity',
|
||||
teamName,
|
||||
detail: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
private resetAllTrackedTools(teamName: string, filesByPath: Map<string, FileState>): void {
|
||||
for (const fileState of filesByPath.values()) {
|
||||
this.emitTargetedReset(teamName, fileState.memberName, [...fileState.activeTools.keys()]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
interface FinishPayload {
|
||||
finishedAt: string;
|
||||
resultPreview?: string;
|
||||
isError?: boolean;
|
||||
}
|
||||
|
||||
function buildCompositeToolUseId(sessionId: string, rawToolUseId: string): string {
|
||||
return `member_log:${sessionId}:${rawToolUseId}`;
|
||||
}
|
||||
|
||||
function extractEntryContent(entry: Record<string, unknown>): Record<string, unknown>[] | null {
|
||||
if (Array.isArray(entry.content)) return entry.content as Record<string, unknown>[];
|
||||
const message = entry.message;
|
||||
if (
|
||||
message &&
|
||||
typeof message === 'object' &&
|
||||
Array.isArray((message as { content?: unknown[] }).content)
|
||||
) {
|
||||
return (message as { content: Record<string, unknown>[] }).content;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function extractEntryTimestamp(entry: Record<string, unknown>): string | null {
|
||||
if (typeof entry.timestamp === 'string' && entry.timestamp.trim().length > 0) {
|
||||
return entry.timestamp;
|
||||
}
|
||||
const message = entry.message;
|
||||
if (
|
||||
message &&
|
||||
typeof message === 'object' &&
|
||||
typeof (message as { timestamp?: unknown }).timestamp === 'string'
|
||||
) {
|
||||
return (message as { timestamp: string }).timestamp;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function splitJsonLines(text: string): { lines: string[]; carry: string } {
|
||||
const normalized = text.replace(/\r\n/g, '\n');
|
||||
const rawParts = normalized.split('\n');
|
||||
let carry = rawParts.pop() ?? '';
|
||||
const lines = rawParts.map((part) => part.trim()).filter((part) => part.length > 0);
|
||||
const trimmedCarry = carry.trim();
|
||||
if (trimmedCarry.length > 0) {
|
||||
try {
|
||||
JSON.parse(trimmedCarry);
|
||||
lines.push(trimmedCarry);
|
||||
carry = '';
|
||||
} catch {
|
||||
carry = trimmedCarry;
|
||||
}
|
||||
} else {
|
||||
carry = '';
|
||||
}
|
||||
return { lines, carry };
|
||||
}
|
||||
|
||||
async function readAppendedChunk(filePath: string, start: number, end: number): Promise<string> {
|
||||
if (end <= start) return '';
|
||||
const length = end - start;
|
||||
const handle = await fs.open(filePath, 'r');
|
||||
try {
|
||||
const buffer = Buffer.alloc(length);
|
||||
await handle.read(buffer, 0, length, start);
|
||||
return buffer.toString('utf8');
|
||||
} finally {
|
||||
await handle.close().catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
function pushBoundedSetValue(set: Set<string>, value: string, limit: number): void {
|
||||
if (set.has(value)) {
|
||||
set.delete(value);
|
||||
}
|
||||
set.add(value);
|
||||
while (set.size > limit) {
|
||||
const oldest = set.values().next().value;
|
||||
if (!oldest) break;
|
||||
set.delete(oldest);
|
||||
}
|
||||
}
|
||||
|
|
@ -20,6 +20,7 @@ export { TeamLogSourceTracker } from './TeamLogSourceTracker';
|
|||
export { TeamMemberLogsFinder } from './TeamMemberLogsFinder';
|
||||
export { TeamMemberResolver } from './TeamMemberResolver';
|
||||
export { TeamMembersMetaStore } from './TeamMembersMetaStore';
|
||||
export { TeammateToolTracker } from './TeammateToolTracker';
|
||||
export { TeamProvisioningService } from './TeamProvisioningService';
|
||||
export { TeamSentMessagesStore } from './TeamSentMessagesStore';
|
||||
export { TeamTaskReader } from './TeamTaskReader';
|
||||
|
|
|
|||
|
|
@ -216,6 +216,9 @@ export const TEAM_GET_TASK_CHANGE_PRESENCE = 'team:getTaskChangePresence';
|
|||
/** Enable or disable task change presence tracking for a visible team tab */
|
||||
export const TEAM_SET_CHANGE_PRESENCE_TRACKING = 'team:setChangePresenceTracking';
|
||||
|
||||
/** Enable or disable live teammate tool activity tracking for a visible team tab */
|
||||
export const TEAM_SET_TOOL_ACTIVITY_TRACKING = 'team:setToolActivityTracking';
|
||||
|
||||
/** Get buffered Claude CLI logs (paged, newest-first) */
|
||||
export const TEAM_GET_CLAUDE_LOGS = 'team:getClaudeLogs';
|
||||
|
||||
|
|
|
|||
|
|
@ -150,6 +150,7 @@ import {
|
|||
TEAM_SAVE_TASK_ATTACHMENT,
|
||||
TEAM_SEND_MESSAGE,
|
||||
TEAM_SET_CHANGE_PRESENCE_TRACKING,
|
||||
TEAM_SET_TOOL_ACTIVITY_TRACKING,
|
||||
TEAM_SET_TASK_CLARIFICATION,
|
||||
TEAM_SHOW_MESSAGE_NOTIFICATION,
|
||||
TEAM_SOFT_DELETE_TASK,
|
||||
|
|
@ -812,6 +813,9 @@ const electronAPI: ElectronAPI = {
|
|||
setChangePresenceTracking: async (teamName: string, enabled: boolean) => {
|
||||
return invokeIpcWithResult<void>(TEAM_SET_CHANGE_PRESENCE_TRACKING, teamName, enabled);
|
||||
},
|
||||
setToolActivityTracking: async (teamName: string, enabled: boolean) => {
|
||||
return invokeIpcWithResult<void>(TEAM_SET_TOOL_ACTIVITY_TRACKING, teamName, enabled);
|
||||
},
|
||||
getClaudeLogs: async (teamName: string, query?: TeamClaudeLogsQuery) => {
|
||||
return invokeIpcWithResult<TeamClaudeLogsResponse>(TEAM_GET_CLAUDE_LOGS, teamName, query);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -676,6 +676,9 @@ export class HttpAPIClient implements ElectronAPI {
|
|||
setChangePresenceTracking: async (): Promise<void> => {
|
||||
// Not available in browser mode — no-op.
|
||||
},
|
||||
setToolActivityTracking: async (): Promise<void> => {
|
||||
// Not available in browser mode — no-op.
|
||||
},
|
||||
getClaudeLogs: async (
|
||||
_teamName: string,
|
||||
_query?: TeamClaudeLogsQuery
|
||||
|
|
|
|||
|
|
@ -54,6 +54,7 @@ import {
|
|||
Command,
|
||||
ListPlus,
|
||||
Maximize2,
|
||||
MoveRight,
|
||||
RefreshCw,
|
||||
Reply,
|
||||
X,
|
||||
|
|
@ -835,9 +836,7 @@ export const ActivityItem = memo(
|
|||
{/* Recipient — arrow + avatar + badge */}
|
||||
{message.to && message.to !== message.from ? (
|
||||
<>
|
||||
<span style={{ color: CARD_ICON_MUTED }} className="text-[10px]">
|
||||
→
|
||||
</span>
|
||||
<MoveRight size={10} style={{ color: CARD_ICON_MUTED }} className="shrink-0" />
|
||||
{crossTeamTarget ? (
|
||||
<CrossTeamTeamBadge teamName={crossTeamTarget} onClick={onTeamClick} />
|
||||
) : null}
|
||||
|
|
|
|||
|
|
@ -277,6 +277,21 @@ export function initializeNotificationListeners(): () => void {
|
|||
delete nextTeamState[memberName];
|
||||
return nextTeamState;
|
||||
};
|
||||
const removeMemberToolEntries = (
|
||||
teamState: Record<string, Record<string, ActiveToolCall>> | undefined,
|
||||
memberName: string,
|
||||
toolUseIds: readonly string[]
|
||||
): Record<string, Record<string, ActiveToolCall>> => {
|
||||
if (!teamState?.[memberName] || toolUseIds.length === 0) return teamState ?? {};
|
||||
let nextTeamState = teamState ?? {};
|
||||
let changed = false;
|
||||
for (const toolUseId of toolUseIds) {
|
||||
if (!nextTeamState[memberName]?.[toolUseId]) continue;
|
||||
nextTeamState = removeMemberToolEntry(nextTeamState, memberName, toolUseId);
|
||||
changed = true;
|
||||
}
|
||||
return changed ? nextTeamState : (teamState ?? {});
|
||||
};
|
||||
const getBaseProjectId = (projectId: string | null | undefined): string | null => {
|
||||
if (!projectId) return null;
|
||||
const separatorIndex = projectId.indexOf('::');
|
||||
|
|
@ -461,6 +476,19 @@ export function initializeNotificationListeners(): () => void {
|
|||
return new Set([selectedTeamName]);
|
||||
};
|
||||
|
||||
const getTrackedToolActivityTeams = (): Set<string> => {
|
||||
const { paneLayout } = useStore.getState();
|
||||
const tracked = new Set<string>();
|
||||
for (const pane of paneLayout.panes) {
|
||||
if (!pane.activeTabId) continue;
|
||||
const activeTab = pane.tabs.find((tab) => tab.id === pane.activeTabId);
|
||||
if (activeTab?.type === 'team' && activeTab.teamName) {
|
||||
tracked.add(activeTab.teamName);
|
||||
}
|
||||
}
|
||||
return tracked;
|
||||
};
|
||||
|
||||
if (ENABLE_AUTO_TEAM_CHANGE_PRESENCE_TRACKING && api.teams?.setChangePresenceTracking) {
|
||||
let trackedTeamNames = new Set<string>();
|
||||
const syncVisibleTeamTracking = (): void => {
|
||||
|
|
@ -503,6 +531,44 @@ export function initializeNotificationListeners(): () => void {
|
|||
});
|
||||
}
|
||||
|
||||
if (api.teams?.setToolActivityTracking) {
|
||||
let trackedTeamNames = new Set<string>();
|
||||
const syncVisibleTeamTracking = (): void => {
|
||||
const nextTrackedTeamNames = getTrackedToolActivityTeams();
|
||||
|
||||
for (const teamName of nextTrackedTeamNames) {
|
||||
if (!trackedTeamNames.has(teamName)) {
|
||||
void api.teams.setToolActivityTracking(teamName, true).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
for (const teamName of trackedTeamNames) {
|
||||
if (!nextTrackedTeamNames.has(teamName)) {
|
||||
void api.teams.setToolActivityTracking(teamName, false).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
trackedTeamNames = nextTrackedTeamNames;
|
||||
};
|
||||
|
||||
syncVisibleTeamTracking();
|
||||
|
||||
const unsubscribeVisibleTeamTracking = useStore.subscribe((state, prevState) => {
|
||||
if (state.paneLayout === prevState.paneLayout) {
|
||||
return;
|
||||
}
|
||||
syncVisibleTeamTracking();
|
||||
});
|
||||
|
||||
cleanupFns.push(() => {
|
||||
unsubscribeVisibleTeamTracking();
|
||||
for (const teamName of trackedTeamNames) {
|
||||
void api.teams.setToolActivityTracking(teamName, false).catch(() => undefined);
|
||||
}
|
||||
trackedTeamNames.clear();
|
||||
});
|
||||
}
|
||||
|
||||
// Listen for task-list file changes to refresh currently viewed session metadata
|
||||
if (api.onTodoChange) {
|
||||
const cleanup = api.onTodoChange((event) => {
|
||||
|
|
@ -801,6 +867,10 @@ export function initializeNotificationListeners(): () => void {
|
|||
} else if (payload.action === 'reset') {
|
||||
if (payload.memberName) {
|
||||
const memberName = payload.memberName;
|
||||
const toolUseIds =
|
||||
Array.isArray(payload.toolUseIds) && payload.toolUseIds.length > 0
|
||||
? payload.toolUseIds
|
||||
: null;
|
||||
useStore.setState((prev) => {
|
||||
if (!prev.activeToolsByTeam[event.teamName]?.[memberName]) {
|
||||
return {};
|
||||
|
|
@ -808,10 +878,13 @@ export function initializeNotificationListeners(): () => void {
|
|||
return {
|
||||
activeToolsByTeam: {
|
||||
...prev.activeToolsByTeam,
|
||||
[event.teamName]: removeMemberToolGroup(
|
||||
prev.activeToolsByTeam[event.teamName],
|
||||
memberName
|
||||
),
|
||||
[event.teamName]: toolUseIds
|
||||
? removeMemberToolEntries(
|
||||
prev.activeToolsByTeam[event.teamName],
|
||||
memberName,
|
||||
toolUseIds
|
||||
)
|
||||
: removeMemberToolGroup(prev.activeToolsByTeam[event.teamName], memberName),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
|
|
|||
|
|
@ -39,6 +39,11 @@ const sanitizeSchema: SanitizeSchema = {
|
|||
// Allow title on abbr (for tooltip definitions)
|
||||
abbr: [...(defaultSchema.attributes?.abbr ?? []), 'title'],
|
||||
},
|
||||
protocols: {
|
||||
...defaultSchema.protocols,
|
||||
// Allow internal-only protocols used for mention badges, team badges, and task tooltips
|
||||
href: [...(defaultSchema.protocols?.href ?? []), 'mention', 'team', 'task'],
|
||||
},
|
||||
};
|
||||
|
||||
/** Full plugin chain: raw HTML → sanitize → syntax highlighting */
|
||||
|
|
|
|||
|
|
@ -419,6 +419,7 @@ export interface TeamsAPI {
|
|||
getData: (teamName: string) => Promise<TeamData>;
|
||||
getTaskChangePresence: (teamName: string) => Promise<Record<string, TaskChangePresenceState>>;
|
||||
setChangePresenceTracking: (teamName: string, enabled: boolean) => Promise<void>;
|
||||
setToolActivityTracking: (teamName: string, enabled: boolean) => Promise<void>;
|
||||
getClaudeLogs: (teamName: string, query?: TeamClaudeLogsQuery) => Promise<TeamClaudeLogsResponse>;
|
||||
deleteTeam: (teamName: string) => Promise<void>;
|
||||
restoreTeam: (teamName: string) => Promise<void>;
|
||||
|
|
|
|||
|
|
@ -308,7 +308,7 @@ export interface ToolCallMeta {
|
|||
toolUseId?: string;
|
||||
}
|
||||
|
||||
export type ToolActivitySource = 'runtime' | 'inbox';
|
||||
export type ToolActivitySource = 'runtime' | 'member_log' | 'inbox';
|
||||
export type ToolActivityState = 'running' | 'complete' | 'error';
|
||||
|
||||
/** Live or recently finished tool activity for one team member. */
|
||||
|
|
@ -337,6 +337,7 @@ export interface ToolActivityEventPayload {
|
|||
};
|
||||
memberName?: string;
|
||||
toolUseId?: string;
|
||||
toolUseIds?: string[];
|
||||
finishedAt?: string;
|
||||
resultPreview?: string;
|
||||
isError?: boolean;
|
||||
|
|
|
|||
|
|
@ -93,6 +93,7 @@ export function extractToolPreview(
|
|||
case 'Glob':
|
||||
return typeof input.pattern === 'string' ? truncateStr(input.pattern, 40) : undefined;
|
||||
case 'Agent':
|
||||
case 'Task':
|
||||
case 'TaskCreate':
|
||||
return typeof input.prompt === 'string'
|
||||
? input.prompt
|
||||
|
|
@ -116,3 +117,34 @@ export function extractToolPreview(
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
function flattenToolResultContent(content: unknown): string[] {
|
||||
if (typeof content === 'string') {
|
||||
return [content];
|
||||
}
|
||||
|
||||
if (!Array.isArray(content)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const parts: string[] = [];
|
||||
for (const item of content) {
|
||||
if (!item || typeof item !== 'object') continue;
|
||||
const block = item as Record<string, unknown>;
|
||||
if (typeof block.text === 'string') {
|
||||
parts.push(block.text);
|
||||
continue;
|
||||
}
|
||||
if (typeof block.content === 'string') {
|
||||
parts.push(block.content);
|
||||
}
|
||||
}
|
||||
return parts;
|
||||
}
|
||||
|
||||
/** Extract a short human-readable preview from tool_result content. */
|
||||
export function extractToolResultPreview(content: unknown, max = 80): string | undefined {
|
||||
const joined = flattenToolResultContent(content).join(' ').replace(/\s+/g, ' ').trim();
|
||||
if (!joined) return undefined;
|
||||
return truncateStr(joined, max);
|
||||
}
|
||||
|
|
|
|||
375
test/main/services/team/TeammateToolTracker.test.ts
Normal file
375
test/main/services/team/TeammateToolTracker.test.ts
Normal file
|
|
@ -0,0 +1,375 @@
|
|||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { appendFile, mkdtemp, mkdir, rm, writeFile } from 'fs/promises';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
|
||||
import { TeammateToolTracker } from '@main/services/team/TeammateToolTracker';
|
||||
|
||||
import type { TeamChangeEvent } from '@shared/types';
|
||||
|
||||
interface Deferred<T> {
|
||||
promise: Promise<T>;
|
||||
resolve: (value: T) => void;
|
||||
reject: (reason?: unknown) => void;
|
||||
}
|
||||
|
||||
async function createSubagentLog(
|
||||
rootDir: string,
|
||||
sessionId: string,
|
||||
fileName = 'agent-worker.jsonl'
|
||||
): Promise<string> {
|
||||
const subagentsDir = path.join(rootDir, sessionId, 'subagents');
|
||||
await mkdir(subagentsDir, { recursive: true });
|
||||
const filePath = path.join(subagentsDir, fileName);
|
||||
await writeFile(filePath, '', 'utf8');
|
||||
return filePath;
|
||||
}
|
||||
|
||||
async function waitForCondition(check: () => void, attempts = 20): Promise<void> {
|
||||
let lastError: unknown;
|
||||
for (let attempt = 0; attempt < attempts; attempt += 1) {
|
||||
try {
|
||||
check();
|
||||
return;
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
}
|
||||
}
|
||||
throw lastError instanceof Error ? lastError : new Error(String(lastError));
|
||||
}
|
||||
|
||||
function createDeferred<T>(): Deferred<T> {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (reason?: unknown) => void;
|
||||
const promise = new Promise<T>((res, rej) => {
|
||||
resolve = res;
|
||||
reject = rej;
|
||||
});
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
describe('TeammateToolTracker', () => {
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true })));
|
||||
});
|
||||
|
||||
it('emits unresolved teammate tools on initial enable', async () => {
|
||||
const rootDir = await mkdtemp(path.join(os.tmpdir(), 'teammate-tool-tracker-'));
|
||||
tempDirs.push(rootDir);
|
||||
const filePath = await createSubagentLog(rootDir, 'session-a');
|
||||
|
||||
await writeFile(
|
||||
filePath,
|
||||
`${JSON.stringify({
|
||||
timestamp: '2026-03-28T10:00:00.000Z',
|
||||
type: 'assistant',
|
||||
content: [{ type: 'tool_use', id: 'tool-1', name: 'Read', input: { file_path: 'src/index.ts' } }],
|
||||
})}\n`,
|
||||
'utf8'
|
||||
);
|
||||
|
||||
const listAttributedSubagentFiles = vi.fn(async () => [
|
||||
{
|
||||
memberName: 'alice',
|
||||
sessionId: 'session-a',
|
||||
filePath,
|
||||
mtimeMs: Date.now(),
|
||||
},
|
||||
]);
|
||||
const setTracking = vi.fn(async () => ({
|
||||
projectFingerprint: null,
|
||||
logSourceGeneration: null,
|
||||
}));
|
||||
const events: TeamChangeEvent[] = [];
|
||||
|
||||
const tracker = new TeammateToolTracker(
|
||||
{ listAttributedSubagentFiles } as never,
|
||||
{ setTracking } as never,
|
||||
(event) => events.push(event)
|
||||
);
|
||||
|
||||
await tracker.setTracking('my-team', true);
|
||||
|
||||
expect(setTracking).toHaveBeenCalledWith('my-team', 'tool_activity', true);
|
||||
expect(events).toHaveLength(1);
|
||||
const payload = JSON.parse(events[0].detail ?? '');
|
||||
expect(payload).toMatchObject({
|
||||
action: 'start',
|
||||
activity: {
|
||||
memberName: 'alice',
|
||||
toolUseId: 'member_log:session-a:tool-1',
|
||||
toolName: 'Read',
|
||||
source: 'member_log',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('emits finish when appended tool_result arrives and preserves chunk carry across boundaries', async () => {
|
||||
const rootDir = await mkdtemp(path.join(os.tmpdir(), 'teammate-tool-tracker-'));
|
||||
tempDirs.push(rootDir);
|
||||
const filePath = await createSubagentLog(rootDir, 'session-b');
|
||||
|
||||
await writeFile(
|
||||
filePath,
|
||||
`${JSON.stringify({
|
||||
timestamp: '2026-03-28T10:00:00.000Z',
|
||||
type: 'assistant',
|
||||
content: [{ type: 'tool_use', id: 'tool-1', name: 'Read', input: { file_path: 'src/index.ts' } }],
|
||||
})}\n`,
|
||||
'utf8'
|
||||
);
|
||||
|
||||
const listAttributedSubagentFiles = vi.fn(async () => [
|
||||
{
|
||||
memberName: 'alice',
|
||||
sessionId: 'session-b',
|
||||
filePath,
|
||||
mtimeMs: Date.now(),
|
||||
},
|
||||
]);
|
||||
const events: TeamChangeEvent[] = [];
|
||||
const tracker = new TeammateToolTracker(
|
||||
{ listAttributedSubagentFiles } as never,
|
||||
{ setTracking: vi.fn(async () => ({ projectFingerprint: null, logSourceGeneration: null })) } as never,
|
||||
(event) => events.push(event)
|
||||
);
|
||||
|
||||
await tracker.setTracking('my-team', true);
|
||||
expect(events).toHaveLength(1);
|
||||
|
||||
const resultLine = JSON.stringify({
|
||||
timestamp: '2026-03-28T10:00:01.000Z',
|
||||
type: 'user',
|
||||
content: [{ type: 'tool_result', tool_use_id: 'tool-1', content: 'done' }],
|
||||
});
|
||||
const splitAt = Math.floor(resultLine.length / 2);
|
||||
await appendFile(filePath, resultLine.slice(0, splitAt), 'utf8');
|
||||
tracker.handleLogSourceChange('my-team');
|
||||
await waitForCondition(() => {
|
||||
expect(events).toHaveLength(1);
|
||||
});
|
||||
|
||||
await appendFile(filePath, `${resultLine.slice(splitAt)}\n`, 'utf8');
|
||||
tracker.handleLogSourceChange('my-team');
|
||||
await waitForCondition(() => {
|
||||
expect(events).toHaveLength(2);
|
||||
});
|
||||
const payload = JSON.parse(events[1].detail ?? '');
|
||||
expect(payload).toMatchObject({
|
||||
action: 'finish',
|
||||
memberName: 'alice',
|
||||
toolUseId: 'member_log:session-b:tool-1',
|
||||
resultPreview: 'done',
|
||||
});
|
||||
});
|
||||
|
||||
it('resets only removed file tools when one of multiple files disappears', async () => {
|
||||
const rootDir = await mkdtemp(path.join(os.tmpdir(), 'teammate-tool-tracker-'));
|
||||
tempDirs.push(rootDir);
|
||||
const firstFile = await createSubagentLog(rootDir, 'session-c', 'agent-a.jsonl');
|
||||
const secondFile = await createSubagentLog(rootDir, 'session-d', 'agent-b.jsonl');
|
||||
|
||||
const runningLine = (toolId: string) =>
|
||||
`${JSON.stringify({
|
||||
timestamp: '2026-03-28T10:00:00.000Z',
|
||||
type: 'assistant',
|
||||
content: [{ type: 'tool_use', id: toolId, name: 'Read', input: { file_path: `${toolId}.ts` } }],
|
||||
})}\n`;
|
||||
|
||||
await writeFile(firstFile, runningLine('tool-a'), 'utf8');
|
||||
await writeFile(secondFile, runningLine('tool-b'), 'utf8');
|
||||
|
||||
const attributedFiles = [
|
||||
{ memberName: 'alice', sessionId: 'session-c', filePath: firstFile, mtimeMs: Date.now() },
|
||||
{ memberName: 'alice', sessionId: 'session-d', filePath: secondFile, mtimeMs: Date.now() },
|
||||
];
|
||||
const listAttributedSubagentFiles = vi.fn(async () => [...attributedFiles]);
|
||||
const events: TeamChangeEvent[] = [];
|
||||
const tracker = new TeammateToolTracker(
|
||||
{ listAttributedSubagentFiles } as never,
|
||||
{ setTracking: vi.fn(async () => ({ projectFingerprint: null, logSourceGeneration: null })) } as never,
|
||||
(event) => events.push(event)
|
||||
);
|
||||
|
||||
await tracker.setTracking('my-team', true);
|
||||
expect(events).toHaveLength(2);
|
||||
|
||||
attributedFiles.shift();
|
||||
tracker.handleLogSourceChange('my-team');
|
||||
await waitForCondition(() => {
|
||||
expect(events).toHaveLength(3);
|
||||
});
|
||||
|
||||
const payload = JSON.parse(events[2].detail ?? '');
|
||||
expect(payload).toMatchObject({
|
||||
action: 'reset',
|
||||
memberName: 'alice',
|
||||
toolUseIds: ['member_log:session-c:tool-a'],
|
||||
});
|
||||
});
|
||||
|
||||
it('resets truncated file tools and replays only currently unresolved tools after full resync', async () => {
|
||||
const rootDir = await mkdtemp(path.join(os.tmpdir(), 'teammate-tool-tracker-'));
|
||||
tempDirs.push(rootDir);
|
||||
const filePath = await createSubagentLog(rootDir, 'session-e');
|
||||
|
||||
const toolALine = JSON.stringify({
|
||||
timestamp: '2026-03-28T10:00:00.000Z',
|
||||
type: 'assistant',
|
||||
content: [{ type: 'tool_use', id: 'tool-a', name: 'Read', input: { file_path: 'a.ts' } }],
|
||||
});
|
||||
const toolAResult = JSON.stringify({
|
||||
timestamp: '2026-03-28T10:00:01.000Z',
|
||||
type: 'user',
|
||||
content: [{ type: 'tool_result', tool_use_id: 'tool-a', content: 'done-a' }],
|
||||
});
|
||||
await writeFile(filePath, `${toolALine}\n${toolAResult}\n`, 'utf8');
|
||||
|
||||
const listAttributedSubagentFiles = vi.fn(async () => [
|
||||
{
|
||||
memberName: 'alice',
|
||||
sessionId: 'session-e',
|
||||
filePath,
|
||||
mtimeMs: Date.now(),
|
||||
},
|
||||
]);
|
||||
const events: TeamChangeEvent[] = [];
|
||||
const tracker = new TeammateToolTracker(
|
||||
{ listAttributedSubagentFiles } as never,
|
||||
{ setTracking: vi.fn(async () => ({ projectFingerprint: null, logSourceGeneration: null })) } as never,
|
||||
(event) => events.push(event)
|
||||
);
|
||||
|
||||
await tracker.setTracking('my-team', true);
|
||||
expect(events).toHaveLength(0);
|
||||
|
||||
const toolBLine = JSON.stringify({
|
||||
timestamp: '2026-03-28T10:00:02.000Z',
|
||||
type: 'assistant',
|
||||
content: [{ type: 'tool_use', id: 'tool-b', name: 'Bash', input: { command: 'echo ok' } }],
|
||||
});
|
||||
await writeFile(filePath, `${toolBLine}\n`, 'utf8');
|
||||
|
||||
tracker.handleLogSourceChange('my-team');
|
||||
await waitForCondition(() => {
|
||||
expect(events).toHaveLength(1);
|
||||
});
|
||||
|
||||
const payload = JSON.parse(events[0].detail ?? '');
|
||||
expect(payload).toMatchObject({
|
||||
action: 'start',
|
||||
activity: {
|
||||
memberName: 'alice',
|
||||
toolUseId: 'member_log:session-e:tool-b',
|
||||
toolName: 'Bash',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('resets old ownership and replays unresolved tools when file attribution changes', async () => {
|
||||
const rootDir = await mkdtemp(path.join(os.tmpdir(), 'teammate-tool-tracker-'));
|
||||
tempDirs.push(rootDir);
|
||||
const filePath = await createSubagentLog(rootDir, 'session-f');
|
||||
|
||||
await writeFile(
|
||||
filePath,
|
||||
`${JSON.stringify({
|
||||
timestamp: '2026-03-28T10:00:00.000Z',
|
||||
type: 'assistant',
|
||||
content: [{ type: 'tool_use', id: 'tool-1', name: 'Read', input: { file_path: 'src/index.ts' } }],
|
||||
})}\n`,
|
||||
'utf8'
|
||||
);
|
||||
|
||||
let currentMemberName = 'alice';
|
||||
const listAttributedSubagentFiles = vi.fn(async () => [
|
||||
{
|
||||
memberName: currentMemberName,
|
||||
sessionId: 'session-f',
|
||||
filePath,
|
||||
mtimeMs: Date.now(),
|
||||
},
|
||||
]);
|
||||
const events: TeamChangeEvent[] = [];
|
||||
const tracker = new TeammateToolTracker(
|
||||
{ listAttributedSubagentFiles } as never,
|
||||
{ setTracking: vi.fn(async () => ({ projectFingerprint: null, logSourceGeneration: null })) } as never,
|
||||
(event) => events.push(event)
|
||||
);
|
||||
|
||||
await tracker.setTracking('my-team', true);
|
||||
expect(events).toHaveLength(1);
|
||||
expect(JSON.parse(events[0].detail ?? '')).toMatchObject({
|
||||
action: 'start',
|
||||
activity: { memberName: 'alice', toolUseId: 'member_log:session-f:tool-1' },
|
||||
});
|
||||
|
||||
currentMemberName = 'bob';
|
||||
tracker.handleLogSourceChange('my-team');
|
||||
await waitForCondition(() => {
|
||||
expect(events).toHaveLength(3);
|
||||
});
|
||||
|
||||
const resetPayload = JSON.parse(events[1].detail ?? '');
|
||||
const replayPayload = JSON.parse(events[2].detail ?? '');
|
||||
expect(resetPayload).toMatchObject({
|
||||
action: 'reset',
|
||||
memberName: 'alice',
|
||||
toolUseIds: ['member_log:session-f:tool-1'],
|
||||
});
|
||||
expect(replayPayload).toMatchObject({
|
||||
action: 'start',
|
||||
activity: { memberName: 'bob', toolUseId: 'member_log:session-f:tool-1' },
|
||||
});
|
||||
});
|
||||
|
||||
it('drops late refresh results when tracking is disabled during in-flight scan', async () => {
|
||||
const rootDir = await mkdtemp(path.join(os.tmpdir(), 'teammate-tool-tracker-'));
|
||||
tempDirs.push(rootDir);
|
||||
const filePath = await createSubagentLog(rootDir, 'session-g');
|
||||
|
||||
await writeFile(
|
||||
filePath,
|
||||
`${JSON.stringify({
|
||||
timestamp: '2026-03-28T10:00:00.000Z',
|
||||
type: 'assistant',
|
||||
content: [{ type: 'tool_use', id: 'tool-1', name: 'Read', input: { file_path: 'src/index.ts' } }],
|
||||
})}\n`,
|
||||
'utf8'
|
||||
);
|
||||
|
||||
const deferred = createDeferred<
|
||||
Array<{ memberName: string; sessionId: string; filePath: string; mtimeMs: number }>
|
||||
>();
|
||||
const listAttributedSubagentFiles = vi.fn(() => deferred.promise);
|
||||
const setTracking = vi.fn(async () => ({ projectFingerprint: null, logSourceGeneration: null }));
|
||||
const events: TeamChangeEvent[] = [];
|
||||
const tracker = new TeammateToolTracker(
|
||||
{ listAttributedSubagentFiles } as never,
|
||||
{ setTracking } as never,
|
||||
(event) => events.push(event)
|
||||
);
|
||||
|
||||
const enablePromise = tracker.setTracking('my-team', true);
|
||||
await Promise.resolve();
|
||||
const disablePromise = tracker.setTracking('my-team', false);
|
||||
|
||||
deferred.resolve([
|
||||
{
|
||||
memberName: 'alice',
|
||||
sessionId: 'session-g',
|
||||
filePath,
|
||||
mtimeMs: Date.now(),
|
||||
},
|
||||
]);
|
||||
|
||||
await Promise.all([enablePromise, disablePromise]);
|
||||
|
||||
expect(events).toHaveLength(0);
|
||||
expect(setTracking).toHaveBeenNthCalledWith(1, 'my-team', 'tool_activity', true);
|
||||
expect(setTracking).toHaveBeenNthCalledWith(2, 'my-team', 'tool_activity', false);
|
||||
});
|
||||
});
|
||||
|
|
@ -26,6 +26,7 @@ describe('reviewDiffSafety', () => {
|
|||
newString: 'const a = 2;\n',
|
||||
timestamp: '2026-03-28T10:00:00.000Z',
|
||||
toolUseId: 'tool-1',
|
||||
toolName: 'Edit',
|
||||
type: 'edit',
|
||||
replaceAll: false,
|
||||
isError: false,
|
||||
|
|
@ -43,6 +44,7 @@ describe('reviewDiffSafety', () => {
|
|||
newString: 'a'.repeat(600 * 1024),
|
||||
timestamp: '2026-03-28T10:00:00.000Z',
|
||||
toolUseId: 'tool-2',
|
||||
toolName: 'Write',
|
||||
type: 'write-update',
|
||||
replaceAll: false,
|
||||
isError: false,
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|||
|
||||
const hoisted = vi.hoisted(() => ({
|
||||
onTeamChangeCb: null as
|
||||
| ((event: unknown, data: { type?: string; teamName: string }) => void)
|
||||
| ((event: unknown, data: { type?: string; teamName: string; detail?: string }) => void)
|
||||
| null,
|
||||
onProvisioningProgressCb: null as
|
||||
| ((event: unknown, data: { runId: string; teamName: string }) => void)
|
||||
|
|
@ -32,8 +32,11 @@ vi.mock('@renderer/api', () => ({
|
|||
},
|
||||
teams: {
|
||||
setChangePresenceTracking: vi.fn(async () => undefined),
|
||||
setToolActivityTracking: vi.fn(async () => undefined),
|
||||
onTeamChange: vi.fn(
|
||||
(cb: (event: unknown, data: { teamName: string }) => void): (() => void) => {
|
||||
(
|
||||
cb: (event: unknown, data: { teamName: string; type?: string; detail?: string }) => void
|
||||
): (() => void) => {
|
||||
hoisted.onTeamChangeCb = cb;
|
||||
return () => {
|
||||
hoisted.onTeamChangeCb = null;
|
||||
|
|
@ -347,4 +350,66 @@ describe('team change throttling', () => {
|
|||
|
||||
expect(setChangePresenceTrackingSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('tracks visible team tabs for tool activity and disables tracking when tab disappears', async () => {
|
||||
const setToolActivityTrackingSpy = vi.mocked(api.teams.setToolActivityTracking);
|
||||
setToolActivityTrackingSpy.mockClear();
|
||||
|
||||
cleanup?.();
|
||||
cleanup = initializeNotificationListeners();
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
|
||||
expect(setToolActivityTrackingSpy).toHaveBeenCalledWith('my-team', true);
|
||||
|
||||
useStore.setState({
|
||||
paneLayout: {
|
||||
focusedPaneId: 'p1',
|
||||
panes: [{ id: 'p1', widthFraction: 1, tabs: [], activeTabId: null }],
|
||||
},
|
||||
} as never);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
|
||||
expect(setToolActivityTrackingSpy).toHaveBeenCalledWith('my-team', false);
|
||||
});
|
||||
|
||||
it('applies targeted tool resets without clearing sibling tools', async () => {
|
||||
useStore.setState({
|
||||
activeToolsByTeam: {
|
||||
'my-team': {
|
||||
alice: {
|
||||
'tool-a': {
|
||||
memberName: 'alice',
|
||||
toolUseId: 'tool-a',
|
||||
toolName: 'Read',
|
||||
startedAt: '2026-03-28T10:00:00.000Z',
|
||||
state: 'running',
|
||||
source: 'runtime',
|
||||
},
|
||||
'tool-b': {
|
||||
memberName: 'alice',
|
||||
toolUseId: 'tool-b',
|
||||
toolName: 'Bash',
|
||||
startedAt: '2026-03-28T10:00:01.000Z',
|
||||
state: 'running',
|
||||
source: 'runtime',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as never);
|
||||
|
||||
hoisted.onTeamChangeCb?.({}, {
|
||||
type: 'tool-activity',
|
||||
teamName: 'my-team',
|
||||
detail: JSON.stringify({
|
||||
action: 'reset',
|
||||
memberName: 'alice',
|
||||
toolUseIds: ['tool-a'],
|
||||
}),
|
||||
});
|
||||
|
||||
expect(useStore.getState().activeToolsByTeam['my-team']?.alice?.['tool-a']).toBeUndefined();
|
||||
expect(useStore.getState().activeToolsByTeam['my-team']?.alice?.['tool-b']).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -497,6 +497,9 @@ describe('teamSlice actions', () => {
|
|||
currentProvisioningRunIdByTeam: {
|
||||
'my-team': 'pending:my-team:1',
|
||||
},
|
||||
currentRuntimeRunIdByTeam: {
|
||||
'my-team': 'pending:my-team:1',
|
||||
},
|
||||
memberSpawnStatusesByTeam: {
|
||||
'my-team': {
|
||||
alice: { status: 'spawning', updatedAt: '2026-03-12T10:00:00.000Z' },
|
||||
|
|
@ -511,6 +514,7 @@ describe('teamSlice actions', () => {
|
|||
expect(store.getState().currentRuntimeRunIdByTeam['my-team']).toBeUndefined();
|
||||
expect(store.getState().memberSpawnStatusesByTeam['my-team']).toBeUndefined();
|
||||
expect(store.getState().ignoredProvisioningRunIds['pending:my-team:1']).toBe('my-team');
|
||||
expect(store.getState().ignoredRuntimeRunIds['pending:my-team:1']).toBe('my-team');
|
||||
});
|
||||
|
||||
it('does not resurrect a cleared missing run when late progress arrives', () => {
|
||||
|
|
@ -591,6 +595,91 @@ describe('teamSlice actions', () => {
|
|||
expect(store.getState().memberSpawnStatusesByTeam['my-team']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('tombstones the previous runtime run and clears tool layers before creating a new run', async () => {
|
||||
const store = createSliceStore();
|
||||
store.setState({
|
||||
currentRuntimeRunIdByTeam: {
|
||||
'my-team': 'runtime-old',
|
||||
},
|
||||
activeToolsByTeam: {
|
||||
'my-team': {
|
||||
'team-lead': {
|
||||
'tool-a': {
|
||||
memberName: 'team-lead',
|
||||
toolUseId: 'tool-a',
|
||||
toolName: 'Read',
|
||||
startedAt: '2026-03-12T10:00:00.000Z',
|
||||
state: 'running',
|
||||
source: 'runtime',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
finishedVisibleByTeam: {
|
||||
'my-team': {
|
||||
'team-lead': {
|
||||
'tool-b': {
|
||||
memberName: 'team-lead',
|
||||
toolUseId: 'tool-b',
|
||||
toolName: 'Bash',
|
||||
startedAt: '2026-03-12T10:00:01.000Z',
|
||||
finishedAt: '2026-03-12T10:00:02.000Z',
|
||||
state: 'complete',
|
||||
source: 'runtime',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
toolHistoryByTeam: {
|
||||
'my-team': {
|
||||
'team-lead': [
|
||||
{
|
||||
memberName: 'team-lead',
|
||||
toolUseId: 'tool-b',
|
||||
toolName: 'Bash',
|
||||
startedAt: '2026-03-12T10:00:01.000Z',
|
||||
finishedAt: '2026-03-12T10:00:02.000Z',
|
||||
state: 'complete',
|
||||
source: 'runtime',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await store.getState().createTeam({
|
||||
teamName: 'my-team',
|
||||
cwd: '/tmp/project',
|
||||
members: [],
|
||||
});
|
||||
|
||||
expect(store.getState().currentRuntimeRunIdByTeam['my-team']).toBe('run-1');
|
||||
expect(store.getState().ignoredRuntimeRunIds['runtime-old']).toBeUndefined();
|
||||
expect(store.getState().activeToolsByTeam['my-team']).toBeUndefined();
|
||||
expect(store.getState().finishedVisibleByTeam['my-team']).toBeUndefined();
|
||||
expect(store.getState().toolHistoryByTeam['my-team']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('ignores tombstoned runtime spawn-status snapshots', async () => {
|
||||
const store = createSliceStore();
|
||||
store.setState({
|
||||
ignoredRuntimeRunIds: {
|
||||
'runtime-old': 'my-team',
|
||||
},
|
||||
});
|
||||
hoisted.getMemberSpawnStatuses.mockResolvedValue({
|
||||
runId: 'runtime-old',
|
||||
statuses: {
|
||||
alice: { status: 'spawning', updatedAt: '2026-03-12T10:00:00.000Z' },
|
||||
},
|
||||
});
|
||||
|
||||
await store.getState().fetchMemberSpawnStatuses('my-team');
|
||||
|
||||
expect(store.getState().currentRuntimeRunIdByTeam['my-team']).toBeUndefined();
|
||||
expect(store.getState().memberSpawnStatusesByTeam['my-team']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('preserves current spawn statuses when clearing a non-canonical missing run', () => {
|
||||
const store = createSliceStore();
|
||||
store.setState({
|
||||
|
|
|
|||
Loading…
Reference in a new issue