diff --git a/src/main/ipc/teams.ts b/src/main/ipc/teams.ts index 65b78257..65733d3b 100644 --- a/src/main/ipc/teams.ts +++ b/src/main/ipc/teams.ts @@ -178,6 +178,84 @@ const logger = createLogger('IPC:teams'); const seenRateLimitKeys = new Set(); const SEEN_RATE_LIMIT_KEYS_MAX = 500; +async function getDurableLeadTeammateRoster( + teamName: string, + leadName: string +): Promise> { + 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.`, diff --git a/test/main/ipc/teams.test.ts b/test/main/ipc/teams.test.ts index e60cb2ed..2efb6c24 100644 --- a/test/main/ipc/teams.test.ts +++ b/test/main/ipc/teams.test.ts @@ -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',