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:
parent
cb0a13bbf5
commit
7502cc8e53
3 changed files with 228 additions and 15 deletions
|
|
@ -85,7 +85,10 @@ export class TeamMemberLogsFinder {
|
||||||
private readonly fileMentionsCache = new Map<string, boolean>();
|
private readonly fileMentionsCache = new Map<string, boolean>();
|
||||||
private readonly discoveryCache = new Map<
|
private readonly discoveryCache = new Map<
|
||||||
string,
|
string,
|
||||||
{ result: NonNullable<Awaited<ReturnType<TeamMemberLogsFinder['discoverProjectSessions']>>>; expiresAt: number }
|
{
|
||||||
|
result: NonNullable<Awaited<ReturnType<TeamMemberLogsFinder['discoverProjectSessions']>>>;
|
||||||
|
expiresAt: number;
|
||||||
|
}
|
||||||
>();
|
>();
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
|
|
@ -345,6 +348,29 @@ export class TeamMemberLogsFinder {
|
||||||
}
|
}
|
||||||
const tOwner = performance.now();
|
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(
|
const sorted = results.sort(
|
||||||
(a, b) => new Date(b.startTime).getTime() - new Date(a.startTime).getTime()
|
(a, b) => new Date(b.startTime).getTime() - new Date(a.startTime).getTime()
|
||||||
);
|
);
|
||||||
|
|
@ -353,7 +379,8 @@ export class TeamMemberLogsFinder {
|
||||||
console.log(
|
console.log(
|
||||||
`[findLogsForTask] task=${taskId}@${teamName} | ` +
|
`[findLogsForTask] task=${taskId}@${teamName} | ` +
|
||||||
`step2=${step2Count} (scan ${mentionHits}/${totalFiles} files) | ` +
|
`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} | ` +
|
`total=${sorted.length} | ` +
|
||||||
`${(tTotal - t0).toFixed(0)}ms`
|
`${(tTotal - t0).toFixed(0)}ms`
|
||||||
);
|
);
|
||||||
|
|
@ -525,6 +552,28 @@ export class TeamMemberLogsFinder {
|
||||||
}
|
}
|
||||||
const tOwner = performance.now();
|
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 sortedRefs = [...refs].sort((a, b) => b.sortTime - a.sortTime);
|
||||||
const tTotal = performance.now();
|
const tTotal = performance.now();
|
||||||
|
|
||||||
|
|
@ -978,7 +1027,12 @@ export class TeamMemberLogsFinder {
|
||||||
// that this session actually WORKED on the task. Agents commonly call
|
// that this session actually WORKED on the task. Agents commonly call
|
||||||
// task_get to check dependencies from other tasks, producing false matches.
|
// task_get to check dependencies from other tasks, producing false matches.
|
||||||
const toolName = typeof b.name === 'string' ? b.name : '';
|
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;
|
const input = b.input as Record<string, unknown> | undefined;
|
||||||
if (!input) continue;
|
if (!input) continue;
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,46 @@ import {
|
||||||
import type { EnhancedChunk } from '@renderer/types/data';
|
import type { EnhancedChunk } from '@renderer/types/data';
|
||||||
import type { MemberLogSummary } from '@shared/types';
|
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 {
|
interface MemberLogsTabProps {
|
||||||
teamName: string;
|
teamName: string;
|
||||||
memberName?: string;
|
memberName?: string;
|
||||||
|
|
@ -342,7 +382,8 @@ export const MemberLogsTab = ({
|
||||||
try {
|
try {
|
||||||
const next = await fetchDetailForLog(previewLog);
|
const next = await fetchDetailForLog(previewLog);
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
setPreviewChunks(next ? [...next] : null);
|
const filtered = taskId ? filterChunksByWorkIntervals(next, taskWorkIntervals) : next;
|
||||||
|
setPreviewChunks(filtered ? [...filtered] : null);
|
||||||
} catch {
|
} catch {
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
setPreviewChunks(null);
|
setPreviewChunks(null);
|
||||||
|
|
@ -352,7 +393,7 @@ export const MemberLogsTab = ({
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [fetchDetailForLog, previewLog, shouldShowPreview]);
|
}, [fetchDetailForLog, previewLog, shouldShowPreview, intervalsKey]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!shouldShowPreview) return;
|
if (!shouldShowPreview) return;
|
||||||
|
|
@ -367,7 +408,8 @@ export const MemberLogsTab = ({
|
||||||
try {
|
try {
|
||||||
const next = await fetchDetailForLog(previewLog, { bypassCache: true });
|
const next = await fetchDetailForLog(previewLog, { bypassCache: true });
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
setPreviewChunks(next ? [...next] : null);
|
const filtered = taskId ? filterChunksByWorkIntervals(next, taskWorkIntervals) : next;
|
||||||
|
setPreviewChunks(filtered ? [...filtered] : null);
|
||||||
} catch {
|
} catch {
|
||||||
// keep last successful preview
|
// keep last successful preview
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -386,6 +428,7 @@ export const MemberLogsTab = ({
|
||||||
previewLog,
|
previewLog,
|
||||||
shouldShowPreview,
|
shouldShowPreview,
|
||||||
taskStatus,
|
taskStatus,
|
||||||
|
intervalsKey,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -400,8 +443,8 @@ export const MemberLogsTab = ({
|
||||||
try {
|
try {
|
||||||
const next = await fetchDetailForLog(expandedLogSummary, { bypassCache: true });
|
const next = await fetchDetailForLog(expandedLogSummary, { bypassCache: true });
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
// Ensure new reference so memoized transforms update.
|
const filtered = taskId ? filterChunksByWorkIntervals(next, taskWorkIntervals) : next;
|
||||||
setDetailChunks(next ? [...next] : null);
|
setDetailChunks(filtered ? [...filtered] : null);
|
||||||
} catch {
|
} catch {
|
||||||
// Keep last successful data; avoid flicker during transient errors.
|
// Keep last successful data; avoid flicker during transient errors.
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -416,7 +459,15 @@ export const MemberLogsTab = ({
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
clearInterval(interval);
|
clearInterval(interval);
|
||||||
};
|
};
|
||||||
}, [beginRefreshing, endRefreshing, expandedLogSummary, fetchDetailForLog, taskId, taskStatus]);
|
}, [
|
||||||
|
beginRefreshing,
|
||||||
|
endRefreshing,
|
||||||
|
expandedLogSummary,
|
||||||
|
fetchDetailForLog,
|
||||||
|
taskId,
|
||||||
|
taskStatus,
|
||||||
|
intervalsKey,
|
||||||
|
]);
|
||||||
|
|
||||||
const handleExpand = useCallback(
|
const handleExpand = useCallback(
|
||||||
async (log: MemberLogSummary) => {
|
async (log: MemberLogSummary) => {
|
||||||
|
|
@ -436,14 +487,15 @@ export const MemberLogsTab = ({
|
||||||
log,
|
log,
|
||||||
shouldBypassCache ? { bypassCache: true } : undefined
|
shouldBypassCache ? { bypassCache: true } : undefined
|
||||||
);
|
);
|
||||||
setDetailChunks(chunks ? [...chunks] : null);
|
const filtered = taskId ? filterChunksByWorkIntervals(chunks, taskWorkIntervals) : chunks;
|
||||||
|
setDetailChunks(filtered ? [...filtered] : null);
|
||||||
} catch {
|
} catch {
|
||||||
setDetailChunks(null);
|
setDetailChunks(null);
|
||||||
} finally {
|
} finally {
|
||||||
setDetailLoading(false);
|
setDetailLoading(false);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[expandedId, fetchDetailForLog, getRowId, taskStatus]
|
[expandedId, fetchDetailForLog, getRowId, taskStatus, intervalsKey]
|
||||||
);
|
);
|
||||||
|
|
||||||
if (loading && logs.length === 0) {
|
if (loading && logs.length === 0) {
|
||||||
|
|
@ -581,9 +633,7 @@ const LogCard = ({
|
||||||
<Clock size={10} />
|
<Clock size={10} />
|
||||||
{updatedAgo}
|
{updatedAgo}
|
||||||
</span>
|
</span>
|
||||||
<span style={{ opacity: 0.4 }}>
|
<span style={{ opacity: 0.4 }}>started {createdAgo}</span>
|
||||||
started {createdAgo}
|
|
||||||
</span>
|
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<span className="flex items-center gap-1">
|
<span className="flex items-center gap-1">
|
||||||
|
|
@ -601,7 +651,10 @@ const LogCard = ({
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{log.lastOutputPreview && !expanded && (
|
{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}
|
{log.lastOutputPreview}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -805,4 +805,110 @@ describe('changeReviewSlice task changes', () => {
|
||||||
expect(store.getState().changeSetEpoch).toBe(3);
|
expect(store.getState().changeSetEpoch).toBe(3);
|
||||||
expect(store.getState().fileContentVersionByPath).toEqual({});
|
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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue