fix(team-data): dedupe passive user reply summaries
This commit is contained in:
parent
affd9ac748
commit
32ec3a6123
4 changed files with 497 additions and 1 deletions
|
|
@ -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)) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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([
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Reference in a new issue