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 { getMemberColorByName } from '@shared/constants/memberColors';
|
||||||
import { isLeadAgentType, isLeadMember } from '@shared/utils/leadDetection';
|
import { isLeadAgentType, isLeadMember } from '@shared/utils/leadDetection';
|
||||||
import { createLogger } from '@shared/utils/logger';
|
import { createLogger } from '@shared/utils/logger';
|
||||||
|
import { classifyIdleNotificationText } from '@shared/utils/idleNotificationSemantics';
|
||||||
import { getKanbanColumnFromReviewState, normalizeReviewState } from '@shared/utils/reviewState';
|
import { getKanbanColumnFromReviewState, normalizeReviewState } from '@shared/utils/reviewState';
|
||||||
import { buildStandaloneSlashCommandMeta } from '@shared/utils/slashCommands';
|
import { buildStandaloneSlashCommandMeta } from '@shared/utils/slashCommands';
|
||||||
import { formatTaskDisplayLabel } from '@shared/utils/taskIdentity';
|
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 PROCESS_HEALTH_INTERVAL_MS = 2_000;
|
||||||
const TASK_MAP_YIELD_EVERY = 250;
|
const TASK_MAP_YIELD_EVERY = 250;
|
||||||
const TASK_COMMENT_NOTIFICATION_SOURCE = 'system_notification';
|
const TASK_COMMENT_NOTIFICATION_SOURCE = 'system_notification';
|
||||||
|
const PASSIVE_USER_REPLY_LINK_WINDOW_MS = 15_000;
|
||||||
|
|
||||||
interface EligibleTaskCommentNotification {
|
interface EligibleTaskCommentNotification {
|
||||||
key: string;
|
key: string;
|
||||||
|
|
@ -119,6 +121,31 @@ interface FileWatchReconcileDiagnostics {
|
||||||
lastPressureLogAt: number;
|
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 {
|
interface FileWatchReconcileTrigger {
|
||||||
source: 'inbox' | 'task';
|
source: 'inbox' | 'task';
|
||||||
detail?: string;
|
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>> {
|
async getTaskChangePresence(teamName: string): Promise<Record<string, TaskChangePresenceState>> {
|
||||||
const config = await this.configReader.getConfig(teamName);
|
const config = await this.configReader.getConfig(teamName);
|
||||||
if (!config) {
|
if (!config) {
|
||||||
|
|
@ -802,6 +911,9 @@ export class TeamDataService {
|
||||||
}
|
}
|
||||||
mark('dedupMessageIds');
|
mark('dedupMessageIds');
|
||||||
|
|
||||||
|
messages = this.linkPassiveUserReplySummaries(messages);
|
||||||
|
mark('linkPassiveUserReplySummaries');
|
||||||
|
|
||||||
// Enrich inbox messages without leadSessionId by assigning the nearest neighbor's
|
// Enrich inbox messages without leadSessionId by assigning the nearest neighbor's
|
||||||
// session ID (by timestamp). This avoids the old forward-only propagation bug.
|
// session ID (by timestamp). This avoids the old forward-only propagation bug.
|
||||||
if (config.leadSessionId || messages.some((m) => m.leadSessionId)) {
|
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;
|
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(
|
export function isQualifiedExternalRecipient(
|
||||||
value: string | undefined,
|
value: string | undefined,
|
||||||
teamName: string,
|
teamName: string,
|
||||||
|
|
@ -305,6 +317,59 @@ const NoiseRow = ({
|
||||||
</div>
|
</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 = ({
|
const BootstrapSystemRow = ({
|
||||||
teamName,
|
teamName,
|
||||||
senderName,
|
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) {
|
if (bootstrapDisplay) {
|
||||||
return (
|
return (
|
||||||
<BootstrapSystemRow
|
<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 () => {
|
it('caches unchanged lead-session extraction results and returns defensive clones', async () => {
|
||||||
const service = createLeadSessionCachingService();
|
const service = createLeadSessionCachingService();
|
||||||
const jsonlPath = await createTempJsonl([
|
const jsonlPath = await createTempJsonl([
|
||||||
|
|
|
||||||
|
|
@ -193,8 +193,52 @@ describe('ActivityItem legacy system message fallback', () => {
|
||||||
await Promise.resolve();
|
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('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 () => {
|
await act(async () => {
|
||||||
root.unmount();
|
root.unmount();
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue