feat: deduplicate subagent snapshots and enhance chunk filtering by work intervals

- Implemented deduplication logic in TeamMemberLogsFinder to retain only the largest cumulative subagent snapshots per sessionId and memberName.
- Enhanced MemberLogsTab with a new function to filter chunks based on task work intervals, improving the relevance of displayed logs.
- Updated state management in MemberLogsTab to utilize the new filtering logic for both preview and detail chunks.
- Added unit tests to verify the new deduplication and filtering functionalities.
This commit is contained in:
iliya 2026-03-15 10:48:35 +02:00
parent cb0a13bbf5
commit 7502cc8e53
3 changed files with 228 additions and 15 deletions

View file

@ -85,7 +85,10 @@ export class TeamMemberLogsFinder {
private readonly fileMentionsCache = new Map<string, boolean>();
private readonly discoveryCache = new Map<
string,
{ result: NonNullable<Awaited<ReturnType<TeamMemberLogsFinder['discoverProjectSessions']>>>; expiresAt: number }
{
result: NonNullable<Awaited<ReturnType<TeamMemberLogsFinder['discoverProjectSessions']>>>;
expiresAt: number;
}
>();
constructor(
@ -345,6 +348,29 @@ export class TeamMemberLogsFinder {
}
const tOwner = performance.now();
// Dedup cumulative subagent snapshots: keep 1 file per sessionId+memberName (largest).
// In-process teammates produce cumulative JSONL files where each successive file
// contains ALL lines from the previous + a new delta. The largest file is a superset.
const preDedupCount = results.length;
{
const subagentsByKey = new Map<string, MemberSubagentLogSummary>();
const nonSubagent: MemberLogSummary[] = [];
for (const r of results) {
if (r.kind !== 'subagent') {
nonSubagent.push(r);
continue;
}
const memberKey = r.memberName ? r.memberName.toLowerCase() : `_${r.subagentId}`;
const key = `${r.sessionId}:${memberKey}`;
const existing = subagentsByKey.get(key);
if (!existing || r.messageCount > existing.messageCount) {
subagentsByKey.set(key, r);
}
}
results.length = 0;
results.push(...nonSubagent, ...subagentsByKey.values());
}
const sorted = results.sort(
(a, b) => new Date(b.startTime).getTime() - new Date(a.startTime).getTime()
);
@ -353,7 +379,8 @@ export class TeamMemberLogsFinder {
console.log(
`[findLogsForTask] task=${taskId}@${teamName} | ` +
`step2=${step2Count} (scan ${mentionHits}/${totalFiles} files) | ` +
`step3=${sorted.length - step2Count} (owner=${normalizedOwner ?? 'none'}, includeOwner=${includeOwnerSessions}) | ` +
`step3=${preDedupCount - step2Count} (owner=${normalizedOwner ?? 'none'}, includeOwner=${includeOwnerSessions}) | ` +
`dedup=${preDedupCount}${sorted.length} | ` +
`total=${sorted.length} | ` +
`${(tTotal - t0).toFixed(0)}ms`
);
@ -525,6 +552,28 @@ export class TeamMemberLogsFinder {
}
const tOwner = performance.now();
// Dedup cumulative subagent snapshots (same logic as findLogsForTask).
{
const refsByKey = new Map<string, (typeof refs)[0]>();
const leadRefs: (typeof refs)[0][] = [];
for (const ref of refs) {
if (ref.memberName.toLowerCase() === leadMemberName.toLowerCase()) {
leadRefs.push(ref);
continue;
}
const parts = ref.filePath.split(path.sep);
const subagentsIdx = parts.lastIndexOf('subagents');
const sessionId = subagentsIdx > 0 ? parts[subagentsIdx - 1] : '';
const key = `${sessionId}:${ref.memberName.toLowerCase()}`;
const existing = refsByKey.get(key);
if (!existing || ref.sortTime > existing.sortTime) {
refsByKey.set(key, ref);
}
}
refs.length = 0;
refs.push(...leadRefs, ...refsByKey.values());
}
const sortedRefs = [...refs].sort((a, b) => b.sortTime - a.sortTime);
const tTotal = performance.now();
@ -978,7 +1027,12 @@ export class TeamMemberLogsFinder {
// that this session actually WORKED on the task. Agents commonly call
// task_get to check dependencies from other tasks, producing false matches.
const toolName = typeof b.name === 'string' ? b.name : '';
if (toolName === 'task_get' || toolName === 'mcp__agent-teams__task_get') continue;
if (
toolName === 'task_get' ||
toolName === 'mcp__agent-teams__task_get' ||
toolName === 'TaskGet'
)
continue;
const input = b.input as Record<string, unknown> | undefined;
if (!input) continue;

View file

@ -27,6 +27,46 @@ import {
import type { EnhancedChunk } from '@renderer/types/data';
import type { MemberLogSummary } from '@shared/types';
// ---------------------------------------------------------------------------
// Chunk filtering by task work intervals
// ---------------------------------------------------------------------------
const CHUNK_GRACE_BEFORE_MS = 30_000; // 30s before startedAt
const CHUNK_GRACE_AFTER_MS = 10_000; // 10s after completedAt
function filterChunksByWorkIntervals(
chunks: EnhancedChunk[] | null,
intervals: { startedAt: string; completedAt?: string }[] | undefined
): EnhancedChunk[] | null {
if (!chunks) return null;
if (!intervals || intervals.length === 0) return chunks;
const now = Date.now();
const parsed = intervals
.map((i) => {
const s = Date.parse(i.startedAt);
if (!Number.isFinite(s)) return null;
const e = typeof i.completedAt === 'string' ? Date.parse(i.completedAt) : null;
return {
startMs: s - CHUNK_GRACE_BEFORE_MS,
endMs: e != null && Number.isFinite(e) ? e + CHUNK_GRACE_AFTER_MS : null,
};
})
.filter((v): v is { startMs: number; endMs: number | null } => v !== null);
if (parsed.length === 0) return chunks;
return chunks.filter((chunk) => {
const cs = chunk.startTime.getTime();
const ce = chunk.endTime.getTime();
if (!Number.isFinite(cs) || !Number.isFinite(ce)) return true;
return parsed.some((i) => {
const end = i.endMs ?? now;
return cs <= end && ce >= i.startMs;
});
});
}
interface MemberLogsTabProps {
teamName: string;
memberName?: string;
@ -342,7 +382,8 @@ export const MemberLogsTab = ({
try {
const next = await fetchDetailForLog(previewLog);
if (cancelled) return;
setPreviewChunks(next ? [...next] : null);
const filtered = taskId ? filterChunksByWorkIntervals(next, taskWorkIntervals) : next;
setPreviewChunks(filtered ? [...filtered] : null);
} catch {
if (cancelled) return;
setPreviewChunks(null);
@ -352,7 +393,7 @@ export const MemberLogsTab = ({
return () => {
cancelled = true;
};
}, [fetchDetailForLog, previewLog, shouldShowPreview]);
}, [fetchDetailForLog, previewLog, shouldShowPreview, intervalsKey]);
useEffect(() => {
if (!shouldShowPreview) return;
@ -367,7 +408,8 @@ export const MemberLogsTab = ({
try {
const next = await fetchDetailForLog(previewLog, { bypassCache: true });
if (cancelled) return;
setPreviewChunks(next ? [...next] : null);
const filtered = taskId ? filterChunksByWorkIntervals(next, taskWorkIntervals) : next;
setPreviewChunks(filtered ? [...filtered] : null);
} catch {
// keep last successful preview
} finally {
@ -386,6 +428,7 @@ export const MemberLogsTab = ({
previewLog,
shouldShowPreview,
taskStatus,
intervalsKey,
]);
useEffect(() => {
@ -400,8 +443,8 @@ export const MemberLogsTab = ({
try {
const next = await fetchDetailForLog(expandedLogSummary, { bypassCache: true });
if (cancelled) return;
// Ensure new reference so memoized transforms update.
setDetailChunks(next ? [...next] : null);
const filtered = taskId ? filterChunksByWorkIntervals(next, taskWorkIntervals) : next;
setDetailChunks(filtered ? [...filtered] : null);
} catch {
// Keep last successful data; avoid flicker during transient errors.
} finally {
@ -416,7 +459,15 @@ export const MemberLogsTab = ({
cancelled = true;
clearInterval(interval);
};
}, [beginRefreshing, endRefreshing, expandedLogSummary, fetchDetailForLog, taskId, taskStatus]);
}, [
beginRefreshing,
endRefreshing,
expandedLogSummary,
fetchDetailForLog,
taskId,
taskStatus,
intervalsKey,
]);
const handleExpand = useCallback(
async (log: MemberLogSummary) => {
@ -436,14 +487,15 @@ export const MemberLogsTab = ({
log,
shouldBypassCache ? { bypassCache: true } : undefined
);
setDetailChunks(chunks ? [...chunks] : null);
const filtered = taskId ? filterChunksByWorkIntervals(chunks, taskWorkIntervals) : chunks;
setDetailChunks(filtered ? [...filtered] : null);
} catch {
setDetailChunks(null);
} finally {
setDetailLoading(false);
}
},
[expandedId, fetchDetailForLog, getRowId, taskStatus]
[expandedId, fetchDetailForLog, getRowId, taskStatus, intervalsKey]
);
if (loading && logs.length === 0) {
@ -581,9 +633,7 @@ const LogCard = ({
<Clock size={10} />
{updatedAgo}
</span>
<span style={{ opacity: 0.4 }}>
started {createdAgo}
</span>
<span style={{ opacity: 0.4 }}>started {createdAgo}</span>
</>
) : (
<span className="flex items-center gap-1">
@ -601,7 +651,10 @@ const LogCard = ({
)}
</div>
{log.lastOutputPreview && !expanded && (
<div className="mt-1 truncate text-[10px] text-[var(--color-text-muted)]" style={{ opacity: 0.6 }}>
<div
className="mt-1 truncate text-[10px] text-[var(--color-text-muted)]"
style={{ opacity: 0.6 }}
>
{log.lastOutputPreview}
</div>
)}

View file

@ -805,4 +805,110 @@ describe('changeReviewSlice task changes', () => {
expect(store.getState().changeSetEpoch).toBe(3);
expect(store.getState().fileContentVersionByPath).toEqual({});
});
it('forces re-review when snippet order changes even if file paths stay the same', async () => {
const store = createSliceStore();
const first = makeSnippet({
toolUseId: 'tool-1',
filePath: '/repo/file.ts',
oldString: 'a',
newString: 'b',
timestamp: '2026-03-01T10:00:00.000Z',
});
const second = makeSnippet({
toolUseId: 'tool-2',
filePath: '/repo/file.ts',
oldString: 'c',
newString: 'd',
timestamp: '2026-03-01T10:01:00.000Z',
});
const current = {
memberName: 'alice',
teamName: 'team-a',
files: [
{
...makeFile('/repo/file.ts'),
snippets: [first, second],
},
],
totalFiles: 1,
totalLinesAdded: 1,
totalLinesRemoved: 1,
};
const fresh = {
...current,
files: [
{
...current.files[0],
snippets: [second, first],
},
],
};
hoisted.getAgentChanges.mockResolvedValueOnce(fresh);
store.setState({
activeChangeSet: current,
hunkDecisions: { '/repo/file.ts:0': 'rejected' },
fileDecisions: { '/repo/file.ts': 'rejected' },
changeSetEpoch: 0,
fileContentVersionByPath: {},
});
await store.getState().applyReview('team-a', undefined, 'alice');
expect(hoisted.applyDecisions).not.toHaveBeenCalled();
expect(store.getState().activeChangeSet).toEqual(fresh);
expect(store.getState().applyError).toBe(
'Changes have been updated since you started reviewing. Please re-review.'
);
});
it('does not force re-review when only top-level file order changes', async () => {
const store = createSliceStore();
const firstFile = makeFile('/repo/a.ts', { newString: 'after-a' });
const secondFile = makeFile('/repo/b.ts', { newString: 'after-b' });
const current = {
memberName: 'alice',
teamName: 'team-a',
files: [firstFile, secondFile],
totalFiles: 2,
totalLinesAdded: firstFile.linesAdded + secondFile.linesAdded,
totalLinesRemoved: firstFile.linesRemoved + secondFile.linesRemoved,
};
const fresh = {
...current,
files: [secondFile, firstFile],
};
hoisted.getAgentChanges.mockResolvedValueOnce(fresh);
hoisted.applyDecisions.mockResolvedValueOnce({
applied: 0,
skipped: 0,
conflicts: 0,
errors: [],
});
store.setState({
activeChangeSet: current,
hunkDecisions: { '/repo/a.ts:0': 'rejected' },
fileDecisions: { '/repo/a.ts': 'rejected' },
changeSetEpoch: 0,
fileContentVersionByPath: {},
});
await store.getState().applyReview('team-a', undefined, 'alice');
expect(store.getState().applyError).toBeNull();
expect(hoisted.applyDecisions).toHaveBeenCalledTimes(1);
expect(hoisted.applyDecisions).toHaveBeenCalledWith({
teamName: 'team-a',
taskId: undefined,
memberName: 'alice',
decisions: [
expect.objectContaining({
filePath: '/repo/a.ts',
}),
],
});
expect(store.getState().activeChangeSet).toEqual(current);
});
});