diff --git a/agent-teams-controller/src/internal/crossTeam.js b/agent-teams-controller/src/internal/crossTeam.js index b331252f..55dd4c9e 100644 --- a/agent-teams-controller/src/internal/crossTeam.js +++ b/agent-teams-controller/src/internal/crossTeam.js @@ -5,8 +5,10 @@ const { createControllerContext } = require('./context.js'); const { withFileLockSync } = require('./fileLock.js'); const cascadeGuard = require('./cascadeGuard.js'); const runtimeHelpers = require('./runtimeHelpers.js'); +const { formatCrossTeamText, CROSS_TEAM_SOURCE } = require('./crossTeamProtocol.js'); const TEAM_NAME_PATTERN = /^[a-z0-9][a-z0-9-]{0,127}$/; +const CROSS_TEAM_DEDUPE_WINDOW_MS = 5 * 60 * 1000; function readJson(filePath, fallbackValue) { try { @@ -87,6 +89,50 @@ function createTargetContext(sourceContext, toTeam) { }); } +function normalizeForDedupe(value) { + return String(value || '') + .trim() + .replace(/\s+/g, ' ') + .toLowerCase(); +} + +function buildCrossTeamDedupeKey(fromTeam, fromMember, toTeam, text, summary) { + return [ + normalizeForDedupe(fromTeam), + normalizeForDedupe(fromMember), + normalizeForDedupe(toTeam), + normalizeForDedupe(summary), + normalizeForDedupe(text), + ].join('||'); +} + +function getCrossTeamMessageDedupeKey(message) { + if (!message || typeof message !== 'object') return ''; + return buildCrossTeamDedupeKey( + message.fromTeam, + message.fromMember, + message.toTeam, + message.text, + message.summary + ); +} + +function findRecentDuplicate(outboxList, dedupeKey) { + if (!Array.isArray(outboxList) || !dedupeKey) return null; + const cutoff = Date.now() - CROSS_TEAM_DEDUPE_WINDOW_MS; + for (let i = outboxList.length - 1; i >= 0; i -= 1) { + const entry = outboxList[i]; + const ts = Date.parse(entry && entry.timestamp ? entry.timestamp : ''); + if (!Number.isFinite(ts) || ts < cutoff) { + break; + } + if (getCrossTeamMessageDedupeKey(entry) === dedupeKey) { + return entry; + } + } + return null; +} + function sendCrossTeamMessage(context, flags) { const fromTeam = context.teamName; const toTeam = typeof flags.toTeam === 'string' ? flags.toTeam.trim() : ''; @@ -119,45 +165,52 @@ function sendCrossTeamMessage(context, flags) { // Resolve lead const leadName = resolveTargetLead(targetContext.paths, targetConfig); - // Cascade check - cascadeGuard.check(fromTeam, toTeam, chainDepth); - cascadeGuard.record(fromTeam, toTeam); - // Format const from = `${fromTeam}.${fromMember}`; - const formattedText = `[Cross-team from ${from} | depth:${chainDepth}]\n${text}`; + const formattedText = formatCrossTeamText(from, chainDepth, text); const messageId = crypto.randomUUID ? crypto.randomUUID() : `${Date.now()}-${Math.random()}`; + const dedupeKey = buildCrossTeamDedupeKey(fromTeam, fromMember, toTeam, text, summary); - // Cross-process safe inbox write const inboxPath = path.join(targetContext.paths.teamDir, 'inboxes', `${leadName}.json`); - withFileLockSync(inboxPath, () => { - fs.mkdirSync(path.dirname(inboxPath), { recursive: true }); - const current = readJson(inboxPath, []); - const list = Array.isArray(current) ? current : []; - list.push({ - from, - to: leadName, - text: formattedText, - timestamp: new Date().toISOString(), - read: false, - summary: summary || `Cross-team message from ${fromTeam}`, - messageId, - source: 'cross_team', - }); - writeJson(inboxPath, list); - }); - - // Verify - const inbox = readJson(inboxPath, []); - if (!inbox.some((m) => m.messageId === messageId)) { - throw new Error('Cross-team inbox write verification failed'); - } - - // Record outbox (with file lock) const outboxPath = path.join(context.paths.teamDir, 'sent-cross-team.json'); + let duplicate = null; withFileLockSync(outboxPath, () => { const outbox = readJson(outboxPath, []); const outList = Array.isArray(outbox) ? outbox : []; + duplicate = findRecentDuplicate(outList, dedupeKey); + if (duplicate) { + return; + } + + // Cascade check only for real new deliveries. + cascadeGuard.check(fromTeam, toTeam, chainDepth); + cascadeGuard.record(fromTeam, toTeam); + + // Cross-process safe inbox write + withFileLockSync(inboxPath, () => { + fs.mkdirSync(path.dirname(inboxPath), { recursive: true }); + const current = readJson(inboxPath, []); + const list = Array.isArray(current) ? current : []; + list.push({ + from, + to: leadName, + text: formattedText, + timestamp: new Date().toISOString(), + read: false, + summary: summary || `Cross-team message from ${fromTeam}`, + messageId, + source: CROSS_TEAM_SOURCE, + }); + writeJson(inboxPath, list); + }); + + // Verify while still inside dedupe lock so duplicate callers + // cannot append the same request to outbox concurrently. + const inbox = readJson(inboxPath, []); + if (!inbox.some((m) => m.messageId === messageId)) { + throw new Error('Cross-team inbox write verification failed'); + } + outList.push({ messageId, fromTeam, @@ -171,6 +224,14 @@ function sendCrossTeamMessage(context, flags) { writeJson(outboxPath, outList); }); + if (duplicate) { + return { + messageId: duplicate.messageId, + deliveredToInbox: true, + deduplicated: true, + }; + } + return { messageId, deliveredToInbox: true }; } diff --git a/agent-teams-controller/src/internal/crossTeamProtocol.js b/agent-teams-controller/src/internal/crossTeamProtocol.js new file mode 100644 index 00000000..b98d7132 --- /dev/null +++ b/agent-teams-controller/src/internal/crossTeamProtocol.js @@ -0,0 +1,22 @@ +// Cross-team message protocol constants. +// Mirror of src/shared/constants/crossTeam.ts — keep in sync. + +const CROSS_TEAM_PREFIX_TAG = 'Cross-team from'; +const CROSS_TEAM_SOURCE = 'cross_team'; +const CROSS_TEAM_SENT_SOURCE = 'cross_team_sent'; + +function formatCrossTeamPrefix(from, chainDepth) { + return `[${CROSS_TEAM_PREFIX_TAG} ${from} | depth:${chainDepth}]`; +} + +function formatCrossTeamText(from, chainDepth, text) { + return `${formatCrossTeamPrefix(from, chainDepth)}\n${text}`; +} + +module.exports = { + CROSS_TEAM_PREFIX_TAG, + CROSS_TEAM_SOURCE, + CROSS_TEAM_SENT_SOURCE, + formatCrossTeamPrefix, + formatCrossTeamText, +}; diff --git a/agent-teams-controller/test/crossTeam.test.js b/agent-teams-controller/test/crossTeam.test.js index 325f6b63..71fb7959 100644 --- a/agent-teams-controller/test/crossTeam.test.js +++ b/agent-teams-controller/test/crossTeam.test.js @@ -3,6 +3,7 @@ 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 = {}) { @@ -57,9 +58,9 @@ describe('crossTeam module', () => { 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'); + expect(inbox[0].source).toBe(CROSS_TEAM_SOURCE); expect(inbox[0].from).toBe('team-a.lead'); - expect(inbox[0].text).toContain('[Cross-team from team-a.lead | depth:0]'); + expect(inbox[0].text).toContain(`[${CROSS_TEAM_PREFIX_TAG} team-a.lead | depth:0]`); }); it('records outbox entry', () => { @@ -85,6 +86,89 @@ describe('crossTeam module', () => { expect(outbox[0].toTeam).toBe('team-b'); }); + 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); + }); + + 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': { diff --git a/electron.vite.config.1773089413142.mjs b/electron.vite.config.1773089413142.mjs new file mode 100644 index 00000000..63a288e9 --- /dev/null +++ b/electron.vite.config.1773089413142.mjs @@ -0,0 +1,105 @@ +// electron.vite.config.ts +import { defineConfig, externalizeDepsPlugin } from "electron-vite"; +import react from "@vitejs/plugin-react"; +import { readFileSync } from "fs"; +import { resolve } from "path"; +var __electron_vite_injected_dirname = "/Users/belief/dev/projects/claude/claude_team"; +var pkg = JSON.parse(readFileSync(resolve(__electron_vite_injected_dirname, "package.json"), "utf-8")); +var prodDeps = Object.keys(pkg.dependencies || {}); +var bundledDeps = prodDeps.filter((d) => d !== "node-pty" && d !== "agent-teams-controller"); +function nativeModuleStub() { + const STUB_ID = "\0native-stub"; + return { + name: "native-module-stub", + resolveId(source) { + if (source.endsWith(".node")) return STUB_ID; + return null; + }, + load(id) { + if (id === STUB_ID) return "export default {}"; + return null; + } + }; +} +var electron_vite_config_default = defineConfig({ + main: { + plugins: [ + externalizeDepsPlugin({ + exclude: bundledDeps + }), + nativeModuleStub() + ], + resolve: { + alias: { + "@main": resolve(__electron_vite_injected_dirname, "src/main"), + "@shared": resolve(__electron_vite_injected_dirname, "src/shared"), + "@preload": resolve(__electron_vite_injected_dirname, "src/preload") + } + }, + build: { + outDir: "dist-electron/main", + rollupOptions: { + input: { + index: resolve(__electron_vite_injected_dirname, "src/main/index.ts"), + "team-fs-worker": resolve(__electron_vite_injected_dirname, "src/main/workers/team-fs-worker.ts") + }, + output: { + // CJS format so bundled deps can use __dirname/require. + // Use .cjs extension since package.json has "type": "module". + format: "cjs", + entryFileNames: "[name].cjs", + // Set UV_THREADPOOL_SIZE before any module code runs. + // Must be in the banner because ESM→CJS hoists imports above top-level code. + // On Windows, fs.watch({recursive:true}) occupies a UV pool thread per watcher; + // with 3+ watchers + concurrent fs/DNS/spawn, the default 4 threads deadlock. + banner: `if(!process.env.UV_THREADPOOL_SIZE){process.env.UV_THREADPOOL_SIZE='24'}` + } + } + } + }, + preload: { + plugins: [externalizeDepsPlugin()], + resolve: { + alias: { + "@preload": resolve(__electron_vite_injected_dirname, "src/preload"), + "@shared": resolve(__electron_vite_injected_dirname, "src/shared"), + "@main": resolve(__electron_vite_injected_dirname, "src/main") + } + }, + build: { + outDir: "dist-electron/preload", + rollupOptions: { + input: { + index: resolve(__electron_vite_injected_dirname, "src/preload/index.ts") + }, + output: { + format: "cjs", + entryFileNames: "[name].js" + } + } + } + }, + renderer: { + optimizeDeps: { + include: ["@codemirror/language-data"] + }, + resolve: { + alias: { + "@renderer": resolve(__electron_vite_injected_dirname, "src/renderer"), + "@shared": resolve(__electron_vite_injected_dirname, "src/shared"), + "@main": resolve(__electron_vite_injected_dirname, "src/main") + } + }, + plugins: [react()], + build: { + rollupOptions: { + input: { + index: resolve(__electron_vite_injected_dirname, "src/renderer/index.html") + } + } + } + } +}); +export { + electron_vite_config_default as default +}; diff --git a/src/main/services/team/CrossTeamOutbox.ts b/src/main/services/team/CrossTeamOutbox.ts index 2e23a664..1b4c0e96 100644 --- a/src/main/services/team/CrossTeamOutbox.ts +++ b/src/main/services/team/CrossTeamOutbox.ts @@ -6,33 +6,53 @@ import { withFileLock } from './fileLock'; import type { CrossTeamMessage } from '@shared/types'; +const CROSS_TEAM_DEDUPE_WINDOW_MS = 5 * 60 * 1000; + +function normalizeForDedupe(value: string | undefined): string { + return String(value ?? '') + .trim() + .replace(/\s+/g, ' ') + .toLowerCase(); +} + +function buildCrossTeamDedupeKey(message: CrossTeamMessage): string { + return [ + normalizeForDedupe(message.fromTeam), + normalizeForDedupe(message.fromMember), + normalizeForDedupe(message.toTeam), + normalizeForDedupe(message.summary), + normalizeForDedupe(message.text), + ].join('||'); +} + +function findRecentDuplicate( + list: CrossTeamMessage[], + message: CrossTeamMessage, + windowMs: number +): CrossTeamMessage | null { + const dedupeKey = buildCrossTeamDedupeKey(message); + const cutoff = Date.now() - windowMs; + + for (let i = list.length - 1; i >= 0; i -= 1) { + const entry = list[i]; + const ts = Date.parse(entry.timestamp); + if (!Number.isFinite(ts) || ts < cutoff) { + break; + } + if (buildCrossTeamDedupeKey(entry) === dedupeKey) { + return entry; + } + } + + return null; +} + export class CrossTeamOutbox { private getOutboxPath(teamName: string): string { return path.join(getTeamsBasePath(), teamName, 'sent-cross-team.json'); } - async append(teamName: string, message: CrossTeamMessage): Promise { - const outboxPath = this.getOutboxPath(teamName); - await withFileLock(outboxPath, async () => { - let list: CrossTeamMessage[] = []; - try { - const raw = await fs.promises.readFile(outboxPath, 'utf8'); - const parsed = JSON.parse(raw) as unknown; - if (Array.isArray(parsed)) { - list = parsed as CrossTeamMessage[]; - } - } catch (err) { - if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err; - } - list.push(message); - const dir = path.dirname(outboxPath); - await fs.promises.mkdir(dir, { recursive: true }); - await fs.promises.writeFile(outboxPath, JSON.stringify(list, null, 2), 'utf8'); - }); - } - - async read(teamName: string): Promise { - const outboxPath = this.getOutboxPath(teamName); + private async readUnlocked(outboxPath: string): Promise { try { const raw = await fs.promises.readFile(outboxPath, 'utf8'); const parsed = JSON.parse(raw) as unknown; @@ -42,4 +62,45 @@ export class CrossTeamOutbox { throw err; } } + + async append(teamName: string, message: CrossTeamMessage): Promise { + const outboxPath = this.getOutboxPath(teamName); + await withFileLock(outboxPath, async () => { + const list = await this.readUnlocked(outboxPath); + list.push(message); + const dir = path.dirname(outboxPath); + await fs.promises.mkdir(dir, { recursive: true }); + await fs.promises.writeFile(outboxPath, JSON.stringify(list, null, 2), 'utf8'); + }); + } + + async appendIfNotRecent( + teamName: string, + message: CrossTeamMessage, + onBeforeAppend: () => Promise, + windowMs = CROSS_TEAM_DEDUPE_WINDOW_MS + ): Promise<{ duplicate: CrossTeamMessage | null }> { + const outboxPath = this.getOutboxPath(teamName); + let duplicate: CrossTeamMessage | null = null; + + await withFileLock(outboxPath, async () => { + const list = await this.readUnlocked(outboxPath); + duplicate = findRecentDuplicate(list, message, windowMs); + if (duplicate) return; + + await onBeforeAppend(); + + list.push(message); + const dir = path.dirname(outboxPath); + await fs.promises.mkdir(dir, { recursive: true }); + await fs.promises.writeFile(outboxPath, JSON.stringify(list, null, 2), 'utf8'); + }); + + return { duplicate }; + } + + async read(teamName: string): Promise { + const outboxPath = this.getOutboxPath(teamName); + return this.readUnlocked(outboxPath); + } } diff --git a/src/main/services/team/CrossTeamService.ts b/src/main/services/team/CrossTeamService.ts index 6ddbcfaa..84c12274 100644 --- a/src/main/services/team/CrossTeamService.ts +++ b/src/main/services/team/CrossTeamService.ts @@ -1,3 +1,4 @@ +import { CROSS_TEAM_SENT_SOURCE, CROSS_TEAM_SOURCE, formatCrossTeamText } from '@shared/constants'; import { getTeamsBasePath } from '@main/utils/pathDecoder'; import { createLogger } from '@shared/utils/logger'; import { randomUUID } from 'crypto'; @@ -67,24 +68,40 @@ export class CrossTeamService { // 2. Resolve lead const leadName = (await this.dataService.getLeadMemberName(toTeam)) ?? 'team-lead'; - // 3. Cascade check - this.cascadeGuard.check(fromTeam, toTeam, chainDepth); - this.cascadeGuard.record(fromTeam, toTeam); - - // 4. Format + // 3. Format const from = `${fromTeam}.${fromMember}`; - const formattedText = `[Cross-team from ${from} | depth:${chainDepth}]\n${text}`; + const formattedText = formatCrossTeamText(from, chainDepth, text); const messageId = randomUUID(); + const outboxMessage: CrossTeamMessage = { + messageId, + fromTeam, + fromMember, + toTeam, + text, + summary, + chainDepth, + timestamp: new Date().toISOString(), + }; - // 5. Inbox write to TARGET team (TeamInboxWriter handles file lock + in-process lock internally) - await this.inboxWriter.sendMessage(toTeam, { - member: leadName, - text: formattedText, - from, - summary: summary ?? `Cross-team message from ${fromTeam}`, - source: 'cross_team', + const { duplicate } = await this.outbox.appendIfNotRecent(fromTeam, outboxMessage, async () => { + // 4. Cascade check only for real new deliveries + this.cascadeGuard.check(fromTeam, toTeam, chainDepth); + this.cascadeGuard.record(fromTeam, toTeam); + + // 5. Inbox write to TARGET team (TeamInboxWriter handles file lock + in-process lock internally) + await this.inboxWriter.sendMessage(toTeam, { + member: leadName, + text: formattedText, + from, + summary: summary ?? `Cross-team message from ${fromTeam}`, + source: CROSS_TEAM_SOURCE, + }); }); + if (duplicate) { + return { messageId: duplicate.messageId, deliveredToInbox: true, deduplicated: true }; + } + // 6. Write "sent" copy to SENDER's inbox so the message appears in their activity const senderLeadName = (await this.dataService.getLeadMemberName(fromTeam)) ?? 'team-lead'; void this.inboxWriter @@ -94,7 +111,7 @@ export class CrossTeamService { from: 'user', to: `${toTeam}.${leadName}`, summary: summary ?? `Cross-team message to ${toTeam}`, - source: 'cross_team_sent', + source: CROSS_TEAM_SENT_SOURCE, }) .catch((e: unknown) => { logger.warn( @@ -109,23 +126,6 @@ export class CrossTeamService { }); } - // 7. Record outbox - const outboxMessage: CrossTeamMessage = { - messageId, - fromTeam, - fromMember, - toTeam, - text, - summary, - chainDepth, - timestamp: new Date().toISOString(), - }; - void this.outbox.append(fromTeam, outboxMessage).catch((e: unknown) => { - logger.warn( - `Failed to write outbox for ${fromTeam}: ${e instanceof Error ? e.message : String(e)}` - ); - }); - return { messageId, deliveredToInbox: true }; } diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index 11f4b61a..4e80dfb1 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -19,6 +19,7 @@ import { AGENT_BLOCK_OPEN, stripAgentBlocks, } from '@shared/constants/agentBlocks'; +import { CROSS_TEAM_PREFIX_TAG } from '@shared/constants/crossTeam'; import { getMemberColorByName } from '@shared/constants/memberColors'; import { DEFAULT_TOOL_APPROVAL_SETTINGS } from '@shared/types/team'; import { resolveLanguageName } from '@shared/utils/agentLanguage'; @@ -565,6 +566,22 @@ Communication protocol (CRITICAL — you are running headless, no one sees your - When you receive a from a teammate, ALWAYS reply using the SendMessage tool with the sender's name as recipient. - Your plain text output is invisible to teammates — they are separate processes and can only read their inbox. - Example: if you receive ..., respond with SendMessage(type: "message", recipient: "alice", content: "your reply"). +- Cross-team communication: when work needs expertise, coordination, review, or a decision from ANOTHER team, use MCP tool "cross_team_send" with teamName: "${teamName}" and a focused actionable message. +- Before sending cross-team, use MCP tool "cross_team_list_targets" with teamName: "${teamName}" to discover valid target teams. +- To review messages your team already sent to other teams, use MCP tool "cross_team_get_outbox" with teamName: "${teamName}". +- Cross-team delivery goes to the target team's lead inbox and may be relayed to that live lead automatically. +- Prefer cross-team messaging when your team is blocked by another team's scope, needs another team's domain expertise, needs a review/approval from another team, or must coordinate a shared decision. +- Prefer concise messages that state: what you need, why that team is relevant, the expected response, and any task or file references they need. +- Keep cross-team requests high-signal: one focused request per topic, with clear next action and desired outcome. +- Before sending a follow-up on the same topic, check "cross_team_get_outbox" so you do not resend the same request unnecessarily. +- If you receive a message that is clearly from another team (for example prefixed with "[${CROSS_TEAM_PREFIX_TAG} ...]"), treat it as an actionable cross-team request and respond to the originating team with "cross_team_send" when a reply, decision, or status update is needed. +- Do not wait silently on another team: if cross-team coordination is blocking progress, send the request promptly, then continue any useful local work that does not depend on that answer. +- After a meaningful cross-team exchange, update the relevant task or plan context so your team retains the decision, dependency, or answer. +- Golden format for cross-team requests: include (1) brief context, (2) the concrete ask, (3) why your team needs that team specifically, (4) the expected output or decision, and (5) any deadline or blocking impact if relevant. +- Golden format for cross-team replies: answer the concrete ask first, then include the decision, recommendation, or status, and finally any important caveats, next steps, or handoff expectations. +- Do NOT use cross-team messaging when your own team can answer the question locally, when no action/decision is required, when you are only thinking out loud, or when a task update belongs on your own board instead of another team's inbox. +- If the issue is internal to your team, resolve it through your own task board and teammates first; use cross-team only for genuine inter-team dependency, expertise, approval, or coordination. +- Do NOT spam other teams, and do NOT use cross-team messaging for trivial FYIs that do not require action, coordination, or domain knowledge. Message formatting: - When mentioning teammates by name in messages and text output, always use @ prefix (e.g. @alice, @bob) for UI highlighting. Do NOT use @ in tool parameters (recipient, owner, etc.) — those require plain names. diff --git a/src/renderer/components/team/TeamDetailView.tsx b/src/renderer/components/team/TeamDetailView.tsx index a6cfb244..43546e81 100644 --- a/src/renderer/components/team/TeamDetailView.tsx +++ b/src/renderer/components/team/TeamDetailView.tsx @@ -1562,6 +1562,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele isTeamAlive={data.isAlive} sending={sendingMessage} sendError={sendMessageError} + lastResult={lastSendMessageResult} onSend={(member, text, summary, attachments) => { const sentAtMs = Date.now(); setPendingRepliesByMember((prev) => ({ ...prev, [member]: sentAtMs })); diff --git a/src/renderer/components/team/activity/ActivityItem.tsx b/src/renderer/components/team/activity/ActivityItem.tsx index 0da9a82e..6b44835d 100644 --- a/src/renderer/components/team/activity/ActivityItem.tsx +++ b/src/renderer/components/team/activity/ActivityItem.tsx @@ -23,6 +23,11 @@ import { } from '@renderer/utils/agentMessageFormatting'; import { formatAgentRole } from '@renderer/utils/formatAgentRole'; import { stripAgentBlocks } from '@shared/constants/agentBlocks'; +import { + CROSS_TEAM_SENT_SOURCE, + CROSS_TEAM_SOURCE, + stripCrossTeamPrefix, +} from '@shared/constants/crossTeam'; import { extractMarkdownPlainText } from '@shared/utils/markdownTextSearch'; import { isRateLimitMessage } from '@shared/utils/rateLimitDetector'; import { formatTaskDisplayLabel } from '@shared/utils/taskIdentity'; @@ -275,8 +280,8 @@ export const ActivityItem = ({ const isManaged = isManagedCollapseState(collapseState); const isExpanded = isManaged ? !collapseState.isCollapsed : true; - const isCrossTeam = message.source === 'cross_team'; - const isCrossTeamSent = message.source === 'cross_team_sent'; + const isCrossTeam = message.source === CROSS_TEAM_SOURCE; + const isCrossTeamSent = message.source === CROSS_TEAM_SENT_SOURCE; const isCrossTeamAny = isCrossTeam || isCrossTeamSent; const crossTeamOrigin = useMemo(() => { if (!isCrossTeam) return null; @@ -299,9 +304,9 @@ export const ActivityItem = ({ if (structured) return null; let stripped = stripAgentBlocks(message.text).trim(); if (!stripped) return null; // All content was agent-only blocks → show summary instead - // Strip legacy cross-team prefix (e.g. "[Cross-team from team.lead | depth:0]\n") + // Strip cross-team prefix (e.g. "[Cross-team from team.lead | depth:0]\n") — kept in stored text for CLI agents if (isCrossTeamAny) { - stripped = stripped.replace(/^\[Cross-team from [^\]]+\]\n?/, ''); + stripped = stripCrossTeamPrefix(stripped); } // Normalize literal \n from historical CLI-produced text to real newlines return stripped.replace(/\\n/g, '\n').replace(/\\t/g, '\t'); diff --git a/src/renderer/components/team/messages/MessageComposer.tsx b/src/renderer/components/team/messages/MessageComposer.tsx index f8f1616b..a4e4c5a2 100644 --- a/src/renderer/components/team/messages/MessageComposer.tsx +++ b/src/renderer/components/team/messages/MessageComposer.tsx @@ -25,7 +25,12 @@ import { } from 'lucide-react'; import type { MentionSuggestion } from '@renderer/types/mention'; -import type { AttachmentPayload, LeadContextUsage, ResolvedTeamMember } from '@shared/types'; +import type { + AttachmentPayload, + LeadContextUsage, + ResolvedTeamMember, + SendMessageResult, +} from '@shared/types'; interface MessageComposerProps { teamName: string; @@ -33,6 +38,7 @@ interface MessageComposerProps { isTeamAlive?: boolean; sending: boolean; sendError: string | null; + lastResult?: SendMessageResult | null; onSend: ( recipient: string, text: string, @@ -100,6 +106,7 @@ export const MessageComposer = ({ isTeamAlive, sending, sendError, + lastResult, onSend, onCrossTeamSend, }: MessageComposerProps): React.JSX.Element => { @@ -656,6 +663,11 @@ export const MessageComposer = ({ {sendError} + ) : lastResult?.deduplicated ? ( + + + Reused recent cross-team request + ) : null} {remaining < 200 ? ( = (set, lastSendMessageResult: { messageId: result.messageId, deliveredToInbox: result.deliveredToInbox, + deduplicated: result.deduplicated, }, }); await get().refreshTeamData(request.fromTeam); diff --git a/src/shared/constants/crossTeam.ts b/src/shared/constants/crossTeam.ts new file mode 100644 index 00000000..d30ea33b --- /dev/null +++ b/src/shared/constants/crossTeam.ts @@ -0,0 +1,35 @@ +// ── Cross-Team Message Protocol ────────────────────────────────────────────── +// Single source of truth for the cross-team message prefix format. +// Used by: CrossTeamService (main), crossTeam.js (controller), ActivityItem (renderer), tests. + +/** Prefix tag that wraps cross-team metadata in stored message text. */ +export const CROSS_TEAM_PREFIX_TAG = 'Cross-team from'; + +/** Build the full prefix line: `[Cross-team from team.member | depth:N]` */ +export function formatCrossTeamPrefix(from: string, chainDepth: number): string { + return `[${CROSS_TEAM_PREFIX_TAG} ${from} | depth:${chainDepth}]`; +} + +/** Format the full message text with prefix + body. */ +export function formatCrossTeamText(from: string, chainDepth: number, text: string): string { + return `${formatCrossTeamPrefix(from, chainDepth)}\n${text}`; +} + +/** + * Regex that matches the cross-team prefix line at the start of a message. + * Captures nothing — use `.replace(CROSS_TEAM_PREFIX_RE, '')` to strip it. + */ +export const CROSS_TEAM_PREFIX_RE = /^\[Cross-team from [^\]]+\]\n?/; + +/** Strip the cross-team prefix from message text (for UI display). */ +export function stripCrossTeamPrefix(text: string): string { + return text.replace(CROSS_TEAM_PREFIX_RE, ''); +} + +// ── Source discriminators ──────────────────────────────────────────────────── + +/** Incoming cross-team message (written to target team's inbox). */ +export const CROSS_TEAM_SOURCE = 'cross_team' as const; + +/** Outgoing cross-team message copy (written to sender team's inbox). */ +export const CROSS_TEAM_SENT_SOURCE = 'cross_team_sent' as const; diff --git a/src/shared/constants/index.ts b/src/shared/constants/index.ts index c6567b40..75122750 100644 --- a/src/shared/constants/index.ts +++ b/src/shared/constants/index.ts @@ -4,6 +4,7 @@ export * from './agentBlocks'; export * from './cache'; +export * from './crossTeam'; export * from './kanban'; export * from './memberColors'; export * from './teamLimits'; diff --git a/src/shared/types/team.ts b/src/shared/types/team.ts index 31971375..42e5c47c 100644 --- a/src/shared/types/team.ts +++ b/src/shared/types/team.ts @@ -279,6 +279,7 @@ export interface SendMessageResult { deliveredToInbox: boolean; deliveredViaStdin?: boolean; messageId: string; + deduplicated?: boolean; } export type MemberStatus = 'active' | 'idle' | 'terminated' | 'unknown'; @@ -626,6 +627,7 @@ export interface CrossTeamSendRequest { export interface CrossTeamSendResult { messageId: string; deliveredToInbox: boolean; + deduplicated?: boolean; } // ============================================================================= diff --git a/test/main/services/team/CrossTeamOutbox.test.ts b/test/main/services/team/CrossTeamOutbox.test.ts index 7a776304..dd649efa 100644 --- a/test/main/services/team/CrossTeamOutbox.test.ts +++ b/test/main/services/team/CrossTeamOutbox.test.ts @@ -64,4 +64,30 @@ describe('CrossTeamOutbox', () => { const result = await outbox.read('test-team'); expect(result).toHaveLength(2); }); + + it('appendIfNotRecent returns duplicate for recent equivalent message', async () => { + const existing = makeMessage({ + messageId: 'msg-existing', + text: 'Please review this API', + summary: ' Review request ', + }); + await outbox.append('test-team', existing); + + const onBeforeAppend = vi.fn(async () => {}); + const result = await outbox.appendIfNotRecent( + 'test-team', + makeMessage({ + messageId: 'msg-new', + text: 'please review this api', + summary: 'review request', + }), + onBeforeAppend + ); + + expect(result.duplicate?.messageId).toBe('msg-existing'); + expect(onBeforeAppend).not.toHaveBeenCalled(); + + const list = await outbox.read('test-team'); + expect(list).toHaveLength(1); + }); }); diff --git a/test/main/services/team/CrossTeamService.test.ts b/test/main/services/team/CrossTeamService.test.ts index 7e438b38..1ef67393 100644 --- a/test/main/services/team/CrossTeamService.test.ts +++ b/test/main/services/team/CrossTeamService.test.ts @@ -1,6 +1,12 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import * as fs from 'fs'; import { CrossTeamService } from '@main/services/team/CrossTeamService'; +import { + CROSS_TEAM_SENT_SOURCE, + CROSS_TEAM_SOURCE, + formatCrossTeamText, +} from '@shared/constants/crossTeam'; import type { TeamConfigReader } from '@main/services/team/TeamConfigReader'; import type { TeamDataService } from '@main/services/team/TeamDataService'; @@ -12,6 +18,8 @@ vi.mock('@main/utils/pathDecoder', () => ({ getTeamsBasePath: () => '/tmp/cross-team-test-nonexistent-dir-' + process.pid, })); +const MOCK_TEAMS_BASE_PATH = '/tmp/cross-team-test-nonexistent-dir-' + process.pid; + vi.mock('@shared/utils/logger', () => ({ createLogger: () => ({ info: vi.fn(), @@ -50,6 +58,7 @@ describe('CrossTeamService', () => { }; beforeEach(() => { + fs.rmSync(MOCK_TEAMS_BASE_PATH, { recursive: true, force: true }); configReader = { getConfig: vi.fn().mockResolvedValue(makeConfig()), }; @@ -74,6 +83,7 @@ describe('CrossTeamService', () => { afterEach(() => { vi.restoreAllMocks(); + fs.rmSync(MOCK_TEAMS_BASE_PATH, { recursive: true, force: true }); }); describe('send', () => { @@ -87,9 +97,9 @@ describe('CrossTeamService', () => { const [teamName, req] = inboxWriter.sendMessage.mock.calls[0]; expect(teamName).toBe('team-b'); expect(req.member).toBe('team-lead'); - expect(req.source).toBe('cross_team'); + expect(req.source).toBe(CROSS_TEAM_SOURCE); expect(req.from).toBe('team-a.lead'); - expect(req.text).toBe('[Cross-team from team-a.lead | depth:0]\nHello from team-a'); + expect(req.text).toBe(formatCrossTeamText('team-a.lead', 0, 'Hello from team-a')); }); it('writes sender copy to fromTeam inbox as user_sent', async () => { @@ -103,7 +113,7 @@ describe('CrossTeamService', () => { const [senderTeam, senderReq] = inboxWriter.sendMessage.mock.calls[1]; expect(senderTeam).toBe('team-a'); expect(senderReq.from).toBe('user'); - expect(senderReq.source).toBe('cross_team_sent'); + expect(senderReq.source).toBe(CROSS_TEAM_SENT_SOURCE); expect(senderReq.to).toBe('team-b.team-lead'); expect(senderReq.text).toBe('Hello from team-a'); }); @@ -200,6 +210,27 @@ describe('CrossTeamService', () => { const result = await svc.send(makeRequest()); expect(result.deliveredToInbox).toBe(true); }); + + it('deduplicates recent equivalent requests and reuses messageId', async () => { + const request = makeRequest({ + fromTeam: 'team-a-dedupe', + toTeam: 'team-b-dedupe', + text: 'Please review this contract', + summary: ' Review request ', + }); + configReader.getConfig.mockResolvedValue(makeConfig({ name: 'team-b-dedupe' })); + + const first = await service.send(request); + const second = await service.send({ + ...request, + text: 'please review this contract', + summary: 'review request', + }); + + expect(second.deduplicated).toBe(true); + expect(second.messageId).toBe(first.messageId); + expect(inboxWriter.sendMessage).toHaveBeenCalledTimes(2); + }); }); describe('listAvailableTargets', () => { diff --git a/test/main/services/team/TeamProvisioningServicePostCompact.test.ts b/test/main/services/team/TeamProvisioningServicePostCompact.test.ts index 78d66bea..1e9024cc 100644 --- a/test/main/services/team/TeamProvisioningServicePostCompact.test.ts +++ b/test/main/services/team/TeamProvisioningServicePostCompact.test.ts @@ -276,6 +276,17 @@ describe('TeamProvisioningService post-compact lifecycle', () => { // Should contain persistent context expect(text).toContain('Constraints:'); expect(text).toContain('Do NOT call TeamDelete'); + expect(text).toContain('cross_team_send'); + expect(text).toContain('cross_team_list_targets'); + expect(text).toContain('cross_team_get_outbox'); + expect(text).toContain('blocked by another team'); + expect(text).toContain('one focused request per topic'); + expect(text).toContain('If you receive a message that is clearly from another team'); + expect(text).toContain('Do not wait silently on another team'); + expect(text).toContain('Golden format for cross-team requests'); + expect(text).toContain('Golden format for cross-team replies'); + expect(text).toContain('Do NOT use cross-team messaging when your own team can answer'); + expect(text).toContain('resolve it through your own task board and teammates first'); await svc.cancelProvisioning(runId); });