- Introduced new parameters for cross-team messaging, including CROSS_TEAM_SENT_SOURCE for better tracking of sent messages. - Updated sendCrossTeamMessage function to append sent messages to the message store, ensuring a complete history of communications. - Enhanced tests to validate the new message storage functionality and ensure accurate retrieval of sent messages. - Improved handling of message timestamps and deduplication logic for cross-team communications.
404 lines
13 KiB
JavaScript
404 lines
13 KiB
JavaScript
const fs = require('fs');
|
|
const os = require('os');
|
|
const path = require('path');
|
|
|
|
const { createController } = require('../src/index.js');
|
|
const { CROSS_TEAM_SOURCE, CROSS_TEAM_PREFIX_TAG } = require('../src/internal/crossTeamProtocol.js');
|
|
|
|
describe('crossTeam module', () => {
|
|
function makeClaudeDir(teams = {}) {
|
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'crossteam-test-'));
|
|
|
|
for (const [teamName, config] of Object.entries(teams)) {
|
|
const teamDir = path.join(dir, 'teams', teamName);
|
|
const taskDir = path.join(dir, 'tasks', teamName);
|
|
fs.mkdirSync(teamDir, { recursive: true });
|
|
fs.mkdirSync(taskDir, { recursive: true });
|
|
fs.mkdirSync(path.join(teamDir, 'inboxes'), { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(teamDir, 'config.json'),
|
|
JSON.stringify(config, null, 2)
|
|
);
|
|
}
|
|
|
|
return dir;
|
|
}
|
|
|
|
afterEach(() => {
|
|
// Reset cascade guard between tests
|
|
const cascadeGuard = require('../src/internal/cascadeGuard.js');
|
|
cascadeGuard.reset();
|
|
});
|
|
|
|
describe('sendCrossTeamMessage', () => {
|
|
it('delivers message to target team inbox', () => {
|
|
const claudeDir = makeClaudeDir({
|
|
'team-a': {
|
|
name: 'team-a',
|
|
members: [{ name: 'team-lead', agentType: 'team-lead' }],
|
|
},
|
|
'team-b': {
|
|
name: 'team-b',
|
|
members: [{ name: 'team-lead', agentType: 'team-lead' }],
|
|
},
|
|
});
|
|
|
|
const controller = createController({ teamName: 'team-a', claudeDir });
|
|
const result = controller.crossTeam.sendCrossTeamMessage({
|
|
toTeam: 'team-b',
|
|
fromMember: 'lead',
|
|
text: 'Hello from team-a',
|
|
summary: 'Test message',
|
|
});
|
|
|
|
expect(result.deliveredToInbox).toBe(true);
|
|
expect(result.messageId).toBeDefined();
|
|
|
|
// Verify inbox was written
|
|
const inboxPath = path.join(claudeDir, 'teams', 'team-b', 'inboxes', 'team-lead.json');
|
|
const inbox = JSON.parse(fs.readFileSync(inboxPath, 'utf8'));
|
|
expect(inbox).toHaveLength(1);
|
|
expect(inbox[0].source).toBe(CROSS_TEAM_SOURCE);
|
|
expect(inbox[0].from).toBe('team-a.lead');
|
|
expect(inbox[0].text).toContain(`[${CROSS_TEAM_PREFIX_TAG} team-a.lead | depth:0`);
|
|
expect(inbox[0].conversationId).toBeTruthy();
|
|
expect(inbox[0].text).toContain(`conversation:${inbox[0].conversationId}`);
|
|
});
|
|
|
|
it('records outbox entry', () => {
|
|
const claudeDir = makeClaudeDir({
|
|
'team-a': {
|
|
name: 'team-a',
|
|
members: [{ name: 'team-lead', agentType: 'team-lead' }],
|
|
},
|
|
'team-b': {
|
|
name: 'team-b',
|
|
members: [{ name: 'team-lead', agentType: 'team-lead' }],
|
|
},
|
|
});
|
|
|
|
const controller = createController({ teamName: 'team-a', claudeDir });
|
|
controller.crossTeam.sendCrossTeamMessage({
|
|
toTeam: 'team-b',
|
|
text: 'Hello',
|
|
});
|
|
|
|
const outbox = controller.crossTeam.getCrossTeamOutbox();
|
|
expect(outbox).toHaveLength(1);
|
|
expect(outbox[0].toTeam).toBe('team-b');
|
|
expect(outbox[0].conversationId).toBeTruthy();
|
|
|
|
const sentMessagesPath = path.join(claudeDir, 'teams', 'team-a', 'sentMessages.json');
|
|
const sentMessages = JSON.parse(fs.readFileSync(sentMessagesPath, 'utf8'));
|
|
expect(sentMessages).toHaveLength(1);
|
|
expect(sentMessages[0].from).toBe('team-lead');
|
|
expect(sentMessages[0].to).toBe('team-b.team-lead');
|
|
expect(sentMessages[0].text).toBe('Hello');
|
|
expect(sentMessages[0].source).toBe('cross_team_sent');
|
|
expect(sentMessages[0].messageId).toBe(outbox[0].messageId);
|
|
});
|
|
|
|
it('preserves reply conversation metadata for explicit replies', () => {
|
|
const claudeDir = makeClaudeDir({
|
|
'team-a': {
|
|
name: 'team-a',
|
|
members: [{ name: 'team-lead', agentType: 'team-lead' }],
|
|
},
|
|
'team-b': {
|
|
name: 'team-b',
|
|
members: [{ name: 'team-lead', agentType: 'team-lead' }],
|
|
},
|
|
});
|
|
|
|
const controller = createController({ teamName: 'team-a', claudeDir });
|
|
controller.crossTeam.sendCrossTeamMessage({
|
|
toTeam: 'team-b',
|
|
text: 'Answering the open question',
|
|
replyToConversationId: 'conv-123',
|
|
});
|
|
|
|
const inboxPath = path.join(claudeDir, 'teams', 'team-b', 'inboxes', 'team-lead.json');
|
|
const inbox = JSON.parse(fs.readFileSync(inboxPath, 'utf8'));
|
|
expect(inbox[0].conversationId).toBe('conv-123');
|
|
expect(inbox[0].replyToConversationId).toBe('conv-123');
|
|
expect(inbox[0].text).toContain('conversation:conv-123');
|
|
expect(inbox[0].text).toContain('replyTo:conv-123');
|
|
});
|
|
|
|
it('deduplicates the same recent cross-team request', () => {
|
|
const claudeDir = makeClaudeDir({
|
|
'team-a': {
|
|
name: 'team-a',
|
|
members: [{ name: 'team-lead', agentType: 'team-lead' }],
|
|
},
|
|
'team-b': {
|
|
name: 'team-b',
|
|
members: [{ name: 'team-lead', agentType: 'team-lead' }],
|
|
},
|
|
});
|
|
|
|
const controller = createController({ teamName: 'team-a', claudeDir });
|
|
const first = controller.crossTeam.sendCrossTeamMessage({
|
|
toTeam: 'team-b',
|
|
fromMember: 'lead',
|
|
text: 'Please review the API contract',
|
|
summary: 'Review request',
|
|
});
|
|
const second = controller.crossTeam.sendCrossTeamMessage({
|
|
toTeam: 'team-b',
|
|
fromMember: 'lead',
|
|
text: 'Please review the API contract',
|
|
summary: ' Review request ',
|
|
});
|
|
|
|
expect(second.deliveredToInbox).toBe(true);
|
|
expect(second.deduplicated).toBe(true);
|
|
expect(second.messageId).toBe(first.messageId);
|
|
|
|
const inboxPath = path.join(claudeDir, 'teams', 'team-b', 'inboxes', 'team-lead.json');
|
|
const inbox = JSON.parse(fs.readFileSync(inboxPath, 'utf8'));
|
|
expect(inbox).toHaveLength(1);
|
|
|
|
const outbox = controller.crossTeam.getCrossTeamOutbox();
|
|
expect(outbox).toHaveLength(1);
|
|
|
|
const sentMessagesPath = path.join(claudeDir, 'teams', 'team-a', 'sentMessages.json');
|
|
const sentMessages = JSON.parse(fs.readFileSync(sentMessagesPath, 'utf8'));
|
|
expect(sentMessages).toHaveLength(1);
|
|
});
|
|
|
|
it('allows resending after dedupe window expires', () => {
|
|
const claudeDir = makeClaudeDir({
|
|
'team-a': {
|
|
name: 'team-a',
|
|
members: [{ name: 'team-lead', agentType: 'team-lead' }],
|
|
},
|
|
'team-b': {
|
|
name: 'team-b',
|
|
members: [{ name: 'team-lead', agentType: 'team-lead' }],
|
|
},
|
|
});
|
|
|
|
const controller = createController({ teamName: 'team-a', claudeDir });
|
|
const originalNow = Date.now;
|
|
let now = originalNow();
|
|
Date.now = () => now;
|
|
try {
|
|
const first = controller.crossTeam.sendCrossTeamMessage({
|
|
toTeam: 'team-b',
|
|
text: 'Need a decision on the schema',
|
|
summary: 'Schema decision',
|
|
});
|
|
|
|
now += 6 * 60 * 1000;
|
|
|
|
const second = controller.crossTeam.sendCrossTeamMessage({
|
|
toTeam: 'team-b',
|
|
text: 'Need a decision on the schema',
|
|
summary: 'Schema decision',
|
|
});
|
|
|
|
expect(second.deduplicated).toBeUndefined();
|
|
expect(second.messageId).not.toBe(first.messageId);
|
|
|
|
const inboxPath = path.join(claudeDir, 'teams', 'team-b', 'inboxes', 'team-lead.json');
|
|
const inbox = JSON.parse(fs.readFileSync(inboxPath, 'utf8'));
|
|
expect(inbox).toHaveLength(2);
|
|
|
|
const updatedOutbox = controller.crossTeam.getCrossTeamOutbox();
|
|
expect(updatedOutbox).toHaveLength(2);
|
|
} finally {
|
|
Date.now = originalNow;
|
|
}
|
|
});
|
|
|
|
it('rejects self-send', () => {
|
|
const claudeDir = makeClaudeDir({
|
|
'team-a': {
|
|
name: 'team-a',
|
|
members: [{ name: 'team-lead', agentType: 'team-lead' }],
|
|
},
|
|
});
|
|
|
|
const controller = createController({ teamName: 'team-a', claudeDir });
|
|
expect(() =>
|
|
controller.crossTeam.sendCrossTeamMessage({
|
|
toTeam: 'team-a',
|
|
text: 'Self',
|
|
})
|
|
).toThrow('same team');
|
|
});
|
|
|
|
it('rejects when target not found', () => {
|
|
const claudeDir = makeClaudeDir({
|
|
'team-a': {
|
|
name: 'team-a',
|
|
members: [{ name: 'team-lead', agentType: 'team-lead' }],
|
|
},
|
|
});
|
|
|
|
const controller = createController({ teamName: 'team-a', claudeDir });
|
|
expect(() =>
|
|
controller.crossTeam.sendCrossTeamMessage({
|
|
toTeam: 'team-nonexistent',
|
|
text: 'Hello',
|
|
})
|
|
).toThrow('Target team not found');
|
|
});
|
|
|
|
it('rejects when target is deleted', () => {
|
|
const claudeDir = makeClaudeDir({
|
|
'team-a': {
|
|
name: 'team-a',
|
|
members: [{ name: 'team-lead', agentType: 'team-lead' }],
|
|
},
|
|
'team-b': {
|
|
name: 'team-b',
|
|
members: [{ name: 'team-lead', agentType: 'team-lead' }],
|
|
deletedAt: '2024-01-01T00:00:00Z',
|
|
},
|
|
});
|
|
|
|
const controller = createController({ teamName: 'team-a', claudeDir });
|
|
expect(() =>
|
|
controller.crossTeam.sendCrossTeamMessage({
|
|
toTeam: 'team-b',
|
|
text: 'Hello',
|
|
})
|
|
).toThrow('Target team not found');
|
|
});
|
|
|
|
it('rejects excessive chain depth', () => {
|
|
const claudeDir = makeClaudeDir({
|
|
'team-a': {
|
|
name: 'team-a',
|
|
members: [{ name: 'team-lead', agentType: 'team-lead' }],
|
|
},
|
|
'team-b': {
|
|
name: 'team-b',
|
|
members: [{ name: 'team-lead', agentType: 'team-lead' }],
|
|
},
|
|
});
|
|
|
|
const controller = createController({ teamName: 'team-a', claudeDir });
|
|
expect(() =>
|
|
controller.crossTeam.sendCrossTeamMessage({
|
|
toTeam: 'team-b',
|
|
text: 'Hello',
|
|
chainDepth: 5,
|
|
})
|
|
).toThrow('chain depth');
|
|
});
|
|
});
|
|
|
|
describe('resolveTargetLead', () => {
|
|
it('resolves lead by agentType', () => {
|
|
const claudeDir = makeClaudeDir({
|
|
'team-a': {
|
|
name: 'team-a',
|
|
members: [{ name: 'alpha-lead', agentType: 'team-lead' }],
|
|
},
|
|
'team-b': {
|
|
name: 'team-b',
|
|
members: [{ name: 'beta-lead', agentType: 'team-lead' }],
|
|
},
|
|
});
|
|
|
|
const controller = createController({ teamName: 'team-a', claudeDir });
|
|
controller.crossTeam.sendCrossTeamMessage({
|
|
toTeam: 'team-b',
|
|
text: 'Hello',
|
|
});
|
|
|
|
const inboxPath = path.join(claudeDir, 'teams', 'team-b', 'inboxes', 'beta-lead.json');
|
|
expect(fs.existsSync(inboxPath)).toBe(true);
|
|
});
|
|
|
|
it('resolves lead by name fallback', () => {
|
|
const claudeDir = makeClaudeDir({
|
|
'team-a': {
|
|
name: 'team-a',
|
|
members: [{ name: 'team-lead' }],
|
|
},
|
|
'team-b': {
|
|
name: 'team-b',
|
|
members: [{ name: 'team-lead' }],
|
|
},
|
|
});
|
|
|
|
const controller = createController({ teamName: 'team-a', claudeDir });
|
|
controller.crossTeam.sendCrossTeamMessage({
|
|
toTeam: 'team-b',
|
|
text: 'Hello',
|
|
});
|
|
|
|
const inboxPath = path.join(claudeDir, 'teams', 'team-b', 'inboxes', 'team-lead.json');
|
|
expect(fs.existsSync(inboxPath)).toBe(true);
|
|
});
|
|
|
|
it('resolves lead from members.meta.json with normalization', () => {
|
|
const claudeDir = makeClaudeDir({
|
|
'team-a': {
|
|
name: 'team-a',
|
|
members: [{ name: 'team-lead' }],
|
|
},
|
|
'team-b': {
|
|
name: 'team-b',
|
|
members: [],
|
|
},
|
|
});
|
|
|
|
// Write meta with dirty data (leading spaces, duplicates)
|
|
const metaPath = path.join(claudeDir, 'teams', 'team-b', 'members.meta.json');
|
|
fs.writeFileSync(
|
|
metaPath,
|
|
JSON.stringify({
|
|
members: [
|
|
{ name: ' meta-lead ', agentType: 'team-lead' },
|
|
{ name: ' meta-lead ', agentType: 'team-lead' },
|
|
{ name: 'worker', agentType: 'worker' },
|
|
],
|
|
})
|
|
);
|
|
|
|
const controller = createController({ teamName: 'team-a', claudeDir });
|
|
controller.crossTeam.sendCrossTeamMessage({
|
|
toTeam: 'team-b',
|
|
text: 'Hello',
|
|
});
|
|
|
|
const inboxPath = path.join(claudeDir, 'teams', 'team-b', 'inboxes', 'meta-lead.json');
|
|
expect(fs.existsSync(inboxPath)).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('listCrossTeamTargets', () => {
|
|
it('lists valid teams excluding current', () => {
|
|
const claudeDir = makeClaudeDir({
|
|
'team-a': { name: 'Team A' },
|
|
'team-b': { name: 'Team B', description: 'B desc' },
|
|
'team-c': { name: 'Team C', deletedAt: '2024-01-01' },
|
|
});
|
|
|
|
const controller = createController({ teamName: 'team-a', claudeDir });
|
|
const targets = controller.crossTeam.listCrossTeamTargets();
|
|
|
|
expect(targets).toHaveLength(1);
|
|
expect(targets[0].teamName).toBe('team-b');
|
|
expect(targets[0].displayName).toBe('Team B');
|
|
expect(targets[0].description).toBe('B desc');
|
|
});
|
|
});
|
|
|
|
describe('getCrossTeamOutbox', () => {
|
|
it('returns empty for non-existent outbox', () => {
|
|
const claudeDir = makeClaudeDir({
|
|
'team-a': { name: 'Team A' },
|
|
});
|
|
|
|
const controller = createController({ teamName: 'team-a', claudeDir });
|
|
const outbox = controller.crossTeam.getCrossTeamOutbox();
|
|
expect(outbox).toEqual([]);
|
|
});
|
|
});
|
|
});
|