fix(team): add durable roster context to lead direct messages

This commit is contained in:
iliya 2026-04-08 17:51:02 +03:00
parent 78d4c2826b
commit 2624ada4a2
2 changed files with 151 additions and 0 deletions

View file

@ -178,6 +178,84 @@ const logger = createLogger('IPC:teams');
const seenRateLimitKeys = new Set<string>();
const SEEN_RATE_LIMIT_KEYS_MAX = 500;
async function getDurableLeadTeammateRoster(
teamName: string,
leadName: string
): Promise<Array<{ name: string; role?: string }>> {
const normalize = (name: string | undefined | null): string => name?.trim().toLowerCase() ?? '';
const leadLower = normalize(leadName);
const reserved = new Set(['team-lead', 'user', leadLower].filter((value) => value.length > 0));
try {
const members = await new TeamMembersMetaStore().getMembers(teamName);
const teammates = members
.filter((member) => !member.removedAt)
.filter((member) => {
const lower = normalize(member.name);
return lower.length > 0 && !reserved.has(lower);
})
.map((member) => ({
name: member.name.trim(),
role:
typeof member.role === 'string' && member.role.trim().length > 0
? member.role.trim()
: undefined,
}));
if (teammates.length > 0) return teammates;
} catch (error) {
logger.debug(
`[teams:sendMessage] Failed to read members.meta roster for "${teamName}": ${
error instanceof Error ? error.message : String(error)
}`
);
}
try {
const data = await getTeamDataService().getTeamData(teamName);
return data.members
.filter((member) => !member.removedAt)
.filter((member) => {
const lower = normalize(member.name);
return lower.length > 0 && !reserved.has(lower);
})
.map((member) => ({
name: member.name.trim(),
role:
typeof member.role === 'string' && member.role.trim().length > 0
? member.role.trim()
: undefined,
}));
} catch (error) {
logger.debug(
`[teams:sendMessage] Failed to read fallback team roster for "${teamName}": ${
error instanceof Error ? error.message : String(error)
}`
);
return [];
}
}
function buildLeadRosterContextBlock(
teamName: string,
leadName: string,
teammates: Array<{ name: string; role?: string }>
): string | null {
if (teammates.length === 0) return null;
const summary = teammates
.map((member) => (member.role ? `${member.name} (${member.role})` : member.name))
.join(', ');
return [
`Current durable team context:`,
`- Team name: ${teamName}`,
`- You are the live team lead "${leadName}"`,
`- Persistent teammates currently configured: ${summary}`,
`- This team is NOT in solo mode`,
`- If the user asks who is on the team, answer from this durable roster unless newer durable state explicitly says otherwise.`,
].join('\n');
}
/**
* In-memory set of API error message keys already processed.
* Independent of NotificationManager storage survives notification deletion/pruning.
@ -1596,6 +1674,8 @@ async function handleSendMessage(
// Smart routing: lead + alive → stdin direct, else → inbox
if (isLeadRecipient && isAlive) {
const resolvedLeadName = leadName ?? memberName;
const teammateRoster = await getDurableLeadTeammateRoster(tn, resolvedLeadName);
const rosterContextBlock = buildLeadRosterContextBlock(tn, resolvedLeadName, teammateRoster);
// Pre-generate stable messageId so both stdin and persistence use the same identity.
// This allows the lead to call task_create_from_message with the exact messageId.
const preGeneratedMessageId = crypto.randomUUID();
@ -1613,6 +1693,7 @@ async function handleSendMessage(
: [
`You received a direct message from the user.`,
`IMPORTANT: Your text response here is shown to the user in the Messages panel. Always include a brief human-readable reply. Do NOT respond with only an agent-only block.`,
...(rosterContextBlock ? [rosterContextBlock] : []),
AGENT_BLOCK_OPEN,
`MessageId: ${preGeneratedMessageId}`,
`When creating a task from this user message, prefer task_create_from_message with messageId="${preGeneratedMessageId}" for reliable provenance. Only use this exact messageId — never guess or fabricate one.`,

View file

@ -18,6 +18,9 @@ vi.mock('@preload/constants/ipcChannels', async (importOriginal) => {
const { mockAddTeamNotification } = vi.hoisted(() => ({
mockAddTeamNotification: vi.fn().mockResolvedValue({ id: 'n1', isRead: false, createdAt: Date.now() }),
}));
const { mockGetMembersMeta } = vi.hoisted(() => ({
mockGetMembersMeta: vi.fn(),
}));
vi.mock('@main/services/infrastructure/NotificationManager', () => ({
NotificationManager: {
getInstance: vi.fn().mockReturnValue({
@ -25,6 +28,11 @@ vi.mock('@main/services/infrastructure/NotificationManager', () => ({
}),
},
}));
vi.mock('@main/services/team/TeamMembersMetaStore', () => ({
TeamMembersMetaStore: vi.fn().mockImplementation(() => ({
getMembers: mockGetMembersMeta,
})),
}));
import {
TEAM_ALIVE_LIST,
@ -172,6 +180,8 @@ describe('ipc teams handlers', () => {
beforeEach(() => {
handlers.clear();
vi.clearAllMocks();
mockGetMembersMeta.mockReset();
mockGetMembersMeta.mockResolvedValue([]);
initializeTeamHandlers(service as never, provisioningService as never);
registerTeamHandlers(ipcMain as never);
});
@ -298,6 +308,64 @@ describe('ipc teams handlers', () => {
);
});
it('injects durable teammate roster context into the first live lead direct-message wrapper', async () => {
mockGetMembersMeta.mockResolvedValueOnce([
{ name: 'team-lead', role: 'lead' },
{ name: 'alice', role: 'reviewer' },
{ name: 'jack', role: 'developer' },
]);
const sendHandler = handlers.get(TEAM_SEND_MESSAGE);
expect(sendHandler).toBeDefined();
const result = (await sendHandler!({} as never, 'my-team', {
member: 'team-lead',
text: 'Who is on the team right now?',
})) as { success: boolean };
expect(result.success).toBe(true);
expect(provisioningService.sendMessageToTeam).toHaveBeenCalledWith(
'my-team',
expect.stringContaining('Current durable team context:'),
undefined
);
expect(provisioningService.sendMessageToTeam).toHaveBeenCalledWith(
'my-team',
expect.stringContaining('Persistent teammates currently configured: alice (reviewer), jack (developer)'),
undefined
);
expect(provisioningService.sendMessageToTeam).toHaveBeenCalledWith(
'my-team',
expect.stringContaining('This team is NOT in solo mode'),
undefined
);
});
it('omits roster context when durable teammate roster is empty', async () => {
mockGetMembersMeta.mockResolvedValueOnce([]);
service.getTeamData.mockResolvedValueOnce({
teamName: 'my-team',
config: { name: 'My Team' },
tasks: [],
members: [],
messages: [] as InboxMessage[],
kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} },
processes: [],
});
const sendHandler = handlers.get(TEAM_SEND_MESSAGE);
expect(sendHandler).toBeDefined();
const result = (await sendHandler!({} as never, 'my-team', {
member: 'team-lead',
text: 'Who is on the team right now?',
})) as { success: boolean };
expect(result.success).toBe(true);
const stdinCall = vi.mocked(provisioningService.sendMessageToTeam).mock.calls[0] as
| unknown[]
| undefined;
expect(String(stdinCall?.[1] ?? '')).not.toContain('Current durable team context:');
});
it('sends standalone slash commands to lead stdin without the UI routing wrapper', async () => {
const sendHandler = handlers.get(TEAM_SEND_MESSAGE);
expect(sendHandler).toBeDefined();
@ -316,6 +384,7 @@ describe('ipc teams handlers', () => {
const compactCall = vi.mocked(provisioningService.sendMessageToTeam).mock
.calls as unknown[][];
expect(String(compactCall[0]?.[1] ?? '')).not.toContain('You received a direct message from the user');
expect(String(compactCall[0]?.[1] ?? '')).not.toContain('Current durable team context:');
expect(service.sendDirectToLead).toHaveBeenCalledWith(
'my-team',
'team-lead',
@ -347,6 +416,7 @@ describe('ipc teams handlers', () => {
expect(String(unknownSlashCall[0]?.[1] ?? '')).not.toContain(
'You received a direct message from the user'
);
expect(String(unknownSlashCall[0]?.[1] ?? '')).not.toContain('Current durable team context:');
expect(service.sendDirectToLead).toHaveBeenCalledWith(
'my-team',
'team-lead',