fix(team): add durable roster context to lead direct messages
This commit is contained in:
parent
78d4c2826b
commit
2624ada4a2
2 changed files with 151 additions and 0 deletions
|
|
@ -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.`,
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
Loading…
Reference in a new issue