fix(team-data): dedupe passive user reply summaries

This commit is contained in:
iliya 2026-04-09 21:15:50 +03:00
parent affd9ac748
commit 32ec3a6123
4 changed files with 497 additions and 1 deletions

View file

@ -17,6 +17,7 @@ import {
import { getMemberColorByName } from '@shared/constants/memberColors';
import { isLeadAgentType, isLeadMember } from '@shared/utils/leadDetection';
import { createLogger } from '@shared/utils/logger';
import { classifyIdleNotificationText } from '@shared/utils/idleNotificationSemantics';
import { getKanbanColumnFromReviewState, normalizeReviewState } from '@shared/utils/reviewState';
import { buildStandaloneSlashCommandMeta } from '@shared/utils/slashCommands';
import { formatTaskDisplayLabel } from '@shared/utils/taskIdentity';
@ -94,6 +95,7 @@ const LEAD_SESSION_PARSE_CACHE_SCHEMA_VERSION = 'combined-v1';
const PROCESS_HEALTH_INTERVAL_MS = 2_000;
const TASK_MAP_YIELD_EVERY = 250;
const TASK_COMMENT_NOTIFICATION_SOURCE = 'system_notification';
const PASSIVE_USER_REPLY_LINK_WINDOW_MS = 15_000;
interface EligibleTaskCommentNotification {
key: string;
@ -119,6 +121,31 @@ interface FileWatchReconcileDiagnostics {
lastPressureLogAt: number;
}
function normalizePassiveUserReplyLinkText(value: string | undefined): string {
if (typeof value !== 'string') return '';
return value
.trim()
.toLowerCase()
.replace(/\s+/g, ' ')
.replace(/[.!?…]+$/g, '')
.trim();
}
function extractPassiveUserPeerSummaryBody(text: string): string | null {
const classified = classifyIdleNotificationText(text);
if (!classified || classified.primaryKind !== 'heartbeat' || !classified.peerSummary) {
return null;
}
const match = classified.peerSummary.match(/^\[to\s+user\]\s*(.*)$/i);
if (!match) {
return null;
}
const body = match[1]?.trim() ?? '';
return body.length > 0 ? body : null;
}
interface FileWatchReconcileTrigger {
source: 'inbox' | 'task';
detail?: string;
@ -322,6 +349,88 @@ export class TeamDataService {
}
}
private linkPassiveUserReplySummaries(messages: InboxMessage[]): InboxMessage[] {
const canonicalReplies = messages
.map((message) => {
const messageId = typeof message.messageId === 'string' ? message.messageId.trim() : '';
if (!messageId || message.to !== 'user') {
return null;
}
if (classifyIdleNotificationText(message.text)) {
return null;
}
const time = Date.parse(message.timestamp);
if (!Number.isFinite(time)) {
return null;
}
return {
messageId,
from: message.from,
time,
normalizedSummary: normalizePassiveUserReplyLinkText(message.summary),
normalizedText: normalizePassiveUserReplyLinkText(message.text),
};
})
.filter((value): value is NonNullable<typeof value> => value !== null);
if (canonicalReplies.length === 0) {
return messages;
}
let didLink = false;
const linkedMessages = messages.map((message) => {
if (
typeof message.relayOfMessageId === 'string' &&
message.relayOfMessageId.trim().length > 0
) {
return message;
}
const body = extractPassiveUserPeerSummaryBody(message.text);
if (!body) {
return message;
}
const passiveTime = Date.parse(message.timestamp);
if (!Number.isFinite(passiveTime)) {
return message;
}
const normalizedBody = normalizePassiveUserReplyLinkText(body);
if (!normalizedBody) {
return message;
}
const matches = canonicalReplies.filter((candidate) => {
if (candidate.from !== message.from) {
return false;
}
const deltaMs = passiveTime - candidate.time;
if (deltaMs < 0 || deltaMs > PASSIVE_USER_REPLY_LINK_WINDOW_MS) {
return false;
}
if (candidate.normalizedSummary === normalizedBody) {
return true;
}
return normalizedBody.length >= 6 && candidate.normalizedText.includes(normalizedBody);
});
if (matches.length !== 1) {
return message;
}
didLink = true;
return {
...message,
relayOfMessageId: matches[0].messageId,
};
});
return didLink ? linkedMessages : messages;
}
async getTaskChangePresence(teamName: string): Promise<Record<string, TaskChangePresenceState>> {
const config = await this.configReader.getConfig(teamName);
if (!config) {
@ -802,6 +911,9 @@ export class TeamDataService {
}
mark('dedupMessageIds');
messages = this.linkPassiveUserReplySummaries(messages);
mark('linkPassiveUserReplySummaries');
// Enrich inbox messages without leadSessionId by assigning the nearest neighbor's
// session ID (by timestamp). This avoids the old forward-only propagation bug.
if (config.leadSessionId || messages.some((m) => m.leadSessionId)) {

View file

@ -108,6 +108,18 @@ function getCommandOutputSummary(text: string): string {
return firstLine.length > 120 ? `${firstLine.slice(0, 120)}` : firstLine;
}
function parseIdlePeerSummaryRoute(summary: string): { recipient: string | null; body: string } {
const trimmed = summary.trim();
const match = trimmed.match(/^\[to\s+([^\]]+)\]\s*(.*)$/i);
if (!match) {
return { recipient: null, body: trimmed };
}
const recipient = match[1]?.trim() || null;
const body = match[2]?.trim() || trimmed;
return { recipient, body };
}
export function isQualifiedExternalRecipient(
value: string | undefined,
teamName: string,
@ -305,6 +317,59 @@ const NoiseRow = ({
</div>
);
const PassiveIdlePeerSummaryRow = ({
teamName,
senderName,
senderColor,
summary,
timestamp,
onMemberNameClick,
}: {
teamName: string;
senderName: string;
senderColor?: string;
summary: string;
timestamp: string;
onMemberNameClick?: (memberName: string) => void;
}): React.JSX.Element => {
const { recipient, body } = parseIdlePeerSummaryRoute(summary);
return (
<div className="flex items-center gap-2 px-3 py-1.5" style={{ opacity: 0.78 }}>
<span className="bg-sky-500/12 rounded-full px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide text-sky-300">
update
</span>
<MemberBadge
name={senderName}
color={senderColor}
teamName={teamName}
hideAvatar={false}
onClick={onMemberNameClick}
/>
{recipient ? (
<>
<MoveRight size={10} style={{ color: CARD_ICON_MUTED }} className="shrink-0" />
<span
className="inline-flex shrink-0 items-center rounded-full px-2 py-0.5 text-[10px] font-medium tracking-wide"
style={{
backgroundColor: 'rgba(148, 163, 184, 0.12)',
color: CARD_TEXT_LIGHT,
}}
>
{recipient}
</span>
</>
) : null}
<span className="min-w-0 flex-1 truncate text-xs" style={{ color: CARD_TEXT_LIGHT }}>
{body}
</span>
<span className="shrink-0 text-[10px]" style={{ color: CARD_ICON_MUTED }}>
{timestamp}
</span>
</div>
);
};
const BootstrapSystemRow = ({
teamName,
senderName,
@ -751,6 +816,19 @@ export const ActivityItem = memo(
);
}
if (idleSemantic?.uiPresentation === 'peer_summary' && idleSemantic.peerSummary) {
return (
<PassiveIdlePeerSummaryRow
teamName={teamName}
senderName={senderName}
senderColor={senderColor}
summary={idleSemantic.peerSummary}
timestamp={timestamp}
onMemberNameClick={onMemberNameClick}
/>
);
}
if (bootstrapDisplay) {
return (
<BootstrapSystemRow

View file

@ -2518,6 +2518,268 @@ describe('TeamDataService', () => {
});
});
function createPassiveUserSummaryLinkService(options: {
inboxMessages?: InboxMessage[];
sentMessages?: InboxMessage[];
}): TeamDataService {
const { inboxMessages = [], sentMessages = [] } = options;
return new TeamDataService(
{
listTeams: vi.fn(),
getConfig: vi.fn(async () => ({
name: 'My team',
members: [{ name: 'team-lead', role: 'Lead' }],
leadSessionId: 'lead-1',
})),
} as never,
{
getTasks: vi.fn(async () => []),
} as never,
{
listInboxNames: vi.fn(async () => []),
getMessages: vi.fn(async () => inboxMessages),
} as never,
{} as never,
{} as never,
{
resolveMembers: vi.fn(() => []),
} as never,
{
getState: vi.fn(async () => ({ teamName: 'my-team', reviewers: [], tasks: {} })),
} as never,
{} as never,
{} as never,
{
readMessages: vi.fn(async () => sentMessages),
} as never
);
}
it('links passive [to user] acknowledgement summaries to the canonical user reply transiently', async () => {
const passiveSummaryRow: InboxMessage = {
from: 'alice',
text: JSON.stringify({
type: 'idle_notification',
idleReason: 'available',
summary: '[to user] acknowledgement',
}),
timestamp: '2026-04-08T10:00:05.000Z',
read: true,
messageId: 'passive-user-summary-1',
};
const userReplyRow: InboxMessage = {
from: 'alice',
to: 'user',
text: 'Да, я здесь. Готова к работе и жду задач для ревью.',
timestamp: '2026-04-08T10:00:00.000Z',
read: true,
summary: 'acknowledgement',
messageId: 'user-reply-1',
source: 'user_sent',
};
const service = createPassiveUserSummaryLinkService({
inboxMessages: [passiveSummaryRow],
sentMessages: [userReplyRow],
});
const data = await service.getTeamData('my-team');
const linked = data.messages.find((message) => message.messageId === 'passive-user-summary-1');
expect(linked?.relayOfMessageId).toBe('user-reply-1');
expect(passiveSummaryRow.relayOfMessageId).toBeUndefined();
});
it('links passive [to user] summaries when the summary body is contained in the user reply text', async () => {
const service = createPassiveUserSummaryLinkService({
inboxMessages: [
{
from: 'alice',
text: JSON.stringify({
type: 'idle_notification',
idleReason: 'available',
summary: '[to user] Я здесь.',
}),
timestamp: '2026-04-08T10:00:05.000Z',
read: true,
messageId: 'passive-user-summary-contains-1',
},
],
sentMessages: [
{
from: 'alice',
to: 'user',
text: 'Да, я здесь. Готова к работе и жду задач для ревью.',
timestamp: '2026-04-08T10:00:00.000Z',
read: true,
summary: 'presence ack',
messageId: 'user-reply-contains-1',
source: 'user_sent',
},
],
});
const data = await service.getTeamData('my-team');
const linked = data.messages.find(
(message) => message.messageId === 'passive-user-summary-contains-1'
);
expect(linked?.relayOfMessageId).toBe('user-reply-contains-1');
});
it('does not link passive [to user] summaries outside the 15s correlation window', async () => {
const service = createPassiveUserSummaryLinkService({
inboxMessages: [
{
from: 'alice',
text: JSON.stringify({
type: 'idle_notification',
idleReason: 'available',
summary: '[to user] acknowledgement',
}),
timestamp: '2026-04-08T10:00:16.000Z',
read: true,
messageId: 'passive-user-summary-old-1',
},
],
sentMessages: [
{
from: 'alice',
to: 'user',
text: 'Да, я здесь. Готова к работе и жду задач для ревью.',
timestamp: '2026-04-08T10:00:00.000Z',
read: true,
summary: 'acknowledgement',
messageId: 'user-reply-old-1',
source: 'user_sent',
},
],
});
const data = await service.getTeamData('my-team');
const linked = data.messages.find((message) => message.messageId === 'passive-user-summary-old-1');
expect(linked?.relayOfMessageId).toBeUndefined();
});
it('does not link passive peer summaries for recipients other than user', async () => {
const service = createPassiveUserSummaryLinkService({
inboxMessages: [
{
from: 'alice',
text: JSON.stringify({
type: 'idle_notification',
idleReason: 'available',
summary: '[to bob] aligned on rollout order',
}),
timestamp: '2026-04-08T10:00:05.000Z',
read: true,
messageId: 'passive-bob-summary-1',
},
],
sentMessages: [
{
from: 'alice',
to: 'user',
text: 'aligned on rollout order',
timestamp: '2026-04-08T10:00:00.000Z',
read: true,
summary: 'aligned on rollout order',
messageId: 'user-reply-bob-summary-1',
source: 'user_sent',
},
],
});
const data = await service.getTeamData('my-team');
const linked = data.messages.find((message) => message.messageId === 'passive-bob-summary-1');
expect(linked?.relayOfMessageId).toBeUndefined();
});
it('does not link passive [to user] summaries when the sender differs', async () => {
const service = createPassiveUserSummaryLinkService({
inboxMessages: [
{
from: 'alice',
text: JSON.stringify({
type: 'idle_notification',
idleReason: 'available',
summary: '[to user] acknowledgement',
}),
timestamp: '2026-04-08T10:00:05.000Z',
read: true,
messageId: 'passive-user-summary-sender-1',
},
],
sentMessages: [
{
from: 'bob',
to: 'user',
text: 'Да, я здесь.',
timestamp: '2026-04-08T10:00:00.000Z',
read: true,
summary: 'acknowledgement',
messageId: 'user-reply-sender-1',
source: 'user_sent',
},
],
});
const data = await service.getTeamData('my-team');
const linked = data.messages.find(
(message) => message.messageId === 'passive-user-summary-sender-1'
);
expect(linked?.relayOfMessageId).toBeUndefined();
});
it('does not link passive [to user] summaries when multiple plausible user replies exist', async () => {
const service = createPassiveUserSummaryLinkService({
inboxMessages: [
{
from: 'alice',
text: JSON.stringify({
type: 'idle_notification',
idleReason: 'available',
summary: '[to user] acknowledgement',
}),
timestamp: '2026-04-08T10:00:05.000Z',
read: true,
messageId: 'passive-user-summary-ambiguous-1',
},
],
sentMessages: [
{
from: 'alice',
to: 'user',
text: 'Да, я здесь.',
timestamp: '2026-04-08T10:00:00.000Z',
read: true,
summary: 'acknowledgement',
messageId: 'user-reply-ambiguous-1',
source: 'user_sent',
},
{
from: 'alice',
to: 'user',
text: 'Да, на месте.',
timestamp: '2026-04-08T10:00:01.000Z',
read: true,
summary: 'acknowledgement',
messageId: 'user-reply-ambiguous-2',
source: 'user_sent',
},
],
});
const data = await service.getTeamData('my-team');
const linked = data.messages.find(
(message) => message.messageId === 'passive-user-summary-ambiguous-1'
);
expect(linked?.relayOfMessageId).toBeUndefined();
});
it('caches unchanged lead-session extraction results and returns defensive clones', async () => {
const service = createLeadSessionCachingService();
const jsonlPath = await createTempJsonl([

View file

@ -193,8 +193,52 @@ describe('ActivityItem legacy system message fallback', () => {
await Promise.resolve();
});
expect(host.textContent).toContain('[to bob] aligned on rollout order');
expect(host.textContent).toContain('update');
expect(host.textContent).toContain('alice');
expect(host.textContent).toContain('bob');
expect(host.textContent).toContain('aligned on rollout order');
expect(host.textContent).not.toContain('[to bob]');
expect(host.textContent).not.toContain('idle');
expect(host.textContent).not.toContain('Idle (available)');
expect(host.textContent).not.toContain('Raw JSON');
await act(async () => {
root.unmount();
await Promise.resolve();
});
});
it('renders user-directed peer-summary rows as passive updates instead of pseudo messages', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
const message: InboxMessage = {
from: 'alice',
text: JSON.stringify({
type: 'idle_notification',
from: 'alice',
timestamp: '2026-04-08T12:02:00.000Z',
idleReason: 'available',
summary: '[to user] Я здесь.',
}),
timestamp: new Date('2026-04-08T12:02:00.000Z').toISOString(),
read: true,
source: 'inbox',
};
await act(async () => {
root.render(React.createElement(ActivityItem, { message, teamName: 'my-team' }));
await Promise.resolve();
});
expect(host.textContent).toContain('update');
expect(host.textContent).toContain('alice');
expect(host.textContent).toContain('user');
expect(host.textContent).toContain('Я здесь.');
expect(host.textContent).not.toContain('[to user]');
expect(host.textContent).not.toContain('idle');
await act(async () => {
root.unmount();