From 64c9ddc78cbab0c8bf43093c9a04d1c0d2d20109 Mon Sep 17 00:00:00 2001 From: 777genius Date: Fri, 24 Apr 2026 22:41:16 +0300 Subject: [PATCH] feat(opencode): add semantic messaging seam --- .../src/internal/crossTeam.js | 62 +- .../src/internal/memberMessagingProtocol.js | 59 ++ .../src/internal/messageStore.js | 55 +- .../src/internal/messages.js | 64 +- .../src/internal/runtimeHelpers.js | 22 + agent-teams-controller/src/internal/tasks.js | 81 ++- .../test/controller.test.js | 169 +++++- agent-teams-controller/test/crossTeam.test.js | 58 +- mcp-server/src/agent-teams-controller.d.ts | 7 +- mcp-server/src/controller.ts | 5 +- mcp-server/src/tools/crossTeamTools.ts | 9 + mcp-server/src/tools/messageTools.ts | 28 +- mcp-server/src/tools/runtimeTools.ts | 3 +- mcp-server/src/tools/taskTools.ts | 7 +- mcp-server/test/tools.test.ts | 67 +++ src/main/index.ts | 29 +- src/main/ipc/teams.ts | 99 +++- src/main/services/team/CrossTeamOutbox.ts | 5 + src/main/services/team/CrossTeamService.ts | 33 +- src/main/services/team/TeamDataService.ts | 2 + src/main/services/team/TeamInboxReader.ts | 4 + src/main/services/team/TeamInboxWriter.ts | 1 + .../services/team/TeamProvisioningService.ts | 505 ++++++++++++++-- src/main/services/team/agentTeamsToolNames.ts | 54 +- .../bridge/OpenCodeReadinessBridge.ts | 9 +- .../e2e/OpenCodeProductionE2EEvidence.ts | 6 +- .../mcp/OpenCodeMcpToolAvailability.ts | 25 +- .../OpenCodeRuntimeManifestEvidenceReader.ts | 38 +- .../runtime/OpenCodeTeamRuntimeAdapter.ts | 36 +- .../stream/BoardTaskLogStreamService.ts | 17 +- .../components/team/TeamDetailView.tsx | 44 +- .../team/dialogs/CreateTeamDialog.tsx | 10 +- .../team/dialogs/LaunchTeamDialog.tsx | 41 +- .../team/dialogs/SendMessageDialog.tsx | 38 +- .../team/dialogs/TeamModelSelector.tsx | 21 + .../team/members/LeadModelRow.test.tsx | 2 + .../components/team/members/LeadModelRow.tsx | 4 + .../team/members/MembersEditorSection.tsx | 1 + .../team/members/membersEditorUtils.ts | 8 + .../team/messages/MessageComposer.tsx | 7 + .../team/messages/MessagesPanel.tsx | 33 +- src/renderer/store/slices/teamSlice.ts | 41 +- src/shared/types/team.ts | 9 + src/types/agent-teams-controller.d.ts | 5 +- test/main/ipc/teams.test.ts | 99 ++++ .../services/team/CrossTeamService.test.ts | 42 +- .../team/OpenCodeMcpToolAvailability.test.ts | 30 +- .../OpenCodeProductionE2EEvidence.test.ts | 41 +- .../team/OpenCodeProductionGate.live.test.ts | 15 +- .../team/OpenCodeReadinessBridge.test.ts | 9 +- .../OpenCodeSemanticMessaging.live.test.ts | 553 ++++++++++++++++++ .../team/OpenCodeTeamRuntimeAdapter.test.ts | 43 +- .../services/team/TeamDataService.test.ts | 4 + .../team/TeamProvisioningService.test.ts | 288 ++++++++- ...eamProvisioningServiceLiveMessages.test.ts | 62 ++ .../TeamProvisioningServicePrepare.test.ts | 51 +- .../team/TeamProvisioningServiceRelay.test.ts | 367 ++++++++++++ .../services/team/agentTeamsToolNames.test.ts | 62 ++ .../TeamModelSelectorDisabledState.test.ts | 112 ++++ .../team/dialogs/LaunchTeamDialog.test.ts | 33 +- .../team/members/membersEditorUtils.test.ts | 25 +- .../team/messages/MessagesPanel.test.ts | 1 + test/renderer/store/teamSlice.test.ts | 28 +- 63 files changed, 3408 insertions(+), 280 deletions(-) create mode 100644 agent-teams-controller/src/internal/memberMessagingProtocol.js create mode 100644 test/main/services/team/OpenCodeSemanticMessaging.live.test.ts create mode 100644 test/main/services/team/agentTeamsToolNames.test.ts diff --git a/agent-teams-controller/src/internal/crossTeam.js b/agent-teams-controller/src/internal/crossTeam.js index e2fb3a12..474218b9 100644 --- a/agent-teams-controller/src/internal/crossTeam.js +++ b/agent-teams-controller/src/internal/crossTeam.js @@ -101,16 +101,6 @@ function normalizeForDedupe(value) { .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( @@ -118,10 +108,44 @@ function getCrossTeamMessageDedupeKey(message) { message.fromMember, message.toTeam, message.text, - message.summary + message.summary, + message.taskRefs ); } +function normalizeTaskRefs(taskRefs) { + if (!Array.isArray(taskRefs) || taskRefs.length === 0) { + return undefined; + } + + const normalized = taskRefs + .filter((item) => item && typeof item === 'object') + .map((item) => ({ + taskId: String(item.taskId || '').trim(), + displayId: String(item.displayId || '').trim(), + teamName: String(item.teamName || '').trim(), + })) + .filter((item) => item.taskId && item.displayId && item.teamName); + + return normalized.length > 0 ? normalized : undefined; +} + +function normalizeTaskRefsForDedupe(taskRefs) { + const normalized = normalizeTaskRefs(taskRefs); + return normalized ? JSON.stringify(normalized) : ''; +} + +function buildCrossTeamDedupeKey(fromTeam, fromMember, toTeam, text, summary, taskRefs) { + return [ + normalizeForDedupe(fromTeam), + normalizeForDedupe(fromMember), + normalizeForDedupe(toTeam), + normalizeForDedupe(summary), + normalizeForDedupe(text), + normalizeTaskRefsForDedupe(taskRefs), + ].join('||'); +} + function findRecentDuplicate(outboxList, dedupeKey) { if (!Array.isArray(outboxList) || !dedupeKey) return null; const cutoff = Date.now() - CROSS_TEAM_DEDUPE_WINDOW_MS; @@ -141,7 +165,7 @@ function findRecentDuplicate(outboxList, dedupeKey) { function sendCrossTeamMessage(context, flags) { const fromTeam = context.teamName; const toTeam = typeof flags.toTeam === 'string' ? flags.toTeam.trim() : ''; - const fromMember = typeof flags.fromMember === 'string' ? flags.fromMember.trim() : 'team-lead'; + const rawFromMember = typeof flags.fromMember === 'string' ? flags.fromMember.trim() : 'team-lead'; const replyToConversationId = typeof flags.replyToConversationId === 'string' ? flags.replyToConversationId.trim() : ''; const conversationId = @@ -151,6 +175,7 @@ function sendCrossTeamMessage(context, flags) { const text = typeof flags.text === 'string' ? flags.text : ''; const summary = typeof flags.summary === 'string' ? flags.summary.trim() : undefined; const chainDepth = typeof flags.chainDepth === 'number' ? flags.chainDepth : 0; + const taskRefs = normalizeTaskRefs(flags.taskRefs); // Validate if (!TEAM_NAME_PATTERN.test(fromTeam)) { @@ -165,6 +190,14 @@ function sendCrossTeamMessage(context, flags) { if (!text || text.trim().length === 0) { throw new Error('Message text is required'); } + const fromMember = runtimeHelpers.assertExplicitTeamMemberName( + context.paths, + rawFromMember, + 'fromMember', + { + allowLeadAliases: true, + } + ); // Target context + config const targetContext = createTargetContext(context, toTeam); @@ -186,7 +219,7 @@ function sendCrossTeamMessage(context, flags) { }); const messageId = crypto.randomUUID ? crypto.randomUUID() : `${Date.now()}-${Math.random()}`; const timestamp = new Date().toISOString(); - const dedupeKey = buildCrossTeamDedupeKey(fromTeam, fromMember, toTeam, text, summary); + const dedupeKey = buildCrossTeamDedupeKey(fromTeam, fromMember, toTeam, text, summary, taskRefs); const inboxPath = path.join(targetContext.paths.teamDir, 'inboxes', `${leadName}.json`); const outboxPath = path.join(context.paths.teamDir, 'sent-cross-team.json'); @@ -219,6 +252,7 @@ function sendCrossTeamMessage(context, flags) { source: CROSS_TEAM_SOURCE, conversationId: resolvedConversationId, replyToConversationId: replyToConversationId || undefined, + ...(taskRefs ? { taskRefs } : {}), }); writeJson(inboxPath, list); }); @@ -240,6 +274,7 @@ function sendCrossTeamMessage(context, flags) { source: CROSS_TEAM_SENT_SOURCE, conversationId: resolvedConversationId, replyToConversationId: replyToConversationId || undefined, + ...(taskRefs ? { taskRefs } : {}), }); outList.push({ @@ -250,6 +285,7 @@ function sendCrossTeamMessage(context, flags) { conversationId: resolvedConversationId, replyToConversationId: replyToConversationId || undefined, text, + ...(taskRefs ? { taskRefs } : {}), summary, chainDepth, timestamp, diff --git a/agent-teams-controller/src/internal/memberMessagingProtocol.js b/agent-teams-controller/src/internal/memberMessagingProtocol.js new file mode 100644 index 00000000..2d8da105 --- /dev/null +++ b/agent-teams-controller/src/internal/memberMessagingProtocol.js @@ -0,0 +1,59 @@ +function normalizeRuntimeProvider(value) { + const normalized = String(value || '').trim().toLowerCase(); + return normalized === 'opencode' ? 'opencode' : 'native'; +} + +function createMemberMessagingProtocol(runtimeProvider) { + const provider = normalizeRuntimeProvider(runtimeProvider); + + if (provider === 'opencode') { + return { + runtimeProvider: 'opencode', + sendToolName: 'agent-teams_message_send', + sendToolAliases: [ + 'agent-teams_message_send', + 'agent_teams_message_send', + 'mcp__agent-teams__message_send', + 'mcp__agent_teams__message_send', + 'message_send', + ], + sendLeadPhrase: 'MCP tool agent-teams_message_send', + crossTeamPhrase: 'call MCP tool agent-teams_cross_team_send', + buildLeadMessageExample({ teamName, leadName, fromName, text, summary }) { + return `agent-teams_message_send { teamName: "${teamName}", to: "${leadName}", from: "${fromName}", text: "${text}", summary: "${summary}" }`; + }, + buildCrossTeamMessageExample({ teamName, toTeam, fromName, text, summary }) { + return `agent-teams_cross_team_send { teamName: "${teamName}", toTeam: "${toTeam}", fromMember: "${fromName}", text: "${text}", summary: "${summary}" }`; + }, + }; + } + + return { + runtimeProvider: 'native', + sendToolName: 'SendMessage', + sendToolAliases: ['SendMessage'], + sendLeadPhrase: 'SendMessage', + crossTeamPhrase: 'use the cross-team MCP tool cross_team_send', + buildLeadMessageExample({ leadName, text, summary }) { + return `SendMessage { to: "${leadName}", summary: "${summary}", message: "${text}" }`; + }, + buildCrossTeamMessageExample({ teamName, toTeam, fromName, text, summary }) { + return `cross_team_send { teamName: "${teamName}", toTeam: "${toTeam}", fromMember: "${fromName}", text: "${text}", summary: "${summary}" }`; + }, + }; +} + +function isOpenCodeMember(member) { + const provider = String((member && (member.providerId || member.provider)) || '') + .trim() + .toLowerCase(); + if (provider) return provider === 'opencode'; + const model = String((member && member.model) || '').trim().toLowerCase(); + return model.startsWith('opencode/'); +} + +module.exports = { + createMemberMessagingProtocol, + isOpenCodeMember, + normalizeRuntimeProvider, +}; diff --git a/agent-teams-controller/src/internal/messageStore.js b/agent-teams-controller/src/internal/messageStore.js index 9a88f8fe..ff724899 100644 --- a/agent-teams-controller/src/internal/messageStore.js +++ b/agent-teams-controller/src/internal/messageStore.js @@ -71,6 +71,48 @@ function normalizeTaskRefs(taskRefs) { return normalized.length > 0 ? normalized : undefined; } +function normalizeMessageKind(messageKind) { + return messageKind === 'default' || + messageKind === 'slash_command' || + messageKind === 'slash_command_result' || + messageKind === 'task_comment_notification' + ? messageKind + : undefined; +} + +function normalizeSlashCommand(slashCommand) { + if (!slashCommand || typeof slashCommand !== 'object') { + return undefined; + } + const name = String(slashCommand.name || '').trim(); + const command = String(slashCommand.command || '').trim(); + if (!name || !command) { + return undefined; + } + return { + name, + command, + ...(typeof slashCommand.args === 'string' ? { args: slashCommand.args } : {}), + ...(typeof slashCommand.knownDescription === 'string' + ? { knownDescription: slashCommand.knownDescription } + : {}), + }; +} + +function normalizeCommandOutput(commandOutput) { + if (!commandOutput || typeof commandOutput !== 'object') { + return undefined; + } + const stream = commandOutput.stream === 'stdout' || commandOutput.stream === 'stderr' + ? commandOutput.stream + : undefined; + const commandLabel = String(commandOutput.commandLabel || '').trim(); + if (!stream || !commandLabel) { + return undefined; + } + return { stream, commandLabel }; +} + function buildMessage(flags, defaults) { const timestamp = typeof flags.timestamp === 'string' && flags.timestamp.trim() ? flags.timestamp.trim() : nowIso(); @@ -80,6 +122,9 @@ function buildMessage(flags, defaults) { : crypto.randomUUID(); const attachments = normalizeAttachments(flags.attachments); const taskRefs = normalizeTaskRefs(flags.taskRefs); + const messageKind = normalizeMessageKind(flags.messageKind); + const slashCommand = normalizeSlashCommand(flags.slashCommand); + const commandOutput = normalizeCommandOutput(flags.commandOutput); return { from: @@ -91,9 +136,15 @@ function buildMessage(flags, defaults) { timestamp, read: defaults.read, ...(taskRefs ? { taskRefs } : {}), + ...(flags.actionMode === 'do' || flags.actionMode === 'ask' || flags.actionMode === 'delegate' + ? { actionMode: flags.actionMode } + : {}), ...(typeof flags.summary === 'string' && flags.summary.trim() ? { summary: flags.summary.trim() } : {}), + ...(typeof flags.commentId === 'string' && flags.commentId.trim() + ? { commentId: flags.commentId.trim() } + : {}), ...(typeof flags.relayOfMessageId === 'string' && flags.relayOfMessageId.trim() ? { relayOfMessageId: flags.relayOfMessageId.trim() } : {}), @@ -121,6 +172,9 @@ function buildMessage(flags, defaults) { })), } : {}), + ...(messageKind ? { messageKind } : {}), + ...(slashCommand ? { slashCommand } : {}), + ...(commandOutput ? { commandOutput } : {}), ...(attachments ? { attachments } : {}), messageId, }; @@ -235,4 +289,3 @@ module.exports = { lookupMessage, sendInboxMessage, }; - diff --git a/agent-teams-controller/src/internal/messages.js b/agent-teams-controller/src/internal/messages.js index 9aab43f4..8ff2122b 100644 --- a/agent-teams-controller/src/internal/messages.js +++ b/agent-teams-controller/src/internal/messages.js @@ -1,7 +1,69 @@ const messageStore = require('./messageStore.js'); +const runtimeHelpers = require('./runtimeHelpers.js'); + +function normalizeMessageSendFlags(context, flags) { + const next = { ...(flags || {}) }; + const rawTo = + (typeof next.member === 'string' && next.member.trim()) || + (typeof next.to === 'string' && next.to.trim()) || + ''; + + if (!rawTo) { + throw new Error('message_send requires to'); + } + + if (rawTo.toLowerCase() === 'user') { + next.to = 'user'; + delete next.member; + } else { + const resolvedTo = runtimeHelpers.resolveExplicitTeamMemberName(context.paths, rawTo, { + allowLeadAliases: true, + }); + if (!resolvedTo && runtimeHelpers.looksLikeCrossTeamToolRecipient(rawTo)) { + throw new Error('message_send cannot target cross_team_send. Use cross_team_send with toTeam.'); + } + if (!resolvedTo && runtimeHelpers.looksLikeCrossTeamRecipient(rawTo)) { + throw new Error('message_send cannot target another team. Use cross_team_send with toTeam.'); + } + if (!resolvedTo) { + throw new Error(`Unknown to: ${rawTo}. Use a configured team member name.`); + } + next.to = resolvedTo; + next.member = resolvedTo; + } + + if (typeof next.from === 'string' && next.from.trim()) { + const rawFrom = next.from.trim(); + if (rawFrom.toLowerCase() !== 'user') { + next.from = runtimeHelpers.assertExplicitTeamMemberName(context.paths, rawFrom, 'from', { + allowLeadAliases: true, + }); + } else { + next.from = 'user'; + } + } + + return next; +} + +function assertUserDirectedMessageHasSender(context, flags) { + const to = typeof flags.to === 'string' ? flags.to.trim().toLowerCase() : ''; + if (to !== 'user') return; + + const from = typeof flags.from === 'string' ? flags.from.trim() : ''; + if (!from || from.toLowerCase() === 'user') { + throw new Error('message_send to user requires from to be the responding team member name'); + } + + runtimeHelpers.assertExplicitTeamMemberName(context.paths, from, 'from', { + allowLeadAliases: true, + }); +} function sendMessage(context, flags) { - return messageStore.sendInboxMessage(context.paths, flags); + const normalized = normalizeMessageSendFlags(context, flags); + assertUserDirectedMessageHasSender(context, normalized); + return messageStore.sendInboxMessage(context.paths, normalized); } function appendSentMessage(context, flags) { diff --git a/agent-teams-controller/src/internal/runtimeHelpers.js b/agent-teams-controller/src/internal/runtimeHelpers.js index e7ee7ca4..e78da93c 100644 --- a/agent-teams-controller/src/internal/runtimeHelpers.js +++ b/agent-teams-controller/src/internal/runtimeHelpers.js @@ -85,6 +85,10 @@ function looksLikeCrossTeamToolRecipient(name) { return CROSS_TEAM_TOOL_RECIPIENT_NAMES.has(String(name || '').trim()); } +function looksLikeCrossTeamRecipient(name) { + return looksLikeQualifiedExternalRecipient(name) || looksLikeCrossTeamPseudoRecipient(name); +} + function getHomeDir() { if (process.env.HOME) return process.env.HOME; if (process.env.USERPROFILE) return process.env.USERPROFILE; @@ -270,6 +274,10 @@ function normalizeMemberRecord(member) { if (!member || typeof member !== 'object') return null; const name = typeof member.name === 'string' ? member.name.trim() : ''; if (!name) return null; + const copyTrimmedString = (key) => + typeof member[key] === 'string' && member[key].trim() + ? { [key]: member[key].trim() } + : {}; return { name, ...(typeof member.role === 'string' && member.role.trim() ? { role: member.role.trim() } : {}), @@ -281,6 +289,12 @@ function normalizeMemberRecord(member) { : {}), ...(typeof member.color === 'string' && member.color.trim() ? { color: member.color.trim() } : {}), ...(typeof member.cwd === 'string' && member.cwd.trim() ? { cwd: member.cwd.trim() } : {}), + ...copyTrimmedString('providerId'), + ...copyTrimmedString('providerBackendId'), + ...copyTrimmedString('provider'), + ...copyTrimmedString('model'), + ...copyTrimmedString('effort'), + ...copyTrimmedString('fastMode'), ...(typeof member.removedAt === 'number' ? { removedAt: member.removedAt } : {}), }; } @@ -295,6 +309,12 @@ function mergeResolvedMember(target, source) { ...(source.agentType ? { agentType: source.agentType } : {}), ...(source.color ? { color: source.color } : {}), ...(source.cwd ? { cwd: source.cwd } : {}), + ...(source.providerId ? { providerId: source.providerId } : {}), + ...(source.providerBackendId ? { providerBackendId: source.providerBackendId } : {}), + ...(source.provider ? { provider: source.provider } : {}), + ...(source.model ? { model: source.model } : {}), + ...(source.effort ? { effort: source.effort } : {}), + ...(source.fastMode ? { fastMode: source.fastMode } : {}), ...(source.removedAt != null ? { removedAt: source.removedAt } : {}), }; } @@ -600,6 +620,8 @@ module.exports = { getPaths, inferLeadName, isCanonicalLeadMember, + looksLikeCrossTeamRecipient, + looksLikeCrossTeamToolRecipient, isProcessAlive, listInboxMemberNames, readMembersMeta, diff --git a/agent-teams-controller/src/internal/tasks.js b/agent-teams-controller/src/internal/tasks.js index feece6e0..d4f464fb 100644 --- a/agent-teams-controller/src/internal/tasks.js +++ b/agent-teams-controller/src/internal/tasks.js @@ -6,6 +6,10 @@ const kanbanStore = require('./kanbanStore.js'); const agenda = require('./agenda.js'); const { withTeamBoardLock } = require('./boardLock.js'); const { wrapAgentBlock } = require('./agentBlocks.js'); +const { + createMemberMessagingProtocol, + isOpenCodeMember, +} = require('./memberMessagingProtocol.js'); function normalizeActorName(value) { return typeof value === 'string' && value.trim() ? value.trim() : ''; @@ -69,6 +73,7 @@ function warnNonCritical(message, error) { } function buildAssignmentMessage(context, task, options = {}) { + const messagingProtocol = options.messagingProtocol || createMemberMessagingProtocol('native'); const description = typeof options.description === 'string' && options.description.trim() ? options.description.trim() : @@ -92,6 +97,18 @@ function buildAssignmentMessage(context, task, options = {}) { lines.push(``, `Instructions:`, prompt); } + const notifyLeadExample = messagingProtocol.buildLeadMessageExample({ + teamName: context.teamName, + leadName: '', + fromName: '', + text: `#${task.displayId || task.id} done. <2-4 sentence summary>. Full details in task comment . Moving to next task.`, + summary: `#${task.displayId || task.id} done`, + }); + const openCodeVisibleMessageRule = + messagingProtocol.runtimeProvider === 'opencode' + ? '\n For normal visible replies, use agent-teams_message_send. Do not use SendMessage or runtime_deliver_message for ordinary replies.' + : ''; + lines.push( ``, wrapAgentBlock(`Use the board MCP tools to work this task correctly: @@ -105,8 +122,8 @@ function buildAssignmentMessage(context, task, options = {}) { task_add_comment { teamName: "${context.teamName}", taskId: "${task.id}", text: "", from: "" } The response contains comment.id (UUID). Take its first 8 characters as the short commentId. task_complete { teamName: "${context.teamName}", taskId: "${task.id}" } -5. After task_complete, notify your lead via SendMessage with a brief summary and a pointer to the full comment (use the short commentId from step 4). - Example: "#${task.displayId || task.id} done. <2-4 sentence summary>. For full details: task_get_comment { taskId: \\"${task.displayId || task.id}\\", commentId: \\"\\" }. Moving to next task."`) +5. After task_complete, notify your lead via ${messagingProtocol.sendLeadPhrase} with a brief summary and a pointer to the full comment (use the short commentId from step 4). + Example: ${notifyLeadExample}${openCodeVisibleMessageRule}`) ); return lines.join('\n'); @@ -137,12 +154,23 @@ function maybeNotifyAssignedOwner(context, task, options = {}) { return; } + const resolved = runtimeHelpers.resolveTeamMembers(context.paths); + const ownerMember = (resolved.members || []).find( + (member) => isSameMember(member && member.name, owner) + ); + const messagingProtocol = createMemberMessagingProtocol( + isOpenCodeMember(ownerMember) ? 'opencode' : 'native' + ); + const summary = options.summary || `New task #${task.displayId || task.id} assigned`; try { messages.sendMessage(context, { member: owner, from: sender, - text: buildAssignmentMessage(context, task, options), + text: buildAssignmentMessage(context, task, { + ...options, + messagingProtocol, + }), taskRefs: Array.isArray(options.taskRefs) && options.taskRefs.length > 0 ? options.taskRefs : undefined, summary, source: 'system_notification', @@ -592,7 +620,18 @@ function buildMemberActionModeProtocol() { return buildActionModeProtocolText(MEMBER_DELEGATE_DESCRIPTION); } -function buildMemberTaskProtocol(teamName) { +function buildMemberTaskProtocol(teamName, messagingProtocol = createMemberMessagingProtocol('native')) { + const notifyLeadExample = messagingProtocol.buildLeadMessageExample({ + teamName, + leadName: '', + fromName: '', + text: '#abcd1234 done. Found 3 competitors: two lack kanban, one went closed-source in Jan. Full details in task comment e5f6a7b8. Moving to #efgh5678 next.', + summary: '#abcd1234 done', + }); + const openCodeVisibleMessageRule = + messagingProtocol.runtimeProvider === 'opencode' + ? '\n - For normal visible replies, use agent-teams_message_send. Always include teamName, to, from, text, and summary. Always set from to your teammate name. Do not use SendMessage or runtime_deliver_message for ordinary replies.' + : ''; return wrapAgentBlock(`MANDATORY TASK STATUS PROTOCOL — you MUST follow this for EVERY task: 0. IMPORTANT ID RULE: - If a board/task snapshot shows a canonical taskId, prefer using that exact value in MCP tool calls. @@ -609,13 +648,13 @@ function buildMemberTaskProtocol(teamName) { - Do NOT start multiple tasks at once unless the team lead explicitly directs parallel work. 3. Use MCP tool task_complete BEFORE sending your final reply: { teamName: "${teamName}", taskId: "" } - - CRITICAL: Before calling task_complete, you MUST post a task comment with your results via task_add_comment. Save the comment.id from the response — you will need it in the next step. The task comment is the primary delivery channel — the user reads results on the task board. A SendMessage to the lead is NOT a substitute: direct messages are ephemeral and not visible on the board. If you only SendMessage without a task comment, the user will never see your work. + - CRITICAL: Before calling task_complete, you MUST post a task comment with your results via task_add_comment. Save the comment.id from the response — you will need it in the next step. The task comment is the primary delivery channel — the user reads results on the task board. A direct message to the lead is NOT a substitute: direct messages are ephemeral and not visible on the board. If you only send a direct message without a task comment, the user will never see your work. - If a new task comment means you must do more real work on that same task, FIRST add a short task comment saying what you are going to do, THEN run task_start again before doing the follow-up work. - After that follow-up work finishes, add a short task comment with the result, what changed, or what you verified. - After that, run task_complete again before your reply. - Never do comment-driven implementation/fix work while the task is still shown as pending, review, completed, or approved. - - After task_complete, send a notification to your team lead via SendMessage. Use the comment.id you saved earlier (first 8 characters). Your message must include: (a) which task is done, (b) a brief summary of the outcome (2-4 sentences), (c) a pointer to the full comment so the lead can fetch it, (d) what you will do next. Do NOT duplicate the entire results. - Example: "#abcd1234 done. Found 3 competitors: two lack kanban, one went closed-source in Jan. For full details: task_get_comment { taskId: "abcd1234", commentId: "e5f6a7b8" }. Moving to #efgh5678 next." + - After task_complete, send a notification to your team lead via ${messagingProtocol.sendLeadPhrase}. Use the comment.id you saved earlier (first 8 characters). Your message must include: (a) which task is done, (b) a brief summary of the outcome (2-4 sentences), (c) a pointer to the full comment so the lead can fetch it, (d) what you will do next. Do NOT duplicate the entire results. + Example: ${notifyLeadExample}${openCodeVisibleMessageRule} - After task_complete, call review_request ONLY when review is explicitly expected for THIS task and a concrete reviewer is already known. Example: { teamName: "${teamName}", taskId: "", from: "", reviewer: "" } @@ -636,7 +675,7 @@ function buildMemberTaskProtocol(teamName) { 8. When discussing a task with a teammate and you have important findings, decisions, blockers, or progress updates — record them as a task comment: { teamName: "${teamName}", taskId: "", text: "", from: "" } Do NOT comment on trivial coordination messages. Only comment when the information is valuable context for the task. -9. When sending a message about a specific task, include its short display label like # in your SendMessage summary field for traceability. +9. When sending a message about a specific task, include its short display label like # in your ${messagingProtocol.sendToolName} summary field for traceability. 10. In ALL human-facing or teammate-facing message text, when you mention a task reference, ALWAYS write it with a leading # (for example: #abcd1234, not abcd1234 or "task abcd1234"). 11. Review workflow clarity (IMPORTANT): - The work task (e.g. #1) is the thing that must end up APPROVED after review. @@ -653,7 +692,7 @@ function buildMemberTaskProtocol(teamName) { { teamName: "${teamName}", taskId: "", value: "lead" } b) STEP 2 — THEN, add a task comment describing exactly what you need: { teamName: "${teamName}", taskId: "", text: "question / blocker / missing info", from: "" } - c) STEP 3 — THEN, send a message to your team lead via SendMessage so they notice it promptly. + c) STEP 3 — THEN, send a message to your team lead via ${messagingProtocol.sendLeadPhrase} so they notice it promptly. IMPORTANT: Always update the task board BEFORE sending the message. The flag + task comment are what make the request durable and visible on the board. d) The clarification flag is durable until it is cleared explicitly. When the blocker is truly resolved, clear the flag yourself with: @@ -718,7 +757,7 @@ function normalizeMemberName(value) { return typeof value === 'string' && value.trim() ? value.trim().toLowerCase() : ''; } -async function memberBriefing(context, memberName) { +async function memberBriefing(context, memberName, options = {}) { const requestedMemberName = String(memberName).trim(); const requestedMemberKey = normalizeMemberName(requestedMemberName); const resolved = runtimeHelpers.resolveTeamMembers(context.paths); @@ -773,6 +812,9 @@ async function memberBriefing(context, memberName) { } const leadName = runtimeHelpers.inferLeadName(context.paths); const effectiveMember = member; + const messagingProtocol = createMemberMessagingProtocol( + options.runtimeProvider || (isOpenCodeMember(effectiveMember) ? 'opencode' : 'native') + ); const role = typeof effectiveMember.role === 'string' && effectiveMember.role.trim() ? @@ -801,12 +843,25 @@ async function memberBriefing(context, memberName) { ); const taskQueue = await taskBriefing(context, requestedMemberName); + const completionNotifyExample = messagingProtocol.buildLeadMessageExample({ + teamName: context.teamName, + leadName, + fromName: requestedMemberName, + text: '#abcd1234 done. Found 3 competitors, two lack kanban. Full details in task comment e5f6a7b8. Moving to #efgh5678.', + summary: '#abcd1234 done', + }); const lines = [ `Member briefing for ${requestedMemberName} on team "${context.teamName}" (${context.teamName}).`, `Role: ${role}.`, `CRITICAL: If a task gets a new comment and you are going to do additional implementation/fix/follow-up work on that same task, FIRST leave a short task comment saying what you are about to do, THEN move it to in_progress with task_start, THEN do the work, and when finished leave a short result comment and move it to done with task_complete. Never skip this comment -> reopen -> work -> comment -> done cycle.`, - `CRITICAL: When you finish a task, your results (findings, research report, analysis, code changes summary, or any deliverable) MUST be posted as a task comment via task_add_comment BEFORE calling task_complete. Save the comment.id from the response — you will need it in the next step. The task comment is the primary delivery channel — the user reads results on the task board. A SendMessage to the lead is NOT a substitute: direct messages are ephemeral and not visible on the board. If you only SendMessage without a task comment, the user will never see your work.`, - `After task_complete, notify your team lead via SendMessage. Use the comment.id you saved (first 8 characters). Include: task ref, brief summary (2-4 sentences), pointer to full comment, and next step. Example: "#abcd1234 done. Found 3 competitors, two lack kanban. For full details: task_get_comment { taskId: \\"abcd1234\\", commentId: \\"e5f6a7b8\\" }. Moving to #efgh5678."`, + `CRITICAL: When you finish a task, your results (findings, research report, analysis, code changes summary, or any deliverable) MUST be posted as a task comment via task_add_comment BEFORE calling task_complete. Save the comment.id from the response — you will need it in the next step. The task comment is the primary delivery channel — the user reads results on the task board. A direct message to the lead is NOT a substitute: direct messages are ephemeral and not visible on the board. If you only send a direct message without a task comment, the user will never see your work.`, + `After task_complete, notify your team lead via ${messagingProtocol.sendLeadPhrase}. Use the comment.id you saved (first 8 characters). Include: task ref, brief summary (2-4 sentences), pointer to full comment, and next step. Example: ${completionNotifyExample}`, + ...(messagingProtocol.runtimeProvider === 'opencode' + ? [ + 'OpenCode visible messaging rule: call agent-teams_message_send for normal replies to the human user, lead, or same-team teammates. Always include teamName, to, from, text, and summary. Do not use SendMessage or runtime_deliver_message for ordinary replies.', + 'For cross-team replies or messages to another team, call agent-teams_cross_team_send with toTeam/fromMember. Do not put "cross_team_send" or a remote team name into message_send.to.', + ] + : []), `CRITICAL: A newly assigned task must NOT remain silently pending/TODO. If you are idle and the task is ready to start, start it now. If it must wait because you are already finishing another task, blocked, or still need more context, leave a short task comment on the waiting task immediately with the reason and your best ETA or what you are waiting on, keep it in pending/TODO, and only move it to in_progress with task_start when you truly begin.`, `Team lead: ${leadName}.`, buildMemberLanguageInstruction(config), @@ -838,7 +893,7 @@ async function memberBriefing(context, memberName) { '', buildMemberFormattingProtocol(), '', - buildMemberTaskProtocol(context.teamName), + buildMemberTaskProtocol(context.teamName, messagingProtocol), '', buildMemberProcessProtocol(context.teamName) ); diff --git a/agent-teams-controller/test/controller.test.js b/agent-teams-controller/test/controller.test.js index 71fde494..38961ba1 100644 --- a/agent-teams-controller/test/controller.test.js +++ b/agent-teams-controller/test/controller.test.js @@ -151,6 +151,68 @@ describe('agent-teams-controller API', () => { expect(briefing).toContain( 'Awareness items are watch-only context and do not authorize you to start work unless the lead reroutes the task or you become the actionOwner.' ); + expect(briefing).toContain('After task_complete, notify your team lead via SendMessage.'); + expect(briefing).toContain('Full details in task comment e5f6a7b8'); + expect(briefing).not.toContain('task_get_comment {'); + }); + + it('uses OpenCode-native visible-message wording for OpenCode member briefing', async () => { + const claudeDir = makeClaudeDir(); + const configPath = path.join(claudeDir, 'teams', 'my-team', 'config.json'); + const config = JSON.parse(fs.readFileSync(configPath, 'utf8')); + config.members = [ + { name: 'alice', role: 'team-lead' }, + { name: 'bob', role: 'developer', providerId: 'opencode', model: 'openrouter/test-model' }, + ]; + fs.writeFileSync(configPath, JSON.stringify(config, null, 2)); + + const controller = createController({ teamName: 'my-team', claudeDir }); + const briefing = await controller.tasks.memberBriefing('bob'); + + expect(briefing).toContain( + 'After task_complete, notify your team lead via MCP tool agent-teams_message_send.' + ); + expect(briefing).toContain('OpenCode visible messaging rule: call agent-teams_message_send'); + expect(briefing).toContain( + 'agent-teams_message_send { teamName: "my-team", to: "alice", from: "bob"' + ); + expect(briefing).toContain('Full details in task comment e5f6a7b8'); + expect(briefing).not.toContain('task_get_comment {'); + expect(briefing).not.toContain('notify your team lead via SendMessage'); + }); + + it('does not infer OpenCode briefing from a generic provider-scoped model alone', async () => { + const claudeDir = makeClaudeDir(); + const configPath = path.join(claudeDir, 'teams', 'my-team', 'config.json'); + const config = JSON.parse(fs.readFileSync(configPath, 'utf8')); + config.members = [ + { name: 'alice', role: 'team-lead' }, + { name: 'bob', role: 'developer', model: 'openai/gpt-5.4-mini' }, + ]; + fs.writeFileSync(configPath, JSON.stringify(config, null, 2)); + + const controller = createController({ teamName: 'my-team', claudeDir }); + const briefing = await controller.tasks.memberBriefing('bob'); + + expect(briefing).toContain('After task_complete, notify your team lead via SendMessage.'); + expect(briefing).not.toContain('agent-teams_message_send'); + }); + + it('keeps explicit native provider metadata stronger than OpenCode-looking model labels', async () => { + const claudeDir = makeClaudeDir(); + const configPath = path.join(claudeDir, 'teams', 'my-team', 'config.json'); + const config = JSON.parse(fs.readFileSync(configPath, 'utf8')); + config.members = [ + { name: 'alice', role: 'team-lead' }, + { name: 'bob', role: 'developer', providerId: 'codex', model: 'opencode/minimax-m2.5-free' }, + ]; + fs.writeFileSync(configPath, JSON.stringify(config, null, 2)); + + const controller = createController({ teamName: 'my-team', claudeDir }); + const briefing = await controller.tasks.memberBriefing('bob'); + + expect(briefing).toContain('After task_complete, notify your team lead via SendMessage.'); + expect(briefing).not.toContain('agent-teams_message_send'); }); it('resolves member briefing from members.meta.json when config members are missing', async () => { @@ -801,8 +863,10 @@ describe('agent-teams-controller API', () => { from: 'team-lead', text: 'Need your review', summary: 'Review request', + commentId: 'comment-123', relayOfMessageId: 'm-original-1', source: 'system_notification', + messageKind: 'task_comment_notification', leadSessionId: 'session-42', attachments: [{ id: 'a1', filename: 'note.txt', mimeType: 'text/plain', size: 7 }], }); @@ -814,11 +878,113 @@ describe('agent-teams-controller API', () => { const rows = JSON.parse(fs.readFileSync(inboxPath, 'utf8')); expect(rows).toHaveLength(1); expect(rows[0].source).toBe('system_notification'); + expect(rows[0].messageKind).toBe('task_comment_notification'); + expect(rows[0].commentId).toBe('comment-123'); expect(rows[0].relayOfMessageId).toBe('m-original-1'); expect(rows[0].leadSessionId).toBe('session-42'); expect(rows[0].attachments[0].filename).toBe('note.txt'); }); + it('persists slash command metadata through controller messages.appendSentMessage', () => { + const claudeDir = makeClaudeDir(); + const controller = createController({ teamName: 'my-team', claudeDir }); + + controller.messages.appendSentMessage({ + from: 'user', + to: 'alice', + text: '/compact keep only kanban context', + messageKind: 'slash_command', + slashCommand: { + name: 'compact', + command: '/compact', + args: 'keep only kanban context', + knownDescription: 'Compact the active context', + }, + }); + + controller.messages.appendSentMessage({ + from: 'alice', + to: 'user', + text: 'Compacted context.', + messageKind: 'slash_command_result', + commandOutput: { + stream: 'stdout', + commandLabel: '/compact', + }, + }); + + const sentPath = path.join(claudeDir, 'teams', 'my-team', 'sentMessages.json'); + const rows = JSON.parse(fs.readFileSync(sentPath, 'utf8')); + expect(rows).toHaveLength(2); + expect(rows[0].messageKind).toBe('slash_command'); + expect(rows[0].slashCommand).toMatchObject({ + name: 'compact', + command: '/compact', + args: 'keep only kanban context', + }); + expect(rows[1].messageKind).toBe('slash_command_result'); + expect(rows[1].commandOutput).toEqual({ + stream: 'stdout', + commandLabel: '/compact', + }); + }); + + it('canonicalizes local message recipients and guards user-directed sender identity', () => { + const claudeDir = makeClaudeDir(); + const controller = createController({ teamName: 'my-team', claudeDir }); + + controller.messages.sendMessage({ + to: 'team-lead', + from: 'bob', + text: 'Need lead input', + summary: 'Lead input', + actionMode: 'ask', + }); + + const leadInboxPath = path.join(claudeDir, 'teams', 'my-team', 'inboxes', 'alice.json'); + const leadRows = JSON.parse(fs.readFileSync(leadInboxPath, 'utf8')); + expect(leadRows).toHaveLength(1); + expect(leadRows[0].to).toBe('alice'); + expect(leadRows[0].from).toBe('bob'); + expect(leadRows[0].actionMode).toBe('ask'); + + controller.messages.sendMessage({ + to: 'user', + from: 'lead', + text: 'Visible user reply', + summary: 'Reply', + }); + + const userInboxPath = path.join(claudeDir, 'teams', 'my-team', 'inboxes', 'user.json'); + const userRows = JSON.parse(fs.readFileSync(userInboxPath, 'utf8')); + expect(userRows).toHaveLength(1); + expect(userRows[0].to).toBe('user'); + expect(userRows[0].from).toBe('alice'); + + expect(() => + controller.messages.sendMessage({ + to: 'user', + text: 'Missing sender', + }) + ).toThrow('message_send to user requires from to be the responding team member name'); + + expect(() => + controller.messages.sendMessage({ + to: 'other-team.alice', + from: 'bob', + text: 'Wrong transport', + }) + ).toThrow('message_send cannot target another team. Use cross_team_send with toTeam.'); + + expect(() => + controller.messages.sendMessage({ + to: 'cross_team_send', + from: 'bob', + text: 'Wrong transport', + }) + ).toThrow('message_send cannot target cross_team_send. Use cross_team_send with toTeam.'); + }); + it('wakes task owner on regular comment from another member', () => { const claudeDir = makeClaudeDir(); const controller = createController({ teamName: 'my-team', claudeDir }); @@ -887,10 +1053,11 @@ describe('agent-teams-controller API', () => { text: 'Need your decision here.', }); - const inboxPath = path.join(claudeDir, 'teams', 'my-team', 'inboxes', 'team-lead.json'); + const inboxPath = path.join(claudeDir, 'teams', 'my-team', 'inboxes', 'alice.json'); const rows = JSON.parse(fs.readFileSync(inboxPath, 'utf8')); expect(rows).toHaveLength(1); expect(rows[0].from).toBe('bob'); + expect(rows[0].to).toBe('alice'); expect(rows[0].text).toContain('Need your decision here.'); }); diff --git a/agent-teams-controller/test/crossTeam.test.js b/agent-teams-controller/test/crossTeam.test.js index b4b67f43..ccfc2b6c 100644 --- a/agent-teams-controller/test/crossTeam.test.js +++ b/agent-teams-controller/test/crossTeam.test.js @@ -59,8 +59,8 @@ describe('crossTeam module', () => { 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_TAG_NAME} from="team-a.lead" depth="0"`); + expect(inbox[0].from).toBe('team-a.team-lead'); + expect(inbox[0].text).toContain(`<${CROSS_TEAM_TAG_NAME} from="team-a.team-lead" depth="0"`); expect(inbox[0].conversationId).toBeTruthy(); expect(inbox[0].text).toContain(`conversationId="${inbox[0].conversationId}"`); }); @@ -98,6 +98,60 @@ describe('crossTeam module', () => { expect(sentMessages[0].messageId).toBe(outbox[0].messageId); }); + it('preserves taskRefs in target inbox, sender copy and outbox', () => { + 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 taskRefs = [{ taskId: 'task-1', displayId: 'abcd1234', teamName: 'team-a' }]; + + const controller = createController({ teamName: 'team-a', claudeDir }); + controller.crossTeam.sendCrossTeamMessage({ + toTeam: 'team-b', + text: 'Please review the linked task', + taskRefs, + }); + + const inboxPath = path.join(claudeDir, 'teams', 'team-b', 'inboxes', 'team-lead.json'); + const inbox = JSON.parse(fs.readFileSync(inboxPath, 'utf8')); + expect(inbox[0].taskRefs).toEqual(taskRefs); + + const sentMessagesPath = path.join(claudeDir, 'teams', 'team-a', 'sentMessages.json'); + const sentMessages = JSON.parse(fs.readFileSync(sentMessagesPath, 'utf8')); + expect(sentMessages[0].taskRefs).toEqual(taskRefs); + + const outbox = controller.crossTeam.getCrossTeamOutbox(); + expect(outbox[0].taskRefs).toEqual(taskRefs); + }); + + it('rejects unknown source fromMember', () => { + 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', + fromMember: 'ghost', + text: 'Hello from nowhere', + }) + ).toThrow('Unknown fromMember'); + }); + it('preserves reply conversation metadata for explicit replies', () => { const claudeDir = makeClaudeDir({ 'team-a': { diff --git a/mcp-server/src/agent-teams-controller.d.ts b/mcp-server/src/agent-teams-controller.d.ts index bdc6f469..9382d425 100644 --- a/mcp-server/src/agent-teams-controller.d.ts +++ b/mcp-server/src/agent-teams-controller.d.ts @@ -27,7 +27,10 @@ declare module 'agent-teams-controller' { setNeedsClarification(taskId: string, value: string | null): unknown; linkTask(taskId: string, targetId: string, linkType: string): unknown; unlinkTask(taskId: string, targetId: string, linkType: string): unknown; - memberBriefing(memberName: string): Promise; + memberBriefing( + memberName: string, + options?: { runtimeProvider?: 'native' | 'opencode' } + ): Promise; leadBriefing(): Promise; taskBriefing(memberName: string): Promise; } @@ -52,7 +55,7 @@ declare module 'agent-teams-controller' { export interface ControllerMessageApi { appendSentMessage(flags: Record): unknown; sendMessage(flags: Record): unknown; - lookupMessage(messageId: string): { message: Record }; + lookupMessage(messageId: string): { message: Record; store: string }; } export interface ControllerProcessApi { diff --git a/mcp-server/src/controller.ts b/mcp-server/src/controller.ts index 0cfa581a..c16de27d 100644 --- a/mcp-server/src/controller.ts +++ b/mcp-server/src/controller.ts @@ -8,12 +8,15 @@ const controllerModule = (agentTeamsControllerModule as ControllerModule).default ?? agentTeamsControllerModule; const { createController } = controllerModule; +const FORCED_CLAUDE_DIR_ENV = 'AGENT_TEAMS_MCP_CLAUDE_DIR'; + /** Re-export agentBlocks utilities (stripAgentBlocks, wrapAgentBlock, etc.) */ export const agentBlocks = controllerModule.agentBlocks; export function getController(teamName: string, claudeDir?: string) { + const forcedClaudeDir = process.env[FORCED_CLAUDE_DIR_ENV]?.trim(); return createController({ teamName, - ...(claudeDir ? { claudeDir } : {}), + ...(forcedClaudeDir ? { claudeDir: forcedClaudeDir } : claudeDir ? { claudeDir } : {}), }); } diff --git a/mcp-server/src/tools/crossTeamTools.ts b/mcp-server/src/tools/crossTeamTools.ts index 486eb014..95d55d9b 100644 --- a/mcp-server/src/tools/crossTeamTools.ts +++ b/mcp-server/src/tools/crossTeamTools.ts @@ -9,6 +9,12 @@ const toolContextSchema = { claudeDir: z.string().min(1).optional(), }; +const taskRefSchema = z.object({ + taskId: z.string().min(1), + displayId: z.string().min(1), + teamName: z.string().min(1), +}); + export function registerCrossTeamTools(server: Pick) { server.addTool({ name: 'cross_team_send', @@ -22,6 +28,7 @@ export function registerCrossTeamTools(server: Pick) { summary: z.string().optional(), conversationId: z.string().optional(), replyToConversationId: z.string().optional(), + taskRefs: z.array(taskRefSchema).optional(), chainDepth: z.number().int().nonnegative().optional(), }), execute: async ({ @@ -33,6 +40,7 @@ export function registerCrossTeamTools(server: Pick) { summary, conversationId, replyToConversationId, + taskRefs, chainDepth, }) => await Promise.resolve( @@ -44,6 +52,7 @@ export function registerCrossTeamTools(server: Pick) { ...(summary ? { summary } : {}), ...(conversationId ? { conversationId } : {}), ...(replyToConversationId ? { replyToConversationId } : {}), + ...(taskRefs?.length ? { taskRefs } : {}), ...(chainDepth !== undefined ? { chainDepth } : {}), }) ) diff --git a/mcp-server/src/tools/messageTools.ts b/mcp-server/src/tools/messageTools.ts index 59030abf..c08256dd 100644 --- a/mcp-server/src/tools/messageTools.ts +++ b/mcp-server/src/tools/messageTools.ts @@ -12,7 +12,8 @@ const toolContextSchema = { export function registerMessageTools(server: Pick) { server.addTool({ name: 'message_send', - description: 'Send a message into team inbox', + description: + 'Send a visible team/user message into team inbox. OpenCode teammates should use this for normal replies to the human user, lead, or same-team teammates. When to is "user", from is required and must be your configured teammate name.', parameters: z.object({ ...toolContextSchema, to: z.string().min(1), @@ -31,6 +32,15 @@ export function registerMessageTools(server: Pick) { }) ) .optional(), + taskRefs: z + .array( + z.object({ + taskId: z.string().min(1), + displayId: z.string().min(1), + teamName: z.string().min(1), + }) + ) + .optional(), }), execute: async ({ teamName, @@ -42,17 +52,19 @@ export function registerMessageTools(server: Pick) { source, leadSessionId, attachments, + taskRefs, }) => await Promise.resolve( jsonTextContent( getController(teamName, claudeDir).messages.sendMessage({ - to, - text, - ...(from ? { from } : {}), - ...(summary ? { summary } : {}), - ...(source ? { source } : {}), - ...(leadSessionId ? { leadSessionId } : {}), - ...(attachments?.length ? { attachments } : {}), + to, + text, + ...(from ? { from } : {}), + ...(summary ? { summary } : {}), + ...(source ? { source } : {}), + ...(leadSessionId ? { leadSessionId } : {}), + ...(attachments?.length ? { attachments } : {}), + ...(taskRefs?.length ? { taskRefs } : {}), }) ) ), diff --git a/mcp-server/src/tools/runtimeTools.ts b/mcp-server/src/tools/runtimeTools.ts index 6dd065e9..8e69f795 100644 --- a/mcp-server/src/tools/runtimeTools.ts +++ b/mcp-server/src/tools/runtimeTools.ts @@ -129,7 +129,8 @@ export function registerRuntimeTools(server: Pick) { server.addTool({ name: 'runtime_deliver_message', - description: 'Deliver an OpenCode runtime message to the app-owned team journal and destination', + description: + 'Low-level OpenCode runtime delivery journal tool. Use only when the runtime/app prompt explicitly provides runId, runtimeSessionId, idempotencyKey, and asks for runtime delivery. For normal visible replies, use message_send.', parameters: z.object({ ...toolContextSchema, idempotencyKey: z.string().min(1), diff --git a/mcp-server/src/tools/taskTools.ts b/mcp-server/src/tools/taskTools.ts index f4fc62a2..10793091 100644 --- a/mcp-server/src/tools/taskTools.ts +++ b/mcp-server/src/tools/taskTools.ts @@ -564,12 +564,15 @@ export function registerTaskTools(server: Pick) { parameters: z.object({ ...toolContextSchema, memberName: z.string().min(1), + runtimeProvider: z.enum(['native', 'opencode']).optional(), }), - execute: async ({ teamName, claudeDir, memberName }) => ({ + execute: async ({ teamName, claudeDir, memberName, runtimeProvider }) => ({ content: [ { type: 'text' as const, - text: await getController(teamName, claudeDir).tasks.memberBriefing(memberName), + text: await getController(teamName, claudeDir).tasks.memberBriefing(memberName, { + ...(runtimeProvider ? { runtimeProvider } : {}), + }), }, ], }), diff --git a/mcp-server/test/tools.test.ts b/mcp-server/test/tools.test.ts index 0fc075e5..8fd07b88 100644 --- a/mcp-server/test/tools.test.ts +++ b/mcp-server/test/tools.test.ts @@ -119,6 +119,7 @@ describe('agent-teams-mcp tools', () => { text: 'Reply', conversationId: 'conv-1', replyToConversationId: 'conv-1', + taskRefs: [{ taskId: 'task-1', displayId: 'abcd1234', teamName: 'alpha' }], }); expect(parsed?.success).toBe(true); @@ -555,6 +556,22 @@ describe('agent-teams-mcp tools', () => { 'Use task_list only to search/browse inventory rows, not as your working queue.' ); expect(memberBriefingText).toContain('Review MCP adapter'); + expect(memberBriefingText).toContain('Full details in task comment e5f6a7b8'); + expect(memberBriefingText).not.toContain('task_get_comment {'); + + const openCodeMemberBriefing = await getTool('member_briefing').execute({ + claudeDir, + teamName, + memberName: 'alice', + runtimeProvider: 'opencode', + }); + const openCodeMemberBriefingText = ( + openCodeMemberBriefing as { content: Array<{ text: string }> } + ).content[0]?.text; + expect(openCodeMemberBriefingText).toContain('agent-teams_message_send'); + expect(openCodeMemberBriefingText).toContain('Full details in task comment e5f6a7b8'); + expect(openCodeMemberBriefingText).not.toContain('task_get_comment {'); + expect(openCodeMemberBriefingText).not.toContain('notify your team lead via SendMessage'); }); it('keeps owner-backed MCP tasks pending by default, supports explicit startImmediately, sends owner notifications, and returns compact task_briefing output', async () => { @@ -1132,6 +1149,12 @@ describe('agent-teams-mcp tools', () => { it('persists full message metadata through message_send', async () => { const claudeDir = makeClaudeDir(); const teamName = 'gamma'; + writeTeamConfig(claudeDir, teamName, { + members: [ + { name: 'lead', role: 'team-lead' }, + { name: 'alice', role: 'developer' }, + ], + }); const sent = parseJsonToolResult( await getTool('message_send').execute({ @@ -1144,6 +1167,7 @@ describe('agent-teams-mcp tools', () => { source: 'system_notification', leadSessionId: 'session-42', attachments: [{ id: 'att-1', filename: 'note.txt', mimeType: 'text/plain', size: 4 }], + taskRefs: [{ taskId: 'task-1', displayId: 'abcd1234', teamName }], }) ); @@ -1153,6 +1177,49 @@ describe('agent-teams-mcp tools', () => { expect(rows[0].source).toBe('system_notification'); expect(rows[0].leadSessionId).toBe('session-42'); expect(rows[0].attachments[0].filename).toBe('note.txt'); + expect(rows[0].taskRefs).toEqual([{ taskId: 'task-1', displayId: 'abcd1234', teamName }]); + }); + + it('uses forced app claude dir over model-supplied claudeDir when configured', async () => { + const forcedClaudeDir = makeClaudeDir(); + const wrongClaudeDir = makeClaudeDir(); + const teamName = 'forced-root'; + writeTeamConfig(forcedClaudeDir, teamName, { + members: [ + { name: 'lead', role: 'team-lead' }, + { name: 'bob', role: 'developer' }, + ], + }); + + const previousForcedDir = process.env.AGENT_TEAMS_MCP_CLAUDE_DIR; + process.env.AGENT_TEAMS_MCP_CLAUDE_DIR = forcedClaudeDir; + try { + const sent = parseJsonToolResult( + await getTool('message_send').execute({ + claudeDir: wrongClaudeDir, + teamName, + to: 'user', + text: 'Forced root reply', + from: 'bob', + }) + ); + + expect(sent.deliveredToInbox).toBe(true); + const forcedInboxPath = path.join(forcedClaudeDir, 'teams', teamName, 'inboxes', 'user.json'); + const wrongInboxPath = path.join(wrongClaudeDir, 'teams', teamName, 'inboxes', 'user.json'); + expect(JSON.parse(fs.readFileSync(forcedInboxPath, 'utf8'))[0]).toMatchObject({ + from: 'bob', + to: 'user', + text: 'Forced root reply', + }); + expect(fs.existsSync(wrongInboxPath)).toBe(false); + } finally { + if (previousForcedDir === undefined) { + delete process.env.AGENT_TEAMS_MCP_CLAUDE_DIR; + } else { + process.env.AGENT_TEAMS_MCP_CLAUDE_DIR = previousForcedDir; + } + } }); it('exposes zod schemas that reject obviously invalid payloads', () => { diff --git a/src/main/index.ts b/src/main/index.ts index fd91a899..1613889d 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -135,7 +135,12 @@ import { } from './services/team/TeamReconcileDrainScheduler'; import { TeamSentMessagesStore } from './services/team/TeamSentMessagesStore'; import { getAppIconPath } from './utils/appIcon'; -import { getProjectsBasePath, getTeamsBasePath, getTodosBasePath } from './utils/pathDecoder'; +import { + getClaudeBasePath, + getProjectsBasePath, + getTeamsBasePath, + getTodosBasePath, +} from './utils/pathDecoder'; import { clearRendererAvailability, markRendererReady, @@ -227,6 +232,7 @@ async function createOpenCodeRuntimeAdapterRegistry(): Promise { - if (!leadName) return; - if (inboxName === leadName) { - return teamProvisioningService.relayLeadInboxMessages(teamName); + void teamProvisioningService + .relayInboxFileToLiveRecipient(teamName, inboxName) + .then((relay) => { + if (relay.diagnostics?.length) { + logger.warn( + `[FileWatcher] relay diagnostics for ${teamName}/${inboxName}: ${relay.diagnostics.join('; ')}` + ); } - // Teammate inbox relay DISABLED (2026-03-23): teammates read their own - // inbox files directly via fs.watch. See teams.ts handleSendMessage for details. - // Lead relay is still needed (lead reads stdin only, not inbox files). - return undefined; }) .catch((e: unknown) => logger.warn(`[FileWatcher] relay failed for ${teamName}: ${String(e)}`) diff --git a/src/main/ipc/teams.ts b/src/main/ipc/teams.ts index 2f37120b..e1fb819c 100644 --- a/src/main/ipc/teams.ts +++ b/src/main/ipc/teams.ts @@ -212,6 +212,7 @@ import type { import type { CliArgsValidationResult } from '@shared/utils/cliArgsParser'; const logger = createLogger('IPC:teams'); +const OPENCODE_RUNTIME_DELIVERY_UI_TIMEOUT_MS = 12_000; /** * In-memory set of rate-limit message keys already processed. @@ -221,6 +222,27 @@ const logger = createLogger('IPC:teams'); const seenRateLimitKeys = new Set(); const SEEN_RATE_LIMIT_KEYS_MAX = 500; +async function withTimeoutValue( + promise: Promise, + timeoutMs: number, + timeoutValue: T +): Promise { + let timer: ReturnType | null = null; + try { + return await Promise.race([ + promise, + new Promise((resolve) => { + timer = setTimeout(() => resolve(timeoutValue), timeoutMs); + timer.unref?.(); + }), + ]); + } finally { + if (timer) { + clearTimeout(timer); + } + } +} + function noteHeavyTeamDataWorkerFallback(operation: string): void { if (!app.isPackaged) { return; @@ -2538,16 +2560,24 @@ async function handleSendMessage( // Inbox path: offline lead or regular members (no attachment support) const baseText = payload.text!.trim(); + const replyRecipient = + typeof payload.from === 'string' && payload.from.trim().length > 0 + ? payload.from.trim() + : 'user'; + const isOpenCodeRecipient = + !isLeadRecipient && (await provisioning.isOpenCodeRuntimeRecipient(tn, memberName)); const memberDeliveryText = buildMessageDeliveryText(baseText, { actionMode, isLeadRecipient, - replyRecipient: typeof payload.from === 'string' ? payload.from : 'user', + replyRecipient, }); + const inboxText = isOpenCodeRecipient ? baseText : memberDeliveryText; const result = await getTeamDataService().sendMessage(tn, { member: memberName, - text: memberDeliveryText, + text: inboxText, summary: payload.summary, from: payload.from, + actionMode, source: 'user_sent', taskRefs: validatedTaskRefs.value, }); @@ -2570,28 +2600,63 @@ async function handleSendMessage( // logger.warn(`Relay after sendMessage failed for teammate "${memberName}": ${String(e)}`); // } // } - if (!isLeadRecipient && isAlive) { - void provisioning - .deliverOpenCodeMemberMessage(tn, { - memberName, - text: memberDeliveryText, - messageId: result.messageId, - }) - .then((delivery) => { - if (delivery.delivered || delivery.reason === 'recipient_is_not_opencode') { - return; + if (isOpenCodeRecipient) { + try { + const relay = await withTimeoutValue( + provisioning.relayOpenCodeMemberInboxMessages(tn, memberName, { + onlyMessageId: result.messageId, + source: 'ui-send', + deliveryMetadata: { + replyRecipient, + actionMode, + taskRefs: validatedTaskRefs.value, + }, + }), + OPENCODE_RUNTIME_DELIVERY_UI_TIMEOUT_MS, + { + relayed: 0, + attempted: 1, + delivered: 0, + failed: 1, + lastDelivery: { + delivered: false, + reason: 'opencode_runtime_delivery_timeout', + diagnostics: ['opencode_runtime_delivery_timeout'], + }, } + ); + const delivery = relay.lastDelivery ?? { + delivered: relay.relayed > 0, + reason: relay.relayed > 0 ? undefined : 'opencode_message_delivery_not_attempted', + diagnostics: undefined, + }; + result.runtimeDelivery = { + providerId: 'opencode', + attempted: true, + delivered: delivery.delivered, + reason: delivery.reason, + diagnostics: delivery.diagnostics, + }; + if (!delivery.delivered && delivery.reason !== 'recipient_is_not_opencode') { logger.warn( `OpenCode runtime delivery after sendMessage failed for teammate "${memberName}": ${ delivery.reason ?? 'unknown error' }` ); - }) - .catch((e: unknown) => - logger.warn( - `OpenCode runtime delivery after sendMessage crashed for teammate "${memberName}": ${String(e)}` - ) + } + } catch (e: unknown) { + const reason = e instanceof Error ? e.message : String(e); + result.runtimeDelivery = { + providerId: 'opencode', + attempted: true, + delivered: false, + reason, + diagnostics: [reason], + }; + logger.warn( + `OpenCode runtime delivery after sendMessage crashed for teammate "${memberName}": ${reason}` ); + } } // Best-effort relay for lead via inbox diff --git a/src/main/services/team/CrossTeamOutbox.ts b/src/main/services/team/CrossTeamOutbox.ts index 1b4c0e96..4c331968 100644 --- a/src/main/services/team/CrossTeamOutbox.ts +++ b/src/main/services/team/CrossTeamOutbox.ts @@ -15,6 +15,10 @@ function normalizeForDedupe(value: string | undefined): string { .toLowerCase(); } +function normalizeTaskRefsForDedupe(message: CrossTeamMessage): string { + return message.taskRefs?.length ? JSON.stringify(message.taskRefs) : ''; +} + function buildCrossTeamDedupeKey(message: CrossTeamMessage): string { return [ normalizeForDedupe(message.fromTeam), @@ -22,6 +26,7 @@ function buildCrossTeamDedupeKey(message: CrossTeamMessage): string { normalizeForDedupe(message.toTeam), normalizeForDedupe(message.summary), normalizeForDedupe(message.text), + normalizeTaskRefsForDedupe(message), ].join('||'); } diff --git a/src/main/services/team/CrossTeamService.ts b/src/main/services/team/CrossTeamService.ts index 63c01676..f41c4c15 100644 --- a/src/main/services/team/CrossTeamService.ts +++ b/src/main/services/team/CrossTeamService.ts @@ -26,6 +26,28 @@ const { createController } = agentTeamsControllerModule; const TEAM_NAME_PATTERN = /^[a-z0-9][a-z0-9-]{0,127}$/; +function normalizeMemberKey(value: unknown): string { + return typeof value === 'string' && value.trim().length > 0 ? value.trim().toLowerCase() : ''; +} + +function resolveCrossTeamFromMember(config: TeamConfig, rawFromMember: string): string { + const members = Array.isArray(config.members) ? config.members : []; + const rawKey = normalizeMemberKey(rawFromMember); + const direct = members.find((member) => normalizeMemberKey(member.name) === rawKey); + if (direct?.name?.trim()) { + return direct.name.trim(); + } + + const lead = members.find((member) => isLeadMember(member)) ?? members[0]; + const leadName = lead?.name?.trim(); + const leadKey = normalizeMemberKey(leadName); + if (leadName && (rawKey === 'lead' || rawKey === 'team-lead' || rawKey === leadKey)) { + return leadName; + } + + throw new Error(`Unknown fromMember: ${rawFromMember}. Use a configured team member name.`); +} + export interface CrossTeamTarget { teamName: string; displayName: string; @@ -48,7 +70,8 @@ export class CrossTeamService { ) {} async send(request: CrossTeamSendRequest): Promise { - const { fromTeam, fromMember, toTeam, text, taskRefs, summary, actionMode } = request; + const { fromTeam, toTeam, text, taskRefs, summary, actionMode } = request; + const rawFromMember = request.fromMember; const chainDepth = request.chainDepth ?? 0; const messageId = request.messageId?.trim() || randomUUID(); const timestamp = request.timestamp ?? new Date().toISOString(); @@ -76,13 +99,19 @@ export class CrossTeamService { if (fromTeam === toTeam) { throw new Error('Cannot send cross-team message to the same team'); } - if (!fromMember || typeof fromMember !== 'string' || fromMember.trim().length === 0) { + if (!rawFromMember || typeof rawFromMember !== 'string' || rawFromMember.trim().length === 0) { throw new Error('fromMember is required'); } if (!text || typeof text !== 'string' || text.trim().length === 0) { throw new Error('Message text is required'); } + const sourceConfig = await this.configReader.getConfig(fromTeam); + if (!sourceConfig || sourceConfig.deletedAt) { + throw new Error(`Source team not found: ${fromTeam}`); + } + const fromMember = resolveCrossTeamFromMember(sourceConfig, rawFromMember.trim()); + const targetConfig = await this.configReader.getConfig(toTeam); if (!targetConfig || targetConfig.deletedAt) { throw new Error(`Target team not found: ${toTeam}`); diff --git a/src/main/services/team/TeamDataService.ts b/src/main/services/team/TeamDataService.ts index 402e1308..5341ee36 100644 --- a/src/main/services/team/TeamDataService.ts +++ b/src/main/services/team/TeamDataService.ts @@ -2128,6 +2128,8 @@ export class TeamDataService { slashCommand: enrichedRequest.slashCommand, commandOutput: enrichedRequest.commandOutput, taskRefs: enrichedRequest.taskRefs, + actionMode: enrichedRequest.actionMode, + commentId: enrichedRequest.commentId, summary: enrichedRequest.summary, source: enrichedRequest.source, leadSessionId: enrichedRequest.leadSessionId, diff --git a/src/main/services/team/TeamInboxReader.ts b/src/main/services/team/TeamInboxReader.ts index f9b2062d..11793f4d 100644 --- a/src/main/services/team/TeamInboxReader.ts +++ b/src/main/services/team/TeamInboxReader.ts @@ -108,6 +108,10 @@ export class TeamInboxReader { timestamp: row.timestamp, read: typeof row.read === 'boolean' ? row.read : false, taskRefs: Array.isArray(row.taskRefs) ? row.taskRefs : undefined, + actionMode: + row.actionMode === 'do' || row.actionMode === 'ask' || row.actionMode === 'delegate' + ? row.actionMode + : undefined, commentId: typeof row.commentId === 'string' ? row.commentId : undefined, summary: typeof row.summary === 'string' ? row.summary : undefined, color: typeof row.color === 'string' ? row.color : undefined, diff --git a/src/main/services/team/TeamInboxWriter.ts b/src/main/services/team/TeamInboxWriter.ts index 54883782..41ee27ad 100644 --- a/src/main/services/team/TeamInboxWriter.ts +++ b/src/main/services/team/TeamInboxWriter.ts @@ -28,6 +28,7 @@ export class TeamInboxWriter { timestamp: request.timestamp ?? new Date().toISOString(), read: false, taskRefs: request.taskRefs?.length ? request.taskRefs : undefined, + actionMode: request.actionMode, commentId: typeof request.commentId === 'string' ? request.commentId : undefined, summary: request.summary, messageId, diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index b09f1ed5..1db3874c 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -127,6 +127,7 @@ import { getOpenCodeRuntimeRunTombstonesPath, getOpenCodeTeamRuntimeDirectory, migrateLegacyOpenCodeRuntimeState, + OpenCodeRuntimeManifestEvidenceReader, readOpenCodeRuntimeLaneIndex, recoverStaleOpenCodeRuntimeLaneIndexEntry, removeOpenCodeRuntimeLaneIndexEntry, @@ -138,6 +139,7 @@ import { } from './opencode/store/RuntimeRunTombstoneStore'; import { OpenCodeTaskLogAttributionStore } from './taskLogs/stream/OpenCodeTaskLogAttributionStore'; import { buildActionModeProtocol } from './actionModeInstructions'; +import { isAgentTeamsToolUse } from './agentTeamsToolNames'; import { atomicWriteAsync } from './atomicWrite'; import { peekAutoResumeService } from './AutoResumeService'; import { ClaudeBinaryResolver } from './ClaudeBinaryResolver'; @@ -256,6 +258,7 @@ type BootstrapTranscriptOutcome = import type { ActiveToolCall, + AgentActionMode, CliProviderModelCatalog, CliProviderRuntimeCapabilities, CliProviderStatus, @@ -292,6 +295,7 @@ import type { TeamProvisioningState, TeamRuntimeState, TeamTask, + TaskRef, ToolActivityEventPayload, ToolApprovalAutoResolved, ToolApprovalEvent, @@ -319,6 +323,7 @@ function appendPreflightDebugLog(event: string, data: Record): } } const { + AGENT_TEAMS_TEAMMATE_OPERATIONAL_TOOL_NAMES, AGENT_TEAMS_NAMESPACED_LEAD_BOOTSTRAP_TOOL_NAMES, AGENT_TEAMS_NAMESPACED_TEAMMATE_OPERATIONAL_TOOL_NAMES, createController, @@ -373,6 +378,29 @@ function runtimeTaskRefs(teamName: string, value: unknown): InboxMessage['taskRe : undefined; } +function structuredTaskRefs(value: unknown): TaskRef[] | undefined { + if (!Array.isArray(value) || value.length === 0) { + return undefined; + } + + const refs = value + .filter((item): item is Record => Boolean(item) && typeof item === 'object') + .map((item) => ({ + taskId: typeof item.taskId === 'string' ? item.taskId.trim() : '', + displayId: typeof item.displayId === 'string' ? item.displayId.trim() : '', + teamName: typeof item.teamName === 'string' ? item.teamName.trim() : '', + })) + .filter( + (item) => item.taskId.length > 0 && item.displayId.length > 0 && item.teamName.length > 0 + ); + + return refs.length > 0 ? refs : undefined; +} + +function teamToolTaskRefs(teamName: string, value: unknown): TaskRef[] | undefined { + return structuredTaskRefs(value) ?? runtimeTaskRefs(teamName, value); +} + // TODO(team-result-notification-v2): The safest long-term design is a runtime-authored // task_result_notification emitted after task_complete with a validated resultCommentId. // That would let the lead react to authoritative board/runtime state instead of @@ -3478,6 +3506,43 @@ interface NativeSameTeamFingerprint { seenAt: number; } +interface OpenCodeMemberInboxDelivery { + delivered: boolean; + reason?: string; + diagnostics?: string[]; +} + +interface OpenCodeMemberInboxRelayResult { + relayed: number; + attempted: number; + delivered: number; + failed: number; + lastDelivery?: OpenCodeMemberInboxDelivery; + diagnostics?: string[]; +} + +interface LiveInboxRelayResult { + kind: + | 'ignored' + | 'native_lead' + | 'native_member_noop' + | 'opencode_member' + | 'opencode_lead_unsupported'; + relayed: number; + diagnostics?: string[]; + lastDelivery?: OpenCodeMemberInboxDelivery; +} + +interface OpenCodeMemberInboxRelayOptions { + onlyMessageId?: string; + source?: 'watcher' | 'ui-send' | 'manual'; + deliveryMetadata?: { + replyRecipient?: string; + actionMode?: AgentActionMode; + taskRefs?: TaskRef[]; + }; +} + function normalizeSameTeamText(text: string): string { return text.trim().replace(/\r\n/g, '\n'); } @@ -3525,6 +3590,10 @@ export class TeamProvisioningService { private readonly leadInboxRelayInFlight = new Map>(); private readonly relayedLeadInboxMessageIds = new Map>(); private readonly memberInboxRelayInFlight = new Map>(); + private readonly openCodeMemberInboxRelayInFlight = new Map< + string, + Promise + >(); private readonly relayedMemberInboxMessageIds = new Map>(); private readonly pendingCrossTeamFirstReplies = new Map>(); private readonly recentCrossTeamLeadDeliveryMessageIds = new Map>(); @@ -4126,12 +4195,46 @@ export class TeamProvisioningService { }; } + async isOpenCodeRuntimeRecipient(teamName: string, memberName: string): Promise { + const normalizedMemberName = memberName.trim().toLowerCase(); + if (!normalizedMemberName) { + return false; + } + + const [config, metaMembers] = await Promise.all([ + this.configReader.getConfig(teamName).catch(() => null), + this.membersMetaStore.getMembers(teamName).catch(() => []), + ]); + const configMember = config?.members?.find( + (member) => member.name?.trim().toLowerCase() === normalizedMemberName + ); + const metaMember = metaMembers.find( + (member) => member.name?.trim().toLowerCase() === normalizedMemberName + ); + const configProvider = (configMember as { provider?: unknown } | undefined)?.provider; + const metaProvider = (metaMember as { provider?: unknown } | undefined)?.provider; + const normalizeProviderLike = (value: unknown) => + normalizeOptionalTeamProviderId( + typeof value === 'string' ? value.trim().toLowerCase() : value + ); + const providerId = + normalizeProviderLike(metaMember?.providerId) ?? + normalizeProviderLike(metaProvider) ?? + normalizeProviderLike(configMember?.providerId) ?? + normalizeProviderLike(configProvider) ?? + inferTeamProviderIdFromModel(metaMember?.model ?? configMember?.model); + return providerId === 'opencode'; + } + async deliverOpenCodeMemberMessage( teamName: string, input: { memberName: string; text: string; messageId?: string; + replyRecipient?: string; + actionMode?: AgentActionMode; + taskRefs?: TaskRef[]; } ): Promise<{ delivered: boolean; reason?: string; diagnostics?: string[] }> { const adapter = this.getOpenCodeRuntimeMessageAdapter(); @@ -4151,9 +4254,17 @@ export class TeamProvisioningService { const metaMember = metaMembers.find( (member) => member.name?.trim().toLowerCase() === normalizedMemberName.toLowerCase() ); + const configProvider = (configMember as { provider?: unknown } | undefined)?.provider; + const metaProvider = (metaMember as { provider?: unknown } | undefined)?.provider; + const normalizeProviderLike = (value: unknown) => + normalizeOptionalTeamProviderId( + typeof value === 'string' ? value.trim().toLowerCase() : value + ); const providerId = - normalizeOptionalTeamProviderId(metaMember?.providerId) ?? - normalizeOptionalTeamProviderId(configMember?.providerId) ?? + normalizeProviderLike(metaMember?.providerId) ?? + normalizeProviderLike(metaProvider) ?? + normalizeProviderLike(configMember?.providerId) ?? + normalizeProviderLike(configProvider) ?? inferTeamProviderIdFromModel(metaMember?.model ?? configMember?.model); if (providerId !== 'opencode') { return { delivered: false, reason: 'recipient_is_not_opencode' }; @@ -4198,6 +4309,7 @@ export class TeamProvisioningService { const trackedRunId = this.resolveDeliverableTrackedRuntimeRunId(teamName); const trackedRun = trackedRunId ? this.runs.get(trackedRunId) : null; + let liveSecondaryLaneRunId: string | null = null; if ( trackedRun && laneIdentity.laneKind === 'secondary' && @@ -4208,27 +4320,29 @@ export class TeamProvisioningService { lane.laneId === laneIdentity.laneId || lane.member.name.trim().toLowerCase() === normalizedMemberName.toLowerCase() ); - if (!liveLane) { - return { delivered: false, reason: 'opencode_runtime_not_active' }; - } + liveSecondaryLaneRunId = liveLane?.runId?.trim() || null; } - if (!trackedRunId) { - const laneIndex = await readOpenCodeRuntimeLaneIndex(getTeamsBasePath(), teamName).catch( - () => null - ); - if (laneIndex?.lanes[laneIdentity.laneId]?.state !== 'active') { - return { delivered: false, reason: 'opencode_runtime_not_active' }; - } + const runtimeRunId = + laneIdentity.laneKind === 'secondary' && laneIdentity.laneOwnerProviderId === 'opencode' + ? (liveSecondaryLaneRunId ?? + (await this.resolveCurrentOpenCodeRuntimeRunId(teamName, laneIdentity.laneId))) + : (trackedRunId ?? + (await this.resolveCurrentOpenCodeRuntimeRunId(teamName, laneIdentity.laneId))); + if (!runtimeRunId) { + return { delivered: false, reason: 'opencode_runtime_not_active' }; } const result = await adapter.sendMessageToMember({ - ...(trackedRunId ? { runId: trackedRunId } : {}), + ...(runtimeRunId ? { runId: runtimeRunId } : {}), teamName, laneId: laneIdentity.laneId, memberName: canonicalMemberName, cwd, text: input.text, messageId: input.messageId, + replyRecipient: input.replyRecipient, + actionMode: input.actionMode, + taskRefs: input.taskRefs, }); return { delivered: result.ok, @@ -4406,6 +4520,31 @@ export class TeamProvisioningService { return secondaryLaneRun?.runId ?? null; } + private async resolveCurrentOpenCodeRuntimeRunId( + teamName: string, + laneId: string + ): Promise { + const inMemoryRunId = this.getCurrentOpenCodeRuntimeRunId(teamName, laneId); + if (inMemoryRunId) { + return inMemoryRunId; + } + + const laneIndex = await readOpenCodeRuntimeLaneIndex(getTeamsBasePath(), teamName).catch( + () => null + ); + if (laneIndex?.lanes[laneId]?.state !== 'active') { + return null; + } + + const evidence = await new OpenCodeRuntimeManifestEvidenceReader({ + teamsBasePath: getTeamsBasePath(), + }) + .read(teamName, laneId) + .catch(() => null); + const durableRunId = evidence?.activeRunId?.trim(); + return durableRunId || null; + } + private async resolveOpenCodeRuntimeLaneId(params: { teamName: string; runId: string; @@ -4605,6 +4744,11 @@ export class TeamProvisioningService { this.memberInboxRelayInFlight.delete(key); } } + for (const key of Array.from(this.openCodeMemberInboxRelayInFlight.keys())) { + if (key.startsWith(`opencode:${teamName}:`)) { + this.openCodeMemberInboxRelayInFlight.delete(key); + } + } for (const key of Array.from(this.relayedMemberInboxMessageIds.keys())) { if (key.startsWith(`${teamName}:`)) { this.relayedMemberInboxMessageIds.delete(key); @@ -4924,20 +5068,48 @@ export class TeamProvisioningService { return Array.isArray(innerContent) ? (innerContent as Record[]) : []; } - private hasCapturedVisibleSendMessage(content: Record[]): boolean { + private hasCapturedVisibleSendMessage( + content: Record[], + teamName: string + ): boolean { return content.some((part) => { if (!part || typeof part !== 'object') return false; if (part.type !== 'tool_use' || typeof part.name !== 'string') return false; - if (part.name !== 'SendMessage') return false; - const input = part.input; if (!input || typeof input !== 'object') return false; const inp = input as Record; - const target = (typeof inp.recipient === 'string' ? inp.recipient : '').trim(); - const text = (typeof inp.content === 'string' ? inp.content : '').trim(); - return target.length > 0 && text.length > 0; + if (part.name === 'SendMessage') { + const target = (typeof inp.recipient === 'string' ? inp.recipient : '').trim(); + const text = (typeof inp.content === 'string' ? inp.content : '').trim(); + return target.length > 0 && text.length > 0; + } + + const isTeamMessageSendTool = isAgentTeamsToolUse({ + rawName: part.name, + canonicalName: 'message_send', + toolInput: inp, + currentTeamName: teamName, + }); + const isDirectCrossTeamSendTool = isAgentTeamsToolUse({ + rawName: part.name, + canonicalName: 'cross_team_send', + toolInput: inp, + currentTeamName: teamName, + }); + if (!isTeamMessageSendTool && !isDirectCrossTeamSendTool) return false; + + const target = isTeamMessageSendTool + ? typeof inp.to === 'string' + ? inp.to + : '' + : typeof inp.toTeam === 'string' + ? inp.toTeam + : ''; + const text = typeof inp.text === 'string' ? inp.text : ''; + + return target.trim().length > 0 && text.trim().length > 0; }); } @@ -5281,6 +5453,10 @@ export class TeamProvisioningService { return `${teamName}:${memberName.trim()}`; } + private getOpenCodeMemberRelayKey(teamName: string, memberName: string): string { + return `opencode:${this.getMemberRelayKey(teamName, memberName)}`; + } + private normalizeRelayCandidateText(text: string): string { return stripAgentBlocks(String(text)).trim().replace(/\r\n/g, '\n'); } @@ -5650,7 +5826,7 @@ export class TeamProvisioningService { await store.assertEvidenceAccepted({ teamName: input.teamName, runId: input.runId, - currentRunId: this.getCurrentOpenCodeRuntimeRunId(input.teamName, input.laneId), + currentRunId: await this.resolveCurrentOpenCodeRuntimeRunId(input.teamName, input.laneId), evidenceKind: input.evidenceKind, }); } @@ -5804,7 +5980,7 @@ export class TeamProvisioningService { return new RuntimeDeliveryService( { getCurrentRunId: async (candidateTeamName) => - this.getCurrentOpenCodeRuntimeRunId(candidateTeamName, laneId), + this.resolveCurrentOpenCodeRuntimeRunId(candidateTeamName, laneId), }, journal, new RuntimeDeliveryDestinationRegistry(this.createOpenCodeRuntimeDeliveryPorts()), @@ -5939,12 +6115,14 @@ export class TeamProvisioningService { if (!this.crossTeamSender) { throw new Error('Cross-team sender is not configured'); } + const taskRefs = runtimeTaskRefs(envelope.teamName, envelope.taskRefs); await this.crossTeamSender({ fromTeam: envelope.teamName, fromMember: envelope.fromMemberName, toTeam: envelope.to.teamName, text: envelope.text, summary: envelope.summary ?? undefined, + ...(taskRefs ? { taskRefs } : {}), messageId: destinationMessageId, timestamp: envelope.createdAt, conversationId: envelope.idempotencyKey, @@ -11134,6 +11312,233 @@ export class TeamProvisioningService { } } + async relayInboxFileToLiveRecipient( + teamName: string, + inboxName: string, + options: OpenCodeMemberInboxRelayOptions = {} + ): Promise { + if ( + this.isCrossTeamPseudoRecipientName(inboxName) || + this.isCrossTeamToolRecipientName(inboxName) + ) { + return { kind: 'ignored', relayed: 0 }; + } + + const leadName = await this.configReader + .getConfig(teamName) + .then( + (config) => config?.members?.find((member) => isLeadMember(member))?.name?.trim() || null + ) + .catch(() => null); + if (leadName && inboxName.trim().toLowerCase() === leadName.toLowerCase()) { + if (await this.isOpenCodeRuntimeRecipient(teamName, inboxName)) { + const diagnostic = + 'opencode_lead_runtime_session_missing: OpenCode lead inbox relay is unsupported in v1; leaving inbox unread for durable retry/diagnostics.'; + logger.warn(`[${teamName}] ${diagnostic} inbox=${inboxName}`); + return { + kind: 'opencode_lead_unsupported', + relayed: 0, + diagnostics: [diagnostic], + }; + } + return { + kind: 'native_lead', + relayed: this.isTeamAlive(teamName) ? await this.relayLeadInboxMessages(teamName) : 0, + }; + } + + if (await this.isOpenCodeRuntimeRecipient(teamName, inboxName)) { + const relayOptions: OpenCodeMemberInboxRelayOptions = { + source: options.source ?? 'watcher', + ...(options.onlyMessageId ? { onlyMessageId: options.onlyMessageId } : {}), + ...(options.deliveryMetadata ? { deliveryMetadata: options.deliveryMetadata } : {}), + }; + const relay = await this.relayOpenCodeMemberInboxMessages(teamName, inboxName, relayOptions); + return { + kind: 'opencode_member', + relayed: relay.relayed, + diagnostics: relay.diagnostics, + lastDelivery: relay.lastDelivery, + }; + } + + return { kind: 'native_member_noop', relayed: 0 }; + } + + async relayOpenCodeMemberInboxMessages( + teamName: string, + memberName: string, + options: OpenCodeMemberInboxRelayOptions = {} + ): Promise { + const relayKey = this.getOpenCodeMemberRelayKey(teamName, memberName); + const existing = this.openCodeMemberInboxRelayInFlight.get(relayKey); + if (existing) { + const existingResult = await existing; + const onlyMessageId = options.onlyMessageId?.trim(); + if (!onlyMessageId) { + return existingResult; + } + const inboxMessages = await this.inboxReader + .getMessagesFor(teamName, memberName) + .catch(() => []); + const targetMessage = inboxMessages.find((message) => message.messageId === onlyMessageId); + if (targetMessage?.read) { + return { + relayed: 0, + attempted: 1, + delivered: 1, + failed: 0, + lastDelivery: { delivered: true }, + diagnostics: existingResult.diagnostics, + }; + } + if (!targetMessage) { + const diagnostic = `opencode_inbox_message_missing_after_inflight_relay: ${onlyMessageId}`; + return { + relayed: 0, + attempted: 1, + delivered: 0, + failed: 1, + lastDelivery: { + delivered: false, + reason: 'opencode_inbox_message_missing_after_inflight_relay', + diagnostics: [diagnostic], + }, + diagnostics: [diagnostic], + }; + } + } + + const work = (async (): Promise => { + const result: OpenCodeMemberInboxRelayResult = { + relayed: 0, + attempted: 0, + delivered: 0, + failed: 0, + }; + if (!(await this.isOpenCodeRuntimeRecipient(teamName, memberName))) { + result.lastDelivery = { delivered: false, reason: 'recipient_is_not_opencode' }; + return result; + } + + let inboxMessages: Awaited> = []; + try { + inboxMessages = await this.inboxReader.getMessagesFor(teamName, memberName); + } catch (error) { + const diagnostic = `opencode_inbox_read_failed: ${getErrorMessage(error)}`; + result.lastDelivery = { + delivered: false, + reason: 'opencode_inbox_read_failed', + diagnostics: [diagnostic], + }; + result.diagnostics = [diagnostic]; + return result; + } + + const onlyMessageId = options.onlyMessageId?.trim(); + if (onlyMessageId) { + const targetMessage = inboxMessages.find((message) => message.messageId === onlyMessageId); + if (targetMessage?.read) { + return { + relayed: 0, + attempted: 1, + delivered: 1, + failed: 0, + lastDelivery: { delivered: true }, + }; + } + if (!targetMessage) { + const diagnostic = `opencode_inbox_message_missing: ${onlyMessageId}`; + return { + relayed: 0, + attempted: 1, + delivered: 0, + failed: 1, + lastDelivery: { + delivered: false, + reason: 'opencode_inbox_message_missing', + diagnostics: [diagnostic], + }, + diagnostics: [diagnostic], + }; + } + } + const unread = inboxMessages + .filter((message): message is InboxMessage & { messageId: string } => { + if (message.read) return false; + if (onlyMessageId && message.messageId !== onlyMessageId) return false; + if (typeof message.text !== 'string' || message.text.trim().length === 0) return false; + return this.hasStableMessageId(message); + }) + .sort((a, b) => Date.parse(a.timestamp) - Date.parse(b.timestamp)) + .slice(0, 10); + + for (const message of unread) { + const fallbackReplyRecipient = + typeof message.from === 'string' && + message.from.trim() && + message.from.trim().toLowerCase() !== memberName.trim().toLowerCase() + ? message.from.trim() + : 'user'; + result.attempted += 1; + const delivery = await this.deliverOpenCodeMemberMessage(teamName, { + memberName, + text: message.text, + messageId: message.messageId, + replyRecipient: options.deliveryMetadata?.replyRecipient ?? fallbackReplyRecipient, + actionMode: options.deliveryMetadata?.actionMode ?? message.actionMode, + taskRefs: options.deliveryMetadata?.taskRefs ?? message.taskRefs, + }); + result.lastDelivery = delivery; + if (!delivery.delivered) { + result.failed += 1; + result.diagnostics = [ + ...(result.diagnostics ?? []), + ...(delivery.diagnostics ?? [delivery.reason ?? 'opencode_message_delivery_failed']), + ]; + logger.warn( + `[${teamName}] OpenCode inbox relay failed for ${memberName}/${message.messageId}: ${ + delivery.reason ?? 'unknown error' + }` + ); + break; + } + try { + await this.markInboxMessagesRead(teamName, memberName, [message]); + } catch (error) { + const diagnostic = `opencode_inbox_mark_read_failed_after_delivery: ${getErrorMessage( + error + )}`; + result.failed += 1; + result.lastDelivery = { + delivered: false, + reason: 'opencode_inbox_mark_read_failed_after_delivery', + diagnostics: [diagnostic], + }; + result.diagnostics = [...(result.diagnostics ?? []), diagnostic]; + logger.warn(`[${teamName}] ${diagnostic}`); + break; + } + result.delivered += 1; + result.relayed += 1; + } + + if (result.diagnostics?.length) { + result.diagnostics = [...new Set(result.diagnostics)]; + } + return result; + })(); + + this.openCodeMemberInboxRelayInFlight.set(relayKey, work); + try { + return await work; + } finally { + if (this.openCodeMemberInboxRelayInFlight.get(relayKey) === work) { + this.openCodeMemberInboxRelayInFlight.delete(relayKey); + } + } + } + /** * Relay unread inbox messages addressed to the team lead into the live lead process. * @@ -14286,13 +14691,22 @@ export class TeamProvisioningService { for (const part of content) { if (part.type !== 'tool_use' || typeof part.name !== 'string') continue; const isNativeSendMessage = part.name === 'SendMessage'; - const isTeamMessageSendTool = part.name === 'mcp__agent-teams__message_send'; - const isDirectCrossTeamSendTool = - part.name === 'mcp__agent-teams__cross_team_send' || part.name === 'cross_team_send'; - if (!isNativeSendMessage && !isTeamMessageSendTool && !isDirectCrossTeamSendTool) continue; const input = part.input; if (!input || typeof input !== 'object') continue; const inp = input as Record; + const isTeamMessageSendTool = isAgentTeamsToolUse({ + rawName: part.name, + canonicalName: 'message_send', + toolInput: inp, + currentTeamName: run.teamName, + }); + const isDirectCrossTeamSendTool = isAgentTeamsToolUse({ + rawName: part.name, + canonicalName: 'cross_team_send', + toolInput: inp, + currentTeamName: run.teamName, + }); + if (!isNativeSendMessage && !isTeamMessageSendTool && !isDirectCrossTeamSendTool) continue; if (isDirectCrossTeamSendTool) { const toTeam = typeof inp.toTeam === 'string' ? inp.toTeam.trim() : ''; @@ -14356,6 +14770,7 @@ export class TeamProvisioningService { const replyMeta = inferredReplyMeta; const timestamp = nowIso(); const messageId = `lead-sendmsg-${run.runId}-${Date.now()}`; + const taskRefs = teamToolTaskRefs(run.teamName, inp.taskRefs); void this.crossTeamSender({ fromTeam: run.teamName, @@ -14363,6 +14778,7 @@ export class TeamProvisioningService { toTeam: crossTeamRecipient.teamName, text: strippedCrossTeamContent, summary, + ...(taskRefs ? { taskRefs } : {}), messageId, timestamp, conversationId: crossTeamMeta?.conversationId ?? replyMeta?.conversationId, @@ -14402,6 +14818,7 @@ export class TeamProvisioningService { replyMeta?.replyToConversationId ?? crossTeamMeta?.conversationId ?? replyMeta?.conversationId, + ...(taskRefs ? { taskRefs } : {}), }; this.pushLiveLeadProcessMessage(run.teamName, msg); this.teamChangeEmitter?.({ @@ -15341,7 +15758,10 @@ export class TeamProvisioningService { if (msg.type === 'assistant') { const content = this.extractStreamContentBlocks(msg); - const hasCapturedVisibleSendMessage = this.hasCapturedVisibleSendMessage(content); + const hasCapturedVisibleSendMessage = this.hasCapturedVisibleSendMessage( + content, + run.teamName + ); const textParts = content .filter((part) => part.type === 'text' && typeof part.text === 'string') @@ -17639,6 +18059,11 @@ export class TeamProvisioningService { this.memberInboxRelayInFlight.delete(key); } } + for (const key of Array.from(this.openCodeMemberInboxRelayInFlight.keys())) { + if (key.startsWith(`opencode:${run.teamName}:`)) { + this.openCodeMemberInboxRelayInFlight.delete(key); + } + } for (const key of Array.from(this.relayedMemberInboxMessageIds.keys())) { if (key.startsWith(`${run.teamName}:`)) { this.relayedMemberInboxMessageIds.delete(key); @@ -20002,17 +20427,24 @@ export class TeamProvisioningService { const toolsList = await request('tools/list', {}); throwIfCancelled(); - const memberBriefingTool = (toolsList.tools ?? []).find( - (tool) => tool.name === 'member_briefing' + const availableTools = new Set((toolsList.tools ?? []).map((tool) => tool.name)); + const requiredTools = Array.from( + new Set([ + ...AGENT_TEAMS_TEAMMATE_OPERATIONAL_TOOL_NAMES, + 'lead_briefing', + 'runtime_bootstrap_checkin', + 'runtime_deliver_message', + 'runtime_task_event', + 'runtime_heartbeat', + ]) ); - if (!memberBriefingTool) { - throw new Error('agent-teams MCP started but tools/list did not include member_briefing'); - } - const leadBriefingTool = (toolsList.tools ?? []).find( - (tool) => tool.name === 'lead_briefing' - ); - if (!leadBriefingTool) { - throw new Error('agent-teams MCP started but tools/list did not include lead_briefing'); + const missingTools = requiredTools.filter((toolName) => !availableTools.has(toolName)); + if (missingTools.length > 0) { + throw new Error( + `agent-teams MCP started but tools/list did not include required tool(s): ${missingTools.join( + ', ' + )}` + ); } const memberBriefing = await request('tools/call', { @@ -20021,6 +20453,7 @@ export class TeamProvisioningService { claudeDir: fixture.claudeDir, teamName: fixture.teamName, memberName: fixture.memberName, + runtimeProvider: 'opencode', }, }); throwIfCancelled(); diff --git a/src/main/services/team/agentTeamsToolNames.ts b/src/main/services/team/agentTeamsToolNames.ts index 19457c80..82e25832 100644 --- a/src/main/services/team/agentTeamsToolNames.ts +++ b/src/main/services/team/agentTeamsToolNames.ts @@ -1,4 +1,9 @@ -const AGENT_TEAMS_PREFIXES = ['mcp__agent-teams__', 'mcp__agent_teams__'] as const; +const AGENT_TEAMS_PREFIXES = [ + 'mcp__agent-teams__', + 'mcp__agent_teams__', + 'agent-teams_', + 'agent_teams_', +] as const; const TASK_BOUNDARY_TOOL_NAMES = ['task_start', 'task_complete', 'task_set_status'] as const; const TASK_BOUNDARY_TOOL_SET = new Set(TASK_BOUNDARY_TOOL_NAMES); @@ -23,7 +28,7 @@ const TASK_BOUNDARY_TOOL_LINE_PATTERN = new RegExp( ); export function canonicalizeAgentTeamsToolName(rawName: string): string { - const normalized = rawName.replace(/^proxy_/, ''); + const normalized = rawName.trim().replace(/^proxy_/, ''); for (const prefix of AGENT_TEAMS_PREFIXES) { if (normalized.startsWith(prefix)) { @@ -34,6 +39,51 @@ export function canonicalizeAgentTeamsToolName(rawName: string): string { return normalized; } +export function isAgentTeamsToolName(rawName: string, canonicalName: string): boolean { + return canonicalizeAgentTeamsToolName(rawName).toLowerCase() === canonicalName.toLowerCase(); +} + +export function isAgentTeamsToolUse(input: { + rawName: string; + canonicalName: string; + toolInput?: Record; + currentTeamName?: string; +}): boolean { + const rawName = input.rawName.trim(); + const normalizedRawName = rawName.replace(/^proxy_/, ''); + const canonical = canonicalizeAgentTeamsToolName(rawName); + if (canonical.toLowerCase() !== input.canonicalName.toLowerCase()) { + return false; + } + + const hasKnownPrefix = AGENT_TEAMS_PREFIXES.some((prefix) => + normalizedRawName.startsWith(prefix) + ); + if (hasKnownPrefix) { + return true; + } + + if (input.canonicalName === 'message_send') { + return ( + typeof input.toolInput?.teamName === 'string' && + input.toolInput.teamName === input.currentTeamName && + typeof input.toolInput?.to === 'string' && + typeof input.toolInput?.text === 'string' + ); + } + + if (input.canonicalName === 'cross_team_send') { + return ( + typeof input.toolInput?.teamName === 'string' && + input.toolInput.teamName === input.currentTeamName && + typeof input.toolInput?.toTeam === 'string' && + typeof input.toolInput?.text === 'string' + ); + } + + return false; +} + export function isAgentTeamsTaskBoundaryToolName(rawName: string): boolean { return TASK_BOUNDARY_TOOL_SET.has(canonicalizeAgentTeamsToolName(rawName)); } diff --git a/src/main/services/team/opencode/bridge/OpenCodeReadinessBridge.ts b/src/main/services/team/opencode/bridge/OpenCodeReadinessBridge.ts index f022e09e..31c4fff5 100644 --- a/src/main/services/team/opencode/bridge/OpenCodeReadinessBridge.ts +++ b/src/main/services/team/opencode/bridge/OpenCodeReadinessBridge.ts @@ -3,10 +3,7 @@ import { buildOpenCodeProjectPathFingerprint, type OpenCodeProductionE2EEvidence, } from '../e2e/OpenCodeProductionE2EEvidence'; -import { - buildOpenCodeCanonicalMcpToolId, - REQUIRED_AGENT_TEAMS_RUNTIME_TOOLS, -} from '../mcp/OpenCodeMcpToolAvailability'; +import { REQUIRED_AGENT_TEAMS_APP_TOOL_IDS } from '../mcp/OpenCodeMcpToolAvailability'; import type { OpenCodeTeamRuntimeBridgePort } from '../../runtime/OpenCodeTeamRuntimeAdapter'; import type { @@ -164,9 +161,7 @@ export class OpenCodeReadinessBridge implements OpenCodeTeamRuntimeBridgePort { capabilitySnapshotId: input.runtime.capabilitySnapshotId, selectedModel: expectedModel, projectPathFingerprint, - requiredMcpTools: REQUIRED_AGENT_TEAMS_RUNTIME_TOOLS.map((tool) => - buildOpenCodeCanonicalMcpToolId('agent-teams', tool) - ), + requiredMcpTools: REQUIRED_AGENT_TEAMS_APP_TOOL_IDS, }, }) : { diff --git a/src/main/services/team/opencode/e2e/OpenCodeProductionE2EEvidence.ts b/src/main/services/team/opencode/e2e/OpenCodeProductionE2EEvidence.ts index 93a61e70..7c88adbf 100644 --- a/src/main/services/team/opencode/e2e/OpenCodeProductionE2EEvidence.ts +++ b/src/main/services/team/opencode/e2e/OpenCodeProductionE2EEvidence.ts @@ -52,8 +52,8 @@ export interface OpenCodeProductionE2EEvidence { projectPathFingerprint: string | null; requiredSignals: Record; mcpTools: { - requiredTools: string[]; - observedTools: string[]; + requiredTools: readonly string[]; + observedTools: readonly string[]; }; launch: { runId: string; @@ -100,7 +100,7 @@ export interface OpenCodeProductionE2EGateExpectation { */ selectedModel: string | null; projectPathFingerprint?: string | null; - requiredMcpTools?: string[]; + requiredMcpTools?: readonly string[]; } export interface OpenCodeProductionE2EGateResult { diff --git a/src/main/services/team/opencode/mcp/OpenCodeMcpToolAvailability.ts b/src/main/services/team/opencode/mcp/OpenCodeMcpToolAvailability.ts index 6c92e8d4..b2be19ef 100644 --- a/src/main/services/team/opencode/mcp/OpenCodeMcpToolAvailability.ts +++ b/src/main/services/team/opencode/mcp/OpenCodeMcpToolAvailability.ts @@ -1,11 +1,30 @@ -export const REQUIRED_AGENT_TEAMS_RUNTIME_TOOLS = [ +import * as agentTeamsControllerModule from 'agent-teams-controller'; + +export const REQUIRED_AGENT_TEAMS_RUNTIME_PROOF_TOOLS = [ 'runtime_bootstrap_checkin', 'runtime_deliver_message', 'runtime_task_event', 'runtime_heartbeat', ] as const; -export type RequiredAgentTeamsRuntimeTool = (typeof REQUIRED_AGENT_TEAMS_RUNTIME_TOOLS)[number]; +export const REQUIRED_AGENT_TEAMS_RUNTIME_TOOLS = REQUIRED_AGENT_TEAMS_RUNTIME_PROOF_TOOLS; + +export type RequiredAgentTeamsRuntimeTool = + (typeof REQUIRED_AGENT_TEAMS_RUNTIME_PROOF_TOOLS)[number]; + +export const REQUIRED_AGENT_TEAMS_TEAMMATE_OPERATIONAL_TOOLS: readonly string[] = [ + ...agentTeamsControllerModule.AGENT_TEAMS_TEAMMATE_OPERATIONAL_TOOL_NAMES, +]; + +export const REQUIRED_AGENT_TEAMS_APP_TOOLS: readonly string[] = [ + ...REQUIRED_AGENT_TEAMS_RUNTIME_PROOF_TOOLS, + ...REQUIRED_AGENT_TEAMS_TEAMMATE_OPERATIONAL_TOOLS, +]; + +export const REQUIRED_AGENT_TEAMS_APP_TOOL_IDS: readonly string[] = + REQUIRED_AGENT_TEAMS_APP_TOOLS.map((tool) => + buildOpenCodeCanonicalMcpToolId('agent-teams', tool) + ); export interface OpenCodeToolListItem { id: string; @@ -365,7 +384,7 @@ function mergeFailedToolProofs(input: { diagnostics: [ ...input.idsProof.diagnostics, ...input.definitionsProof.diagnostics, - 'OpenCode app-owned MCP server is connected but required runtime tools were not proven available', + 'OpenCode app-owned MCP server is connected but required app tools were not proven available', ], }; } diff --git a/src/main/services/team/opencode/store/OpenCodeRuntimeManifestEvidenceReader.ts b/src/main/services/team/opencode/store/OpenCodeRuntimeManifestEvidenceReader.ts index 7e096176..ea3b8c2d 100644 --- a/src/main/services/team/opencode/store/OpenCodeRuntimeManifestEvidenceReader.ts +++ b/src/main/services/team/opencode/store/OpenCodeRuntimeManifestEvidenceReader.ts @@ -6,7 +6,10 @@ import * as path from 'path'; import { withFileLock } from '../../fileLock'; -import { createRuntimeStoreManifestStore } from './RuntimeStoreManifest'; +import { + createDefaultRuntimeStoreManifest, + validateRuntimeStoreManifest, +} from './RuntimeStoreManifest'; import type { RuntimeStoreManifestEvidence } from '../bridge/OpenCodeBridgeCommandContract'; import type { RuntimeStoreManifestReader } from '../bridge/OpenCodeStateChangingBridgeCommandService'; @@ -168,11 +171,7 @@ export class OpenCodeRuntimeManifestEvidenceReader implements RuntimeStoreManife const manifestPath = normalizedLaneId ? await resolveOpenCodeRuntimeManifestReadPath(this.teamsBasePath, teamName, normalizedLaneId) : getOpenCodeRuntimeManifestPath(this.teamsBasePath, teamName); - const manifest = await createRuntimeStoreManifestStore({ - filePath: manifestPath, - teamName, - clock: this.clock, - }).read(); + const manifest = await readRuntimeStoreManifestEvidenceData(manifestPath, teamName, this.clock); return { highWatermark: manifest.highWatermark, @@ -182,6 +181,33 @@ export class OpenCodeRuntimeManifestEvidenceReader implements RuntimeStoreManife } } +async function readRuntimeStoreManifestEvidenceData( + manifestPath: string, + teamName: string, + clock: () => Date +) { + let raw: string; + try { + raw = await readFile(manifestPath, 'utf8'); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return createDefaultRuntimeStoreManifest(teamName, clock().toISOString()); + } + throw error; + } + + const parsed = JSON.parse(raw) as unknown; + const maybeRecord = + parsed && typeof parsed === 'object' && !Array.isArray(parsed) + ? (parsed as Record) + : null; + const manifestData = + maybeRecord && Object.prototype.hasOwnProperty.call(maybeRecord, 'data') + ? maybeRecord.data + : parsed; + return validateRuntimeStoreManifest(manifestData); +} + async function fileExists(filePath: string): Promise { try { await stat(filePath); diff --git a/src/main/services/team/runtime/OpenCodeTeamRuntimeAdapter.ts b/src/main/services/team/runtime/OpenCodeTeamRuntimeAdapter.ts index 208ba184..a9d13a24 100644 --- a/src/main/services/team/runtime/OpenCodeTeamRuntimeAdapter.ts +++ b/src/main/services/team/runtime/OpenCodeTeamRuntimeAdapter.ts @@ -1,5 +1,7 @@ import { randomUUID } from 'crypto'; +import type { AgentActionMode, TaskRef } from '@shared/types/team'; + import type { OpenCodeBridgeRuntimeSnapshot, OpenCodeLaunchTeamCommandBody, @@ -60,6 +62,9 @@ export interface OpenCodeTeamRuntimeMessageInput { cwd: string; text: string; messageId?: string; + replyRecipient?: string; + actionMode?: AgentActionMode; + taskRefs?: TaskRef[]; } export interface OpenCodeTeamRuntimeMessageResult { @@ -601,8 +606,9 @@ function buildMemberBootstrapPrompt( '', 'This OpenCode session is already attached by the desktop app. Do NOT create local team files, run join scripts, or search the project for a fake team registry.', 'Use the app MCP tools exposed by the "agent-teams" server for team communication and task state.', - 'If available, your first app-team action is to call MCP tool agent-teams_member_briefing (or mcp__agent-teams__member_briefing if that is the exposed name) with:', - `{ "teamName": "${input.teamName}", "memberName": "${member.name}" }`, + 'The desktop bridge may prepend runtime identity and bootstrap instructions. Follow those first.', + 'After runtime identity check-in, if you have not already done so, call MCP tool agent-teams_member_briefing (or mcp__agent-teams__member_briefing if that is the exposed name) with:', + `{ "teamName": "${input.teamName}", "memberName": "${member.name}", "runtimeProvider": "opencode" }`, 'If that tool is not available, stay idle and wait for app-delivered instructions. Do not improvise a replacement workflow.', '', 'When you need to message the human user, team lead, or another teammate, call MCP tool agent-teams_message_send (or mcp__agent-teams__message_send) with teamName, to, from, text, and optional summary.', @@ -614,18 +620,18 @@ function buildMemberBootstrapPrompt( } function buildOpenCodeRuntimeMessageText(input: OpenCodeTeamRuntimeMessageInput): string { - const replyRecipient = extractRequestedReplyRecipient(input.text); - const replyLine = replyRecipient - ? `For this message, if you reply, call agent-teams_message_send with to="${replyRecipient}" and from="${input.memberName}".` - : `If you reply, call agent-teams_message_send with the requested recipient and from="${input.memberName}".`; + const replyRecipient = input.replyRecipient?.trim() || 'user'; + const taskRefs = input.taskRefs?.length ? JSON.stringify(input.taskRefs) : null; return [ '', 'You are running in OpenCode, not Claude Code or Codex native.', - 'If the incoming message below mentions SendMessage, treat that as a UI abstraction for other runtimes. Do not import, require, create, or run a SendMessage script.', 'To make your reply visible in the app Messages UI, call MCP tool agent-teams_message_send (or mcp__agent-teams__message_send if that is the exposed name).', - `Use teamName="${input.teamName}". ${replyLine}`, - 'Pass your human-readable reply as text and a short summary as summary. Do not answer only with plain assistant text when the tool is available.', + `Use teamName="${input.teamName}", to="${replyRecipient}", from="${input.memberName}", text, and summary.`, + 'Do not answer only with plain assistant text when agent-teams_message_send is available.', + 'Do not use SendMessage or runtime_deliver_message for ordinary visible replies.', + input.actionMode ? `Action mode for this message: ${input.actionMode}.` : null, + taskRefs ? `If your reply is about these tasks, include taskRefs exactly: ${taskRefs}` : null, input.messageId ? `The inbound app messageId is "${input.messageId}"; keep it only as context unless a tool explicitly asks for provenance.` : null, @@ -637,18 +643,6 @@ function buildOpenCodeRuntimeMessageText(input: OpenCodeTeamRuntimeMessageInput) .join('\n'); } -function extractRequestedReplyRecipient(text: string): string | null { - const replyRecipientMatch = /reply back to recipient "([^"]+)"/i.exec(text); - if (replyRecipientMatch?.[1]?.trim()) { - return replyRecipientMatch[1].trim(); - } - const destinationMatch = /destination must be exactly to="([^"]+)"/i.exec(text); - if (destinationMatch?.[1]?.trim()) { - return destinationMatch[1].trim(); - } - return null; -} - function validateOpenCodeRuntimeMembers( members: TeamRuntimeLaunchInput['expectedMembers'] ): string[] { diff --git a/src/main/services/team/taskLogs/stream/BoardTaskLogStreamService.ts b/src/main/services/team/taskLogs/stream/BoardTaskLogStreamService.ts index 4f698b1d..a3a16c46 100644 --- a/src/main/services/team/taskLogs/stream/BoardTaskLogStreamService.ts +++ b/src/main/services/team/taskLogs/stream/BoardTaskLogStreamService.ts @@ -11,6 +11,7 @@ import { BoardTaskExactLogStrictParser } from '../exact/BoardTaskExactLogStrictP import { BoardTaskExactLogSummarySelector } from '../exact/BoardTaskExactLogSummarySelector'; import { isBoardTaskExactLogsReadEnabled } from '../exact/featureGates'; import { getBoardTaskExactLogFileVersions } from '../exact/fileVersions'; +import { canonicalizeAgentTeamsToolName } from '../../agentTeamsToolNames'; import { OpenCodeTaskLogStreamSource } from './OpenCodeTaskLogStreamSource'; @@ -57,7 +58,6 @@ interface StreamLayout { visibleSlices: StreamSlice[]; } -const BOARD_MCP_TOOL_PREFIXES = ['mcp__agent-teams__', 'mcp__agent_teams__'] as const; const INFERRED_WINDOW_GRACE_BEFORE_MS = 30_000; const INFERRED_WINDOW_GRACE_AFTER_MS = 15_000; const INFERRED_RECORD_RANGE_BEFORE_MS = 5 * 60_000; @@ -104,18 +104,17 @@ function normalizeMemberName(value: string): string { function isBoardMcpToolName(toolName: string | undefined): boolean { if (!toolName) return false; - const normalized = toolName.trim().toLowerCase(); - return BOARD_MCP_TOOL_PREFIXES.some((prefix) => normalized.startsWith(prefix)); + const canonical = canonicalizeBoardToolName(toolName); + return ( + canonical !== null && + (HISTORICAL_BOARD_LIFECYCLE_TOOL_NAMES.has(canonical) || + HISTORICAL_BOARD_ACTION_TOOL_NAMES.has(canonical)) + ); } function canonicalizeBoardToolName(toolName: string | undefined): string | null { if (!toolName) return null; - const normalized = toolName.trim().toLowerCase(); - for (const prefix of BOARD_MCP_TOOL_PREFIXES) { - if (normalized.startsWith(prefix)) { - return normalized.slice(prefix.length); - } - } + const normalized = canonicalizeAgentTeamsToolName(toolName).trim().toLowerCase(); return normalized.length > 0 ? normalized : null; } diff --git a/src/renderer/components/team/TeamDetailView.tsx b/src/renderer/components/team/TeamDetailView.tsx index 35185172..1a9cc2d8 100644 --- a/src/renderer/components/team/TeamDetailView.tsx +++ b/src/renderer/components/team/TeamDetailView.tsx @@ -1235,6 +1235,7 @@ export const TeamDetailView = ({ closeTab, sendingMessage, sendMessageError, + sendMessageWarning, lastSendMessageResult, reviewActionError, addMember, @@ -1284,6 +1285,7 @@ export const TeamDetailView = ({ closeTab: s.closeTab, sendingMessage: s.sendingMessage, sendMessageError: s.sendMessageError, + sendMessageWarning: s.sendMessageWarning, lastSendMessageResult: s.lastSendMessageResult, reviewActionError: s.reviewActionError, addMember: s.addMember, @@ -2963,21 +2965,24 @@ export const TeamDetailView = ({ isTeamAlive={data.isAlive} sending={sendingMessage} sendError={sendMessageError} + sendWarning={sendMessageWarning} lastResult={lastSendMessageResult} - onSend={(member, text, summary, attachments, actionMode, taskRefs) => { - void (async () => { - const sentAtMs = Date.now(); - setPendingRepliesByMember((prev) => ({ ...prev, [member]: sentAtMs })); - try { - await sendTeamMessage(teamName, { - member, - text, - summary, - attachments, - actionMode, - taskRefs, - }); - } catch { + onSend={async (member, text, summary, attachments, actionMode, taskRefs) => { + const sentAtMs = Date.now(); + setPendingRepliesByMember((prev) => ({ ...prev, [member]: sentAtMs })); + try { + const result = await sendTeamMessage(teamName, { + member, + text, + summary, + attachments, + actionMode, + taskRefs, + }); + if ( + result?.runtimeDelivery?.attempted === true && + result.runtimeDelivery.delivered === false + ) { setPendingRepliesByMember((prev) => { if (prev[member] !== sentAtMs) return prev; const next = { ...prev }; @@ -2985,7 +2990,16 @@ export const TeamDetailView = ({ return next; }); } - })(); + return result; + } catch (error) { + setPendingRepliesByMember((prev) => { + if (prev[member] !== sentAtMs) return prev; + const next = { ...prev }; + delete next[member]; + return next; + }); + throw error; + } }} onClose={() => { setSendDialogOpen(false); diff --git a/src/renderer/components/team/dialogs/CreateTeamDialog.tsx b/src/renderer/components/team/dialogs/CreateTeamDialog.tsx index b9307d2e..008363ea 100644 --- a/src/renderer/components/team/dialogs/CreateTeamDialog.tsx +++ b/src/renderer/components/team/dialogs/CreateTeamDialog.tsx @@ -22,6 +22,7 @@ import { buildMembersFromDrafts, clearMemberModelOverrides, createMemberDraft, + normalizeLeadProviderForMode, normalizeMemberDraftForProviderMode, normalizeProviderForMode, validateMemberNameInline, @@ -404,10 +405,11 @@ export const CreateTeamDialog = ({ }>({}); const [isSubmitting, setIsSubmitting] = useState(false); const [conflictDismissed, setConflictDismissed] = useState(false); - const [selectedProviderId, setSelectedProviderIdRaw] = - useState(getStoredTeamProvider); + const [selectedProviderId, setSelectedProviderIdRaw] = useState(() => + normalizeLeadProviderForMode(getStoredTeamProvider(), multimodelEnabled) + ); const [selectedModel, setSelectedModelRaw] = useState(() => - getStoredTeamModel(getStoredTeamProvider()) + getStoredTeamModel(normalizeLeadProviderForMode(getStoredTeamProvider(), multimodelEnabled)) ); const [limitContext, setLimitContextRaw] = useState(getStoredCreateTeamLimitContext); const [skipPermissions, setSkipPermissionsRaw] = useState(getStoredCreateTeamSkipPermissions); @@ -442,7 +444,7 @@ export const CreateTeamDialog = ({ }; const setSelectedProviderId = (value: TeamProviderId): void => { - const normalizedValue = normalizeProviderForMode(value, multimodelEnabled); + const normalizedValue = normalizeLeadProviderForMode(value, multimodelEnabled); setSelectedProviderIdRaw(normalizedValue); setStoredCreateTeamProvider(normalizedValue); if (normalizedValue !== 'anthropic') { diff --git a/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx b/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx index d2f64803..5d42245a 100644 --- a/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx +++ b/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx @@ -24,6 +24,7 @@ import { clearMemberModelOverrides, createMemberDraftsFromInputs, filterEditableMemberInputs, + normalizeLeadProviderForMode, normalizeMemberDraftForProviderMode, normalizeProviderForMode, validateMemberNameInline, @@ -129,6 +130,8 @@ import { import { computeEffectiveTeamModel, formatTeamModelSummary, + OPENCODE_TEAM_LEAD_DISABLED_BADGE_LABEL, + OPENCODE_TEAM_LEAD_DISABLED_REASON, TeamModelSelector, } from './TeamModelSelector'; @@ -375,10 +378,11 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen const [localError, setLocalError] = useState(null); const [isSubmitting, setIsSubmitting] = useState(false); - const [selectedProviderId, setSelectedProviderIdRaw] = - useState(getStoredTeamProvider); + const [selectedProviderId, setSelectedProviderIdRaw] = useState(() => + normalizeLeadProviderForMode(getStoredTeamProvider(), multimodelEnabled) + ); const [selectedModel, setSelectedModelRaw] = useState(() => - getStoredTeamModel(getStoredTeamProvider()) + getStoredTeamModel(normalizeLeadProviderForMode(getStoredTeamProvider(), multimodelEnabled)) ); const [membersDrafts, setMembersDrafts] = useState([]); const [teammateWorktreeDefault, setTeammateWorktreeDefault] = useState(false); @@ -572,7 +576,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen }; const setSelectedProviderId = (value: TeamProviderId): void => { - const normalizedValue = normalizeProviderForMode(value, multimodelEnabled); + const normalizedValue = normalizeLeadProviderForMode(value, multimodelEnabled); setSelectedProviderIdRaw(normalizedValue); localStorage.setItem('team:lastSelectedProvider', normalizedValue); if (normalizedValue !== 'anthropic') { @@ -685,14 +689,15 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen promptDraft.setValue(schedule.launchConfig.prompt); setCustomCwd(schedule.launchConfig.cwd); setCwdMode('custom'); - const scheduleProviderId = normalizeProviderForMode( + const scheduleProviderId = normalizeLeadProviderForMode( schedule.launchConfig.providerId, multimodelEnabled ); setSelectedProviderIdRaw(scheduleProviderId); setSelectedModelRaw( schedule.launchConfig.providerId !== 'gemini' && - scheduleProviderId === normalizeProviderForMode(schedule.launchConfig.providerId, true) + scheduleProviderId === + normalizeLeadProviderForMode(schedule.launchConfig.providerId, true) ? (schedule.launchConfig.model ?? '') : getStoredTeamModel('anthropic') ); @@ -713,7 +718,10 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen setCwdMode('project'); setSelectedProjectPath(''); setCustomCwd(''); - const storedProviderId = normalizeProviderForMode(getStoredTeamProvider(), multimodelEnabled); + const storedProviderId = normalizeLeadProviderForMode( + getStoredTeamProvider(), + multimodelEnabled + ); setSelectedProviderIdRaw(storedProviderId); setSelectedModelRaw(getStoredTeamModel(storedProviderId)); setSelectedEffortRaw('medium'); @@ -756,7 +764,10 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen savedRequest.providerBackendId.trim().length > 0 ? savedRequest.providerBackendId.trim() : null; - const storedProviderId = normalizeProviderForMode(getStoredTeamProvider(), multimodelEnabled); + const storedProviderId = normalizeLeadProviderForMode( + getStoredTeamProvider(), + multimodelEnabled + ); const launchPrefill = resolveLaunchDialogPrefill({ members, savedRequest, @@ -782,8 +793,12 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen setSyncModelsWithLead( !editableMembersSource.some((member) => member.providerId || member.model || member.effort) ); - setSelectedProviderIdRaw(launchPrefill.providerId); - setSelectedModelRaw(launchPrefill.model); + const leadProviderId = normalizeLeadProviderForMode( + launchPrefill.providerId, + multimodelEnabled + ); + setSelectedProviderIdRaw(leadProviderId); + setSelectedModelRaw(leadProviderId === launchPrefill.providerId ? launchPrefill.model : ''); setSelectedEffortRaw(launchPrefill.effort); setSelectedFastModeRaw(launchPrefill.fastMode); setLimitContextRaw(launchPrefill.limitContext); @@ -2445,6 +2460,12 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen onValueChange={setSelectedModel} id="dialog-model" disableGeminiOption={isGeminiUiFrozen()} + providerDisabledReasonById={{ + opencode: OPENCODE_TEAM_LEAD_DISABLED_REASON, + }} + providerDisabledBadgeLabelById={{ + opencode: OPENCODE_TEAM_LEAD_DISABLED_BADGE_LABEL, + }} /> void; + ) => void | Promise; onClose: () => void; } @@ -91,6 +92,7 @@ export const SendMessageDialog = ({ isTeamAlive, sending, sendError, + sendWarning, lastResult, onSend, onClose, @@ -263,17 +265,24 @@ export const SendMessageDialog = ({ const handleSubmit = (): void => { if (!canSend) return; const taskRefs = extractTaskRefsFromText(textDraft.value, taskSuggestions); - onSend( - member.trim(), - finalText, - trimmedText, - attachments.length > 0 ? attachments : undefined, - actionMode, - taskRefs - ); - textDraft.clearDraft(); - chipDraft.clearChipDraft(); - clearAttachments(); + void Promise.resolve( + onSend( + member.trim(), + finalText, + trimmedText, + attachments.length > 0 ? attachments : undefined, + actionMode, + taskRefs + ) + ) + .then(() => { + textDraft.clearDraft(); + chipDraft.clearChipDraft(); + clearAttachments(); + }) + .catch(() => { + // The store owns the visible send error; keep the draft intact for retry. + }); }; const handleOpenChange = (nextOpen: boolean): void => { @@ -532,6 +541,11 @@ export const SendMessageDialog = ({ {sendError} + ) : sendWarning ? ( + + + {sendWarning} + ) : null} {remaining < 200 ? ( void; id?: string; disableGeminiOption?: boolean; + providerDisabledReasonById?: Partial>; + providerDisabledBadgeLabelById?: Partial>; modelIssueReasonByValue?: Partial>; } @@ -152,6 +156,8 @@ export const TeamModelSelector: React.FC = ({ onValueChange, id, disableGeminiOption = false, + providerDisabledReasonById, + providerDisabledBadgeLabelById, modelIssueReasonByValue, }) => { const multimodelEnabled = useStore((s) => s.appConfig?.general?.multimodelEnabled ?? true); @@ -192,6 +198,13 @@ export const TeamModelSelector: React.FC = ({ return 'Uses the runtime default for the selected provider.'; }, [effectiveProviderId, runtimeProviderStatus]); const getProviderDisabledReason = (candidateProviderId: string): string | null => { + if (isTeamProviderId(candidateProviderId)) { + const overrideReason = providerDisabledReasonById?.[candidateProviderId]?.trim(); + if (overrideReason) { + return overrideReason; + } + } + if (candidateProviderId === 'opencode') { const providerStatus = runtimeProviderStatusById.get('opencode') ?? null; if (!providerStatus) { @@ -232,6 +245,14 @@ export const TeamModelSelector: React.FC = ({ (multimodelAvailable || candidateProviderId === 'anthropic'); const activeProviderSelectable = isProviderSelectable(effectiveProviderId); const getProviderStatusBadge = (candidateProviderId: string): string | null => { + if (isTeamProviderId(candidateProviderId)) { + const overrideReason = providerDisabledReasonById?.[candidateProviderId]?.trim(); + const overrideBadge = providerDisabledBadgeLabelById?.[candidateProviderId]?.trim(); + if (overrideReason && overrideBadge) { + return overrideBadge; + } + } + if (candidateProviderId === 'opencode') { return getProviderDisabledReason(candidateProviderId) ? 'Gated' : null; } diff --git a/src/renderer/components/team/members/LeadModelRow.test.tsx b/src/renderer/components/team/members/LeadModelRow.test.tsx index 24a40296..04ed479a 100644 --- a/src/renderer/components/team/members/LeadModelRow.test.tsx +++ b/src/renderer/components/team/members/LeadModelRow.test.tsx @@ -20,6 +20,8 @@ vi.mock('@renderer/components/team/dialogs/LimitContextCheckbox', () => ({ vi.mock('@renderer/components/team/dialogs/TeamModelSelector', () => ({ getProviderScopedTeamModelLabel: (_providerId: string, model: string) => model || 'Default', getTeamProviderLabel: (providerId: string) => providerId, + OPENCODE_TEAM_LEAD_DISABLED_BADGE_LABEL: 'not teamlead', + OPENCODE_TEAM_LEAD_DISABLED_REASON: 'OpenCode is not available for team lead.', TeamModelSelector: () => React.createElement('div', null, 'team-model-selector'), })); diff --git a/src/renderer/components/team/members/LeadModelRow.tsx b/src/renderer/components/team/members/LeadModelRow.tsx index bee30ba9..7e691c62 100644 --- a/src/renderer/components/team/members/LeadModelRow.tsx +++ b/src/renderer/components/team/members/LeadModelRow.tsx @@ -8,6 +8,8 @@ import { LimitContextCheckbox } from '@renderer/components/team/dialogs/LimitCon import { getProviderScopedTeamModelLabel, getTeamProviderLabel, + OPENCODE_TEAM_LEAD_DISABLED_BADGE_LABEL, + OPENCODE_TEAM_LEAD_DISABLED_REASON, TeamModelSelector, } from '@renderer/components/team/dialogs/TeamModelSelector'; import { Checkbox } from '@renderer/components/ui/checkbox'; @@ -159,6 +161,8 @@ export const LeadModelRow = ({ onValueChange={onModelChange} id="lead-model" disableGeminiOption={disableGeminiOption} + providerDisabledReasonById={{ opencode: OPENCODE_TEAM_LEAD_DISABLED_REASON }} + providerDisabledBadgeLabelById={{ opencode: OPENCODE_TEAM_LEAD_DISABLED_BADGE_LABEL }} modelIssueReasonByValue={model.trim() ? { [model.trim()]: modelIssueText } : undefined} /> ; @@ -78,6 +79,7 @@ export const MessageComposer = ({ isTeamAlive, sending, sendError, + sendWarning, lastResult, textareaRef: externalTextareaRef, onSend, @@ -482,6 +484,11 @@ export const MessageComposer = ({ {sendError} + ) : sendWarning ? ( + + + {sendWarning} + ) : lastResult?.deduplicated ? ( diff --git a/src/renderer/components/team/messages/MessagesPanel.tsx b/src/renderer/components/team/messages/MessagesPanel.tsx index 27992279..0e4895b9 100644 --- a/src/renderer/components/team/messages/MessagesPanel.tsx +++ b/src/renderer/components/team/messages/MessagesPanel.tsx @@ -190,6 +190,7 @@ export const MessagesPanel = memo(function MessagesPanel({ sendCrossTeamMessage, sendingMessage, sendMessageError, + sendMessageWarning, lastSendMessageResult, teams, openTeamTab, @@ -203,6 +204,7 @@ export const MessagesPanel = memo(function MessagesPanel({ sendCrossTeamMessage: s.sendCrossTeamMessage, sendingMessage: s.sendingMessage, sendMessageError: s.sendMessageError, + sendMessageWarning: s.sendMessageWarning, lastSendMessageResult: s.lastSendMessageResult, teams: s.teams, openTeamTab: s.openTeamTab, @@ -515,14 +517,28 @@ export const MessagesPanel = memo(function MessagesPanel({ attachments, actionMode, taskRefs, - }).catch(() => { - onPendingReplyChange((prev) => { - if (prev[member] !== sentAtMs) return prev; - const next = { ...prev }; - delete next[member]; - return next; + }) + .then((result) => { + if ( + result?.runtimeDelivery?.attempted === true && + result.runtimeDelivery.delivered === false + ) { + onPendingReplyChange((prev) => { + if (prev[member] !== sentAtMs) return prev; + const next = { ...prev }; + delete next[member]; + return next; + }); + } + }) + .catch(() => { + onPendingReplyChange((prev) => { + if (prev[member] !== sentAtMs) return prev; + const next = { ...prev }; + delete next[member]; + return next; + }); }); - }); }, [teamName, sendTeamMessage, onPendingReplyChange] ); @@ -670,6 +686,7 @@ export const MessagesPanel = memo(function MessagesPanel({ isTeamAlive={isTeamAlive} sending={sendingMessage} sendError={sendMessageError} + sendWarning={sendMessageWarning} lastResult={lastSendMessageResult} textareaRef={composerTextareaRef} onSend={handleSend} @@ -855,6 +872,7 @@ export const MessagesPanel = memo(function MessagesPanel({ isTeamAlive={isTeamAlive} sending={sendingMessage} sendError={sendMessageError} + sendWarning={sendMessageWarning} lastResult={lastSendMessageResult} textareaRef={composerTextareaRef} onSend={handleSend} @@ -1139,6 +1157,7 @@ export const MessagesPanel = memo(function MessagesPanel({ isTeamAlive={isTeamAlive} sending={sendingMessage} sendError={sendMessageError} + sendWarning={sendMessageWarning} lastResult={lastSendMessageResult} textareaRef={composerTextareaRef} onSend={handleSend} diff --git a/src/renderer/store/slices/teamSlice.ts b/src/renderer/store/slices/teamSlice.ts index a9f9e414..67192443 100644 --- a/src/renderer/store/slices/teamSlice.ts +++ b/src/renderer/store/slices/teamSlice.ts @@ -950,10 +950,13 @@ function areInboxMessageArraysEquivalent( leftItem.text !== rightItem.text || leftItem.summary !== rightItem.summary || leftItem.read !== rightItem.read || + leftItem.actionMode !== rightItem.actionMode || + leftItem.commentId !== rightItem.commentId || leftItem.relayOfMessageId !== rightItem.relayOfMessageId || leftItem.source !== rightItem.source || leftItem.leadSessionId !== rightItem.leadSessionId || - leftItem.messageKind !== rightItem.messageKind + leftItem.messageKind !== rightItem.messageKind || + JSON.stringify(leftItem.taskRefs ?? null) !== JSON.stringify(rightItem.taskRefs ?? null) ) { return false; } @@ -2008,6 +2011,7 @@ export interface TeamSlice { selectedTeamError: string | null; sendingMessage: boolean; sendMessageError: string | null; + sendMessageWarning: string | null; lastSendMessageResult: SendMessageResult | null; reviewActionError: string | null; provisioningRuns: Record; @@ -2091,7 +2095,7 @@ export interface TeamSlice { enabled: boolean, delayMs?: number ) => void; - sendTeamMessage: (teamName: string, request: SendMessageRequest) => Promise; + sendTeamMessage: (teamName: string, request: SendMessageRequest) => Promise; crossTeamTargets: { teamName: string; displayName: string; @@ -2341,6 +2345,7 @@ export const createTeamSlice: StateCreator = (set, selectedTeamError: null, sendingMessage: false, sendMessageError: null, + sendMessageWarning: null, lastSendMessageResult: null, crossTeamTargets: [], crossTeamTargetsLoading: false, @@ -3976,11 +3981,25 @@ export const createTeamSlice: StateCreator = (set, }, sendTeamMessage: async (teamName: string, request: SendMessageRequest) => { - set({ sendingMessage: true, sendMessageError: null, lastSendMessageResult: null }); + set({ + sendingMessage: true, + sendMessageError: null, + sendMessageWarning: null, + lastSendMessageResult: null, + }); try { const result = await unwrapIpc('team:sendMessage', () => api.teams.sendMessage(teamName, request) ); + const runtimeDeliveryFailed = + result.runtimeDelivery?.attempted === true && result.runtimeDelivery.delivered === false; + const runtimeDeliveryWarning = runtimeDeliveryFailed + ? `OpenCode runtime delivery failed: ${ + result.runtimeDelivery?.reason ?? + result.runtimeDelivery?.diagnostics?.[0] ?? + 'message was saved to inbox but not delivered live' + }` + : null; const optimisticMessage: InboxMessage = { from: request.from ?? 'user', to: request.to ?? request.member, @@ -3988,6 +4007,7 @@ export const createTeamSlice: StateCreator = (set, timestamp: request.timestamp ?? nowIso(), read: true, taskRefs: request.taskRefs?.length ? request.taskRefs : undefined, + actionMode: request.actionMode, summary: request.summary, color: request.color, messageId: result.messageId, @@ -4006,7 +4026,8 @@ export const createTeamSlice: StateCreator = (set, set((state) => ({ sendingMessage: false, sendMessageError: null, - lastSendMessageResult: result, + sendMessageWarning: runtimeDeliveryWarning, + lastSendMessageResult: runtimeDeliveryFailed ? null : result, teamMessagesByName: { ...state.teamMessagesByName, [teamName]: upsertOptimisticTeamMessage( @@ -4016,12 +4037,15 @@ export const createTeamSlice: StateCreator = (set, }, })); await get().refreshTeamMessagesHead(teamName); + return result; } catch (error) { set({ sendingMessage: false, lastSendMessageResult: null, + sendMessageWarning: null, sendMessageError: mapSendMessageError(error), }); + throw error; } }, @@ -4037,12 +4061,18 @@ export const createTeamSlice: StateCreator = (set, }, sendCrossTeamMessage: async (request: CrossTeamSendRequest) => { - set({ sendingMessage: true, sendMessageError: null, lastSendMessageResult: null }); + set({ + sendingMessage: true, + sendMessageError: null, + sendMessageWarning: null, + lastSendMessageResult: null, + }); try { const result = await api.crossTeam.send(request); set({ sendingMessage: false, sendMessageError: null, + sendMessageWarning: null, lastSendMessageResult: { messageId: result.messageId, deliveredToInbox: result.deliveredToInbox, @@ -4054,6 +4084,7 @@ export const createTeamSlice: StateCreator = (set, set({ sendingMessage: false, lastSendMessageResult: null, + sendMessageWarning: null, sendMessageError: mapSendMessageError(error), }); } diff --git a/src/shared/types/team.ts b/src/shared/types/team.ts index 65dc5f4a..67328426 100644 --- a/src/shared/types/team.ts +++ b/src/shared/types/team.ts @@ -587,6 +587,8 @@ export interface InboxMessage { timestamp: string; read: boolean; taskRefs?: TaskRef[]; + /** Durable delivery intent used by OpenCode inbox retry. */ + actionMode?: AgentActionMode; /** Authoritative task comment id attached by runtime-authored task notifications. */ commentId?: string; summary?: string; @@ -668,6 +670,13 @@ export interface SendMessageResult { deliveredViaStdin?: boolean; messageId: string; deduplicated?: boolean; + runtimeDelivery?: { + providerId: 'opencode'; + attempted: boolean; + delivered: boolean; + reason?: string; + diagnostics?: string[]; + }; } export interface AddTaskCommentRequest { diff --git a/src/types/agent-teams-controller.d.ts b/src/types/agent-teams-controller.d.ts index 0a56d45d..13c816f0 100644 --- a/src/types/agent-teams-controller.d.ts +++ b/src/types/agent-teams-controller.d.ts @@ -40,7 +40,10 @@ declare module 'agent-teams-controller' { setNeedsClarification(taskId: string, value: string | null): unknown; linkTask(taskId: string, targetId: string, linkType: string): unknown; unlinkTask(taskId: string, targetId: string, linkType: string): unknown; - memberBriefing(memberName: string): Promise; + memberBriefing( + memberName: string, + options?: { runtimeProvider?: 'native' | 'opencode' } + ): Promise; leadBriefing(): Promise; taskBriefing(memberName: string): Promise; } diff --git a/test/main/ipc/teams.test.ts b/test/main/ipc/teams.test.ts index 011edb99..0d38cf73 100644 --- a/test/main/ipc/teams.test.ts +++ b/test/main/ipc/teams.test.ts @@ -8,6 +8,7 @@ import type { BoardTaskExactLogSummariesResponse, InboxMessage, MessagesPage, + SendMessageResult, TeamViewSnapshot, TeamCreateRequest, TeamProvisioningProgress, @@ -230,6 +231,16 @@ describe('ipc teams handlers', () => { pushLiveLeadProcessMessage: vi.fn(), relayLeadInboxMessages: vi.fn(async () => 0), relayMemberInboxMessages: vi.fn(async () => 0), + isOpenCodeRuntimeRecipient: vi.fn(async () => false), + relayOpenCodeMemberInboxMessages: vi.fn(async () => ({ + relayed: 0, + attempted: 0, + delivered: 0, + failed: 0, + lastDelivery: undefined as + | { delivered: boolean; reason?: string; diagnostics?: string[] } + | undefined, + })), getLiveLeadProcessMessages: vi.fn(() => [] as InboxMessage[]), getCurrentLeadSessionId: vi.fn(() => null as string | null), getAliveTeams: vi.fn(() => ['my-team']), @@ -541,6 +552,94 @@ describe('ipc teams handlers', () => { expect(result.success).toBe(false); }); + it('stores base text and returns runtimeDelivery success for OpenCode teammate sends', async () => { + provisioningService.isOpenCodeRuntimeRecipient.mockResolvedValueOnce(true); + provisioningService.relayOpenCodeMemberInboxMessages.mockResolvedValueOnce({ + relayed: 1, + attempted: 1, + delivered: 1, + failed: 0, + lastDelivery: { delivered: true }, + }); + const sendHandler = handlers.get(TEAM_SEND_MESSAGE); + expect(sendHandler).toBeDefined(); + + const result = (await sendHandler!({} as never, 'my-team', { + member: 'bob', + text: 'Can you check this?', + actionMode: 'ask', + taskRefs: [{ teamName: 'my-team', taskId: 'task-1', displayId: 'abcd1234' }], + })) as { success: boolean; data?: SendMessageResult }; + + expect(result.success).toBe(true); + expect(service.sendMessage).toHaveBeenCalledWith( + 'my-team', + expect.objectContaining({ + member: 'bob', + text: 'Can you check this?', + }) + ); + expect(service.sendMessage).not.toHaveBeenCalledWith( + 'my-team', + expect.objectContaining({ + text: expect.stringContaining('SendMessage'), + }) + ); + expect(provisioningService.relayOpenCodeMemberInboxMessages).toHaveBeenCalledWith( + 'my-team', + 'bob', + expect.objectContaining({ + onlyMessageId: 'm1', + source: 'ui-send', + deliveryMetadata: expect.objectContaining({ + replyRecipient: 'user', + actionMode: 'ask', + taskRefs: [{ teamName: 'my-team', taskId: 'task-1', displayId: 'abcd1234' }], + }), + }) + ); + expect(result.data?.runtimeDelivery).toMatchObject({ + providerId: 'opencode', + attempted: true, + delivered: true, + }); + }); + + it('returns runtimeDelivery failure without hiding the persisted OpenCode message', async () => { + provisioningService.isOpenCodeRuntimeRecipient.mockResolvedValueOnce(true); + provisioningService.relayOpenCodeMemberInboxMessages.mockResolvedValueOnce({ + relayed: 0, + attempted: 1, + delivered: 0, + failed: 1, + lastDelivery: { + delivered: false, + reason: 'opencode_runtime_not_active', + diagnostics: ['opencode_runtime_not_active'], + }, + }); + const sendHandler = handlers.get(TEAM_SEND_MESSAGE); + expect(sendHandler).toBeDefined(); + + const result = (await sendHandler!({} as never, 'my-team', { + member: 'bob', + text: 'Ping bob', + })) as { success: boolean; data?: SendMessageResult }; + + expect(result.success).toBe(true); + expect(result.data?.deliveredToInbox).toBe(true); + expect(result.data?.runtimeDelivery).toMatchObject({ + providerId: 'opencode', + attempted: true, + delivered: false, + reason: 'opencode_runtime_not_active', + }); + expect(vi.mocked(console.warn).mock.calls[0]?.join(' ')).toContain( + 'OpenCode runtime delivery after sendMessage failed for teammate "bob"' + ); + vi.mocked(console.warn).mockClear(); + }); + it('passes hidden ask-mode instructions to a live lead without exposing them in stored text', async () => { const sendHandler = handlers.get(TEAM_SEND_MESSAGE); expect(sendHandler).toBeDefined(); diff --git a/test/main/services/team/CrossTeamService.test.ts b/test/main/services/team/CrossTeamService.test.ts index ca8ad6cb..4dd371e5 100644 --- a/test/main/services/team/CrossTeamService.test.ts +++ b/test/main/services/team/CrossTeamService.test.ts @@ -105,10 +105,10 @@ describe('CrossTeamService', () => { expect(teamName).toBe('team-b'); expect(req.member).toBe('team-lead'); expect(req.source).toBe(CROSS_TEAM_SOURCE); - expect(req.from).toBe('team-a.lead'); + expect(req.from).toBe('team-a.team-lead'); expect(req.text).toContain('Hello from team-a'); const prefix = parseCrossTeamPrefix(req.text); - expect(prefix?.from).toBe('team-a.lead'); + expect(prefix?.from).toBe('team-a.team-lead'); expect(prefix?.chainDepth).toBe(0); expect(prefix?.conversationId).toBeTruthy(); }); @@ -130,7 +130,7 @@ describe('CrossTeamService', () => { const raw = fs.readFileSync(sentMessagesPath, 'utf8'); const sentRows = JSON.parse(raw) as Array>; expect(sentRows).toHaveLength(1); - expect(sentRows[0]?.from).toBe('lead'); + expect(sentRows[0]?.from).toBe('team-lead'); expect(sentRows[0]?.source).toBe(CROSS_TEAM_SENT_SOURCE); expect(sentRows[0]?.to).toBe('team-b.team-lead'); expect(sentRows[0]?.text).toBe('Hello from team-a'); @@ -248,13 +248,38 @@ describe('CrossTeamService', () => { }); it('rejects when target not found', async () => { - configReader.getConfig.mockResolvedValue(null); + configReader.getConfig.mockImplementation(async (teamName: string) => + teamName === 'team-b' ? null : makeConfig() + ); await expect(service.send(makeRequest())).rejects.toThrow('Target team not found'); }); it('rejects when target is deleted', async () => { - configReader.getConfig.mockResolvedValue(makeConfig({ deletedAt: '2024-01-01T00:00:00Z' })); - await expect(service.send(makeRequest())).rejects.toThrow('Target team not found'); + configReader.getConfig.mockImplementation(async (teamName: string) => + teamName === 'to-be-deleted' + ? makeConfig({ name: 'to-be-deleted', deletedAt: '2024-01-01T00:00:00Z' }) + : makeConfig() + ); + await expect(service.send(makeRequest({ toTeam: 'to-be-deleted' }))).rejects.toThrow( + 'Target team not found' + ); + }); + + it('rejects unknown source fromMember', async () => { + await expect(service.send(makeRequest({ fromMember: 'researcher' }))).rejects.toThrow( + 'Unknown fromMember' + ); + }); + + it('rejects when source is deleted', async () => { + configReader.getConfig.mockImplementation(async (teamName: string) => + teamName === 'deleted-source' + ? makeConfig({ name: 'deleted-source', deletedAt: '2024-01-01T00:00:00Z' }) + : makeConfig() + ); + await expect(service.send(makeRequest({ fromTeam: 'deleted-source' }))).rejects.toThrow( + 'Source team not found' + ); }); it('rejects excessive chain depth', async () => { @@ -282,6 +307,11 @@ describe('CrossTeamService', () => { }); it('uses from format "team.member"', async () => { + configReader.getConfig.mockImplementation(async (teamName: string) => + teamName === 'alpha' + ? makeConfig({ name: 'alpha', members: [{ name: 'researcher' }] }) + : makeConfig() + ); await service.send(makeRequest({ fromTeam: 'alpha', fromMember: 'researcher' })); const [, req] = inboxWriter.sendMessage.mock.calls[0]; diff --git a/test/main/services/team/OpenCodeMcpToolAvailability.test.ts b/test/main/services/team/OpenCodeMcpToolAvailability.test.ts index 4c025d81..7e028a48 100644 --- a/test/main/services/team/OpenCodeMcpToolAvailability.test.ts +++ b/test/main/services/team/OpenCodeMcpToolAvailability.test.ts @@ -6,6 +6,10 @@ import { buildOpenCodeCanonicalMcpToolId, matchRequiredOpenCodeTools, OpenCodeMcpToolAvailabilityProbe, + REQUIRED_AGENT_TEAMS_APP_TOOL_IDS, + REQUIRED_AGENT_TEAMS_APP_TOOLS, + REQUIRED_AGENT_TEAMS_TEAMMATE_OPERATIONAL_TOOLS, + REQUIRED_AGENT_TEAMS_RUNTIME_PROOF_TOOLS, REQUIRED_AGENT_TEAMS_RUNTIME_TOOLS, sanitizeOpenCodeMcpToolPart, verifyAppMcpRuntimeToolContracts, @@ -21,6 +25,20 @@ describe('OpenCode MCP tool availability', () => { ); }); + it('loads launch-visible teammate-operational tools from the controller catalog', () => { + expect(REQUIRED_AGENT_TEAMS_TEAMMATE_OPERATIONAL_TOOLS).toContain('message_send'); + expect(REQUIRED_AGENT_TEAMS_TEAMMATE_OPERATIONAL_TOOLS).toContain('cross_team_send'); + expect(REQUIRED_AGENT_TEAMS_TEAMMATE_OPERATIONAL_TOOLS).toContain('task_start'); + expect(REQUIRED_AGENT_TEAMS_TEAMMATE_OPERATIONAL_TOOLS).not.toContain('lead_briefing'); + expect(REQUIRED_AGENT_TEAMS_APP_TOOLS).toEqual([ + ...REQUIRED_AGENT_TEAMS_RUNTIME_PROOF_TOOLS, + ...REQUIRED_AGENT_TEAMS_TEAMMATE_OPERATIONAL_TOOLS, + ]); + expect(REQUIRED_AGENT_TEAMS_APP_TOOL_IDS).toContain('agent-teams_message_send'); + expect(REQUIRED_AGENT_TEAMS_APP_TOOL_IDS).toContain('agent-teams_member_briefing'); + expect(REQUIRED_AGENT_TEAMS_APP_TOOL_IDS).toContain('agent-teams_cross_team_send'); + }); + it('fails production proof when only alias ids are observed', () => { const proof = matchRequiredOpenCodeTools({ route: '/experimental/tool/ids', @@ -117,7 +135,7 @@ describe('OpenCode MCP tool availability', () => { expect.arrayContaining(['runtime_deliver_message', 'runtime_task_event', 'runtime_heartbeat']) ); expect(proof.diagnostics).toContain( - 'OpenCode app-owned MCP server is connected but required runtime tools were not proven available' + 'OpenCode app-owned MCP server is connected but required app tools were not proven available' ); }); @@ -141,6 +159,16 @@ describe('OpenCode MCP tool availability', () => { }); }); + it('keeps runtime schema validation scoped to runtime proof tools', () => { + expect(APP_MCP_RUNTIME_TOOL_CONTRACTS.map((contract) => contract.name)).toEqual( + REQUIRED_AGENT_TEAMS_RUNTIME_PROOF_TOOLS + ); + expect(REQUIRED_AGENT_TEAMS_APP_TOOLS).toContain('message_send'); + expect(APP_MCP_RUNTIME_TOOL_CONTRACTS.map((contract) => contract.name)).not.toContain( + 'message_send' + ); + }); + it('fails direct app MCP preflight when delivery schema misses idempotencyKey', () => { const tools = APP_MCP_RUNTIME_TOOL_CONTRACTS.map((contract) => ({ name: contract.name, diff --git a/test/main/services/team/OpenCodeProductionE2EEvidence.test.ts b/test/main/services/team/OpenCodeProductionE2EEvidence.test.ts index d8c6159c..2069c9da 100644 --- a/test/main/services/team/OpenCodeProductionE2EEvidence.test.ts +++ b/test/main/services/team/OpenCodeProductionE2EEvidence.test.ts @@ -14,8 +14,7 @@ import { } from '../../../../src/main/services/team/opencode/e2e/OpenCodeProductionE2EEvidence'; import { OpenCodeProductionE2EEvidenceStore } from '../../../../src/main/services/team/opencode/e2e/OpenCodeProductionE2EEvidenceStore'; import { - buildOpenCodeCanonicalMcpToolId, - REQUIRED_AGENT_TEAMS_RUNTIME_TOOLS, + REQUIRED_AGENT_TEAMS_APP_TOOL_IDS, } from '../../../../src/main/services/team/opencode/mcp/OpenCodeMcpToolAvailability'; describe('OpenCodeProductionE2EEvidence', () => { @@ -45,7 +44,7 @@ describe('OpenCodeProductionE2EEvidence', () => { capabilitySnapshotId: 'cap-1', selectedModel: 'openai/gpt-5.4-mini', projectPathFingerprint: 'project-a', - requiredMcpTools: ['agent-teams_runtime_deliver_message'], + requiredMcpTools: REQUIRED_AGENT_TEAMS_APP_TOOL_IDS, }, }) ).toEqual({ @@ -54,6 +53,38 @@ describe('OpenCodeProductionE2EEvidence', () => { }); }); + it('rejects stale runtime-only evidence when production expects full app MCP tools', () => { + const runtimeOnlyToolIds = ['agent-teams_runtime_deliver_message']; + const evidence = passingEvidence({ + mcpTools: { + requiredTools: runtimeOnlyToolIds, + observedTools: runtimeOnlyToolIds, + }, + }); + + expect( + assertOpenCodeProductionE2EArtifactGate({ + evidence, + artifactPath: '/tmp/opencode-e2e', + now, + expected: { + opencodeVersion: '1.14.19', + binaryFingerprint: 'version:1.14.19', + capabilitySnapshotId: 'cap-1', + selectedModel: 'openai/gpt-5.4-mini', + projectPathFingerprint: 'project-a', + requiredMcpTools: REQUIRED_AGENT_TEAMS_APP_TOOL_IDS, + }, + }) + ).toMatchObject({ + ok: false, + diagnostics: expect.arrayContaining([ + expect.stringContaining('agent-teams_message_send'), + expect.stringContaining('agent-teams_member_briefing'), + ]), + }); + }); + it('fails closed for stale, mismatched or incomplete evidence', () => { const expired = passingEvidence({ expiresAt: '2026-04-21T11:59:59.000Z', @@ -308,9 +339,7 @@ function passingEvidence( ): OpenCodeProductionE2EEvidence { const createdAt = '2026-04-21T12:00:00.000Z'; const sessionId = 'session-1'; - const requiredToolIds = REQUIRED_AGENT_TEAMS_RUNTIME_TOOLS.map((tool) => - buildOpenCodeCanonicalMcpToolId('agent-teams', tool) - ); + const requiredToolIds = REQUIRED_AGENT_TEAMS_APP_TOOL_IDS; return { schemaVersion: 1, diff --git a/test/main/services/team/OpenCodeProductionGate.live.test.ts b/test/main/services/team/OpenCodeProductionGate.live.test.ts index 5a500817..f308ffc6 100644 --- a/test/main/services/team/OpenCodeProductionGate.live.test.ts +++ b/test/main/services/team/OpenCodeProductionGate.live.test.ts @@ -26,8 +26,7 @@ import { } from '../../../../src/main/services/team/opencode/e2e/OpenCodeProductionE2EEvidence'; import { OpenCodeProductionE2EEvidenceStore } from '../../../../src/main/services/team/opencode/e2e/OpenCodeProductionE2EEvidenceStore'; import { - buildOpenCodeCanonicalMcpToolId, - REQUIRED_AGENT_TEAMS_RUNTIME_TOOLS, + REQUIRED_AGENT_TEAMS_APP_TOOL_IDS, } from '../../../../src/main/services/team/opencode/mcp/OpenCodeMcpToolAvailability'; import { resolveAgentTeamsMcpLaunchSpec } from '../../../../src/main/services/team/TeamMcpConfigBuilder'; import { applyOpenCodeAutoUpdatePolicy } from '../../../../src/main/services/runtime/openCodeAutoUpdatePolicy'; @@ -225,6 +224,10 @@ liveDescribe('OpenCode production gate live e2e', () => { staleRunRejected, appMcpToolsVisible: readiness.requiredToolsPresent, }); + const missingObservedAppToolIds = REQUIRED_AGENT_TEAMS_APP_TOOL_IDS.filter( + (toolId) => !readiness.evidence.observedMcpTools.includes(toolId) + ); + expect(missingObservedAppToolIds).toEqual([]); const gate = assertOpenCodeProductionE2EArtifactGate({ evidence: candidate, artifactPath: candidate.artifactPath, @@ -234,9 +237,7 @@ liveDescribe('OpenCode production gate live e2e', () => { capabilitySnapshotId: finalRuntime.capabilitySnapshotId ?? null, selectedModel, projectPathFingerprint: buildOpenCodeProjectPathFingerprint(PROJECT_PATH), - requiredMcpTools: REQUIRED_AGENT_TEAMS_RUNTIME_TOOLS.map((tool) => - buildOpenCodeCanonicalMcpToolId('agent-teams', tool) - ), + requiredMcpTools: REQUIRED_AGENT_TEAMS_APP_TOOL_IDS, }, }); @@ -408,9 +409,7 @@ function buildCandidateEvidence(input: { stale_run_rejected: input.staleRunRejected, } as OpenCodeProductionE2EEvidence['requiredSignals'], mcpTools: { - requiredTools: REQUIRED_AGENT_TEAMS_RUNTIME_TOOLS.map((tool) => - buildOpenCodeCanonicalMcpToolId('agent-teams', tool) - ), + requiredTools: REQUIRED_AGENT_TEAMS_APP_TOOL_IDS, observedTools: input.readinessObservedTools, }, launch: { diff --git a/test/main/services/team/OpenCodeReadinessBridge.test.ts b/test/main/services/team/OpenCodeReadinessBridge.test.ts index 5aa66bbe..f8d77c2a 100644 --- a/test/main/services/team/OpenCodeReadinessBridge.test.ts +++ b/test/main/services/team/OpenCodeReadinessBridge.test.ts @@ -12,8 +12,7 @@ import { type OpenCodeProductionE2EEvidence, } from '../../../../src/main/services/team/opencode/e2e/OpenCodeProductionE2EEvidence'; import { - buildOpenCodeCanonicalMcpToolId, - REQUIRED_AGENT_TEAMS_RUNTIME_TOOLS, + REQUIRED_AGENT_TEAMS_APP_TOOL_IDS, } from '../../../../src/main/services/team/opencode/mcp/OpenCodeMcpToolAvailability'; import type { OpenCodeTeamLaunchReadiness } from '../../../../src/main/services/team/opencode/readiness/OpenCodeTeamLaunchReadiness'; @@ -373,7 +372,7 @@ function readiness( evidence: { capabilitiesReady: true, mcpToolProofRoute: '/experimental/tool/ids', - observedMcpTools: ['agent-teams_runtime_deliver_message'], + observedMcpTools: [...REQUIRED_AGENT_TEAMS_APP_TOOL_IDS], runtimeStoreReadinessReason: 'runtime_store_manifest_valid', }, ...overrides, @@ -385,9 +384,7 @@ function productionEvidence( ): OpenCodeProductionE2EEvidence { const createdAt = new Date().toISOString(); const sessionId = 'session-1'; - const requiredToolIds = REQUIRED_AGENT_TEAMS_RUNTIME_TOOLS.map((tool) => - buildOpenCodeCanonicalMcpToolId('agent-teams', tool) - ); + const requiredToolIds = REQUIRED_AGENT_TEAMS_APP_TOOL_IDS; return { schemaVersion: 1, evidenceId: 'e2e-1', diff --git a/test/main/services/team/OpenCodeSemanticMessaging.live.test.ts b/test/main/services/team/OpenCodeSemanticMessaging.live.test.ts new file mode 100644 index 00000000..817f8e69 --- /dev/null +++ b/test/main/services/team/OpenCodeSemanticMessaging.live.test.ts @@ -0,0 +1,553 @@ +import { constants as fsConstants, promises as fs } from 'fs'; +import * as os from 'os'; +import * as path from 'path'; + +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { OpenCodeBridgeCommandClient } from '../../../../src/main/services/team/opencode/bridge/OpenCodeBridgeCommandClient'; +import { + createOpenCodeBridgeCommandLeaseStore, + createOpenCodeBridgeCommandLedgerStore, +} from '../../../../src/main/services/team/opencode/bridge/OpenCodeBridgeCommandLedgerStore'; +import { + createOpenCodeBridgeClientIdentity, + OpenCodeBridgeCommandHandshakePort, +} from '../../../../src/main/services/team/opencode/bridge/OpenCodeBridgeHandshakeClient'; +import { OpenCodeReadinessBridge } from '../../../../src/main/services/team/opencode/bridge/OpenCodeReadinessBridge'; +import { OpenCodeStateChangingBridgeCommandService } from '../../../../src/main/services/team/opencode/bridge/OpenCodeStateChangingBridgeCommandService'; +import { readOpenCodeRuntimeLaneIndex } from '../../../../src/main/services/team/opencode/store/OpenCodeRuntimeManifestEvidenceReader'; +import { applyOpenCodeAutoUpdatePolicy } from '../../../../src/main/services/runtime/openCodeAutoUpdatePolicy'; +import { OpenCodeTeamRuntimeAdapter } from '../../../../src/main/services/team/runtime/OpenCodeTeamRuntimeAdapter'; +import { TeamRuntimeAdapterRegistry } from '../../../../src/main/services/team/runtime/TeamRuntimeAdapter'; +import { resolveAgentTeamsMcpLaunchSpec } from '../../../../src/main/services/team/TeamMcpConfigBuilder'; +import { TeamProvisioningService } from '../../../../src/main/services/team/TeamProvisioningService'; +import { + getClaudeBasePath, + getTeamsBasePath, + setClaudeBasePathOverride, +} from '../../../../src/main/utils/pathDecoder'; + +import type { OpenCodeBridgeCommandExecutor } from '../../../../src/main/services/team/opencode/bridge/OpenCodeStateChangingBridgeCommandService'; +import type { RuntimeStoreManifestEvidence } from '../../../../src/main/services/team/opencode/bridge/OpenCodeBridgeCommandContract'; +import type { RuntimeStoreManifestReader } from '../../../../src/main/services/team/opencode/bridge/OpenCodeStateChangingBridgeCommandService'; +import type { TeamProvisioningProgress } from '../../../../src/shared/types'; + +const liveDescribe = + process.env.OPENCODE_E2E === '1' && process.env.OPENCODE_E2E_SEMANTIC_MESSAGING === '1' + ? describe + : describe.skip; + +const PROJECT_PATH = process.env.OPENCODE_E2E_PROJECT_PATH?.trim() || process.cwd(); +const DEFAULT_ORCHESTRATOR_CLI = '/Users/belief/dev/projects/claude/agent_teams_orchestrator/cli'; +const DEFAULT_MODEL = 'opencode/big-pickle'; + +interface InboxMessage { + from?: string; + to?: string; + text?: string; + messageId?: string; + read?: boolean; +} + +liveDescribe('OpenCode semantic messaging live e2e', () => { + let tempDir: string; + let tempClaudeRoot: string; + + beforeEach(async () => { + tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'opencode-semantic-message-e2e-')); + tempClaudeRoot = path.join(tempDir, '.claude'); + await fs.mkdir(tempClaudeRoot, { recursive: true }); + setClaudeBasePathOverride(tempClaudeRoot); + }); + + afterEach(async () => { + setClaudeBasePathOverride(null); + await fs.rm(tempDir, { recursive: true, force: true }); + }); + + it( + 'delivers a desktop message to an OpenCode member and records the reply through agent-teams_message_send', + async () => { + const { bridgeClient, selectedModel, svc } = await createOpenCodeLiveHarness(tempDir); + + const teamName = `opencode-semantic-message-${Date.now()}`; + const memberName = 'bob'; + const expectedReply = `opencode-semantic-message-e2e-${Date.now()}`; + const progressEvents: TeamProvisioningProgress[] = []; + + try { + const { runId } = await svc.createTeam( + { + teamName, + cwd: PROJECT_PATH, + providerId: 'opencode', + model: selectedModel, + skipPermissions: true, + members: [ + { + name: memberName, + role: 'Developer', + providerId: 'opencode', + model: selectedModel, + }, + ], + }, + (progress) => { + progressEvents.push(progress); + } + ); + + expect(runId).toBeTruthy(); + const progressDump = progressEvents + .map((progress) => + [ + progress.state, + progress.message, + progress.messageSeverity, + progress.error, + progress.cliLogsTail, + ] + .filter(Boolean) + .join(' | ') + ) + .join('\n'); + expect( + progressEvents.some((progress) => + progress.message.includes('OpenCode team launch is ready') + ), + progressDump + ).toBe(true); + const runtimeSnapshot = await svc.getTeamAgentRuntimeSnapshot(teamName); + expect(runtimeSnapshot.members[memberName]).toMatchObject({ + alive: true, + runtimeModel: selectedModel, + }); + await expect(readOpenCodeRuntimeLaneIndex(getTeamsBasePath(), teamName)).resolves.toMatchObject({ + lanes: { + primary: { + state: 'active', + }, + }, + }); + + const delivery = await svc.deliverOpenCodeMemberMessage(teamName, { + memberName, + messageId: `ui-message-${Date.now()}`, + replyRecipient: 'user', + text: [ + `Reply to the app Messages UI with exactly: ${expectedReply}`, + 'Use agent-teams_message_send with to="user" and from="bob".', + 'Do not answer only as plain assistant text.', + ].join('\n'), + }); + + if (!delivery.delivered) { + throw new Error(`OpenCode runtime delivery failed: ${JSON.stringify(delivery, null, 2)}`); + } + + let reply: InboxMessage; + try { + reply = await waitForUserInboxReply(teamName, memberName, expectedReply, 90_000); + } catch (error) { + const transcript = await getRuntimeTranscript(bridgeClient, teamName, memberName); + throw new Error( + `${error instanceof Error ? error.message : String(error)}\nTranscript: ${JSON.stringify( + transcript, + null, + 2 + )}` + ); + } + expect(reply).toMatchObject({ + from: memberName, + to: 'user', + }); + expect(reply.text).toContain(expectedReply); + } finally { + svc.stopTeam(teamName); + await waitUntil(async () => { + const laneIndex = await readOpenCodeRuntimeLaneIndex(getTeamsBasePath(), teamName); + return Object.keys(laneIndex.lanes).length === 0; + }, 90_000).catch(() => undefined); + } + }, + 300_000 + ); + + it( + 'relays an OpenCode teammate message into another OpenCode member runtime and records the reply', + async () => { + const { bridgeClient, selectedModel, svc } = await createOpenCodeLiveHarness(tempDir); + + const teamName = `opencode-peer-message-${Date.now()}`; + const senderName = 'bob'; + const recipientName = 'jack'; + const peerToken = `opencode-peer-inbox-e2e-${Date.now()}`; + const replyToken = `opencode-peer-reply-e2e-${Date.now()}`; + const peerInstructionText = [ + `Peer relay token: ${peerToken}.`, + `${recipientName}, call agent-teams_message_send with teamName="${teamName}", to="user", from="${recipientName}", text exactly "${replyToken}", and summary "peer reply".`, + ].join(' '); + const progressEvents: TeamProvisioningProgress[] = []; + + try { + const { runId } = await svc.createTeam( + { + teamName, + cwd: PROJECT_PATH, + providerId: 'opencode', + model: selectedModel, + skipPermissions: true, + members: [ + { + name: senderName, + role: 'Developer', + providerId: 'opencode', + model: selectedModel, + }, + { + name: recipientName, + role: 'Developer', + providerId: 'opencode', + model: selectedModel, + }, + ], + }, + (progress) => { + progressEvents.push(progress); + } + ); + + expect(runId).toBeTruthy(); + const progressDump = progressEvents + .map((progress) => + [ + progress.state, + progress.message, + progress.messageSeverity, + progress.error, + progress.cliLogsTail, + ] + .filter(Boolean) + .join(' | ') + ) + .join('\n'); + expect( + progressEvents.some((progress) => + progress.message.includes('OpenCode team launch is ready') + ), + progressDump + ).toBe(true); + const runtimeSnapshot = await svc.getTeamAgentRuntimeSnapshot(teamName); + expect(runtimeSnapshot.members[senderName]).toMatchObject({ + alive: true, + runtimeModel: selectedModel, + }); + expect(runtimeSnapshot.members[recipientName]).toMatchObject({ + alive: true, + runtimeModel: selectedModel, + }); + + const senderDelivery = await svc.deliverOpenCodeMemberMessage(teamName, { + memberName: senderName, + messageId: `ui-peer-message-${Date.now()}`, + replyRecipient: recipientName, + text: [ + `Send ${recipientName} a team message by calling agent-teams_message_send exactly once.`, + `Set to="${recipientName}" and from="${senderName}".`, + 'Use this exact message text, with no extra text:', + peerInstructionText, + `Use agent-teams_message_send with to="${recipientName}" and from="${senderName}".`, + 'Do not reply to user instead of sending the team message.', + ].join('\n'), + }); + + if (!senderDelivery.delivered) { + throw new Error( + `OpenCode sender delivery failed: ${JSON.stringify(senderDelivery, null, 2)}` + ); + } + + let peerMessage: InboxMessage & { messageId: string }; + try { + peerMessage = await waitForMemberInboxMessage( + teamName, + recipientName, + senderName, + [peerToken, replyToken], + 90_000 + ); + } catch (error) { + const transcript = await getRuntimeTranscript(bridgeClient, teamName, senderName); + throw new Error( + `${error instanceof Error ? error.message : String(error)}\n${senderName} transcript: ${JSON.stringify( + transcript, + null, + 2 + )}` + ); + } + + const relay = await svc.relayOpenCodeMemberInboxMessages(teamName, recipientName, { + onlyMessageId: peerMessage.messageId, + source: 'manual', + deliveryMetadata: { + replyRecipient: 'user', + }, + }); + if (relay.delivered < 1) { + throw new Error(`OpenCode peer relay failed: ${JSON.stringify(relay, null, 2)}`); + } + + let reply: InboxMessage; + try { + reply = await waitForUserInboxReply(teamName, recipientName, replyToken, 120_000); + } catch (error) { + const [senderTranscript, recipientTranscript] = await Promise.all([ + getRuntimeTranscript(bridgeClient, teamName, senderName), + getRuntimeTranscript(bridgeClient, teamName, recipientName), + ]); + throw new Error( + `${error instanceof Error ? error.message : String(error)}\n${senderName} transcript: ${JSON.stringify( + senderTranscript, + null, + 2 + )}\n${recipientName} transcript: ${JSON.stringify(recipientTranscript, null, 2)}` + ); + } + expect(reply).toMatchObject({ + from: recipientName, + to: 'user', + }); + expect(reply.text).toContain(replyToken); + } finally { + svc.stopTeam(teamName); + await waitUntil(async () => { + const laneIndex = await readOpenCodeRuntimeLaneIndex(getTeamsBasePath(), teamName); + return Object.keys(laneIndex.lanes).length === 0; + }, 90_000).catch(() => undefined); + } + }, + 360_000 + ); +}); + +async function waitForUserInboxReply( + teamName: string, + from: string, + expectedText: string, + timeoutMs: number +): Promise { + const deadline = Date.now() + timeoutMs; + const inboxPath = path.join(getTeamsBasePath(), teamName, 'inboxes', 'user.json'); + let lastMessages: InboxMessage[] = []; + + while (Date.now() < deadline) { + lastMessages = await readInboxMessages(inboxPath); + const match = lastMessages.find( + (message) => + message.from === from && + message.to === 'user' && + typeof message.text === 'string' && + message.text.includes(expectedText) + ); + if (match) { + return match; + } + await new Promise((resolve) => setTimeout(resolve, 1_500)); + } + + throw new Error( + `Timed out waiting for OpenCode reply in ${inboxPath}. Last messages: ${JSON.stringify( + lastMessages, + null, + 2 + )}` + ); +} + +async function waitForMemberInboxMessage( + teamName: string, + memberName: string, + from: string, + expectedText: string | string[], + timeoutMs: number +): Promise { + const deadline = Date.now() + timeoutMs; + const inboxPath = path.join(getTeamsBasePath(), teamName, 'inboxes', `${memberName}.json`); + let lastMessages: InboxMessage[] = []; + const expectedTexts = Array.isArray(expectedText) ? expectedText : [expectedText]; + + while (Date.now() < deadline) { + lastMessages = await readInboxMessages(inboxPath); + const match = lastMessages.find( + (message): message is InboxMessage & { messageId: string; text: string } => { + if (message.from !== from || message.to !== memberName) return false; + if (typeof message.messageId !== 'string' || !message.messageId.trim()) return false; + const text = message.text; + if (typeof text !== 'string') return false; + return expectedTexts.every((expected) => text.includes(expected)); + } + ); + if (match) { + return match; + } + await new Promise((resolve) => setTimeout(resolve, 1_500)); + } + + throw new Error( + `Timed out waiting for OpenCode member message in ${inboxPath}. Last messages: ${JSON.stringify( + lastMessages, + null, + 2 + )}` + ); +} + +async function readInboxMessages(inboxPath: string): Promise { + try { + const parsed = JSON.parse(await fs.readFile(inboxPath, 'utf8')); + return Array.isArray(parsed) ? (parsed as InboxMessage[]) : []; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return []; + } + throw error; + } +} + +async function waitUntil( + predicate: () => Promise, + timeoutMs: number, + pollMs = 500 +): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (await predicate()) { + return; + } + await new Promise((resolve) => setTimeout(resolve, pollMs)); + } + throw new Error(`Timed out after ${timeoutMs}ms waiting for condition`); +} + +async function createOpenCodeLiveHarness(tempDir: string): Promise<{ + bridgeClient: OpenCodeBridgeCommandClient; + selectedModel: string; + svc: TeamProvisioningService; +}> { + const selectedModel = process.env.OPENCODE_E2E_MODEL?.trim() || DEFAULT_MODEL; + const orchestratorCli = + process.env.CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH?.trim() || DEFAULT_ORCHESTRATOR_CLI; + await assertExecutable(orchestratorCli); + + const mcpLaunchSpec = await resolveAgentTeamsMcpLaunchSpec(); + const bridgeEnv = { + ...createStableBridgeEnv(), + PATH: withBunOnPath(process.env.PATH ?? ''), + XDG_DATA_HOME: path.join(tempDir, 'xdg-data'), + AGENT_TEAMS_MCP_CLAUDE_DIR: getClaudeBasePath(), + CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_COMMAND: mcpLaunchSpec.command, + CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_ENTRY: mcpLaunchSpec.args[0] ?? '', + }; + const bridgeClient = new OpenCodeBridgeCommandClient({ + binaryPath: orchestratorCli, + tempDirectory: path.join(tempDir, 'bridge-input'), + env: bridgeEnv, + }); + const stateChangingCommands = createStateChangingCommands({ + bridge: bridgeClient, + controlDir: path.join(tempDir, 'control'), + }); + const readinessBridge = new OpenCodeReadinessBridge(bridgeClient, { + stateChangingCommands, + timeoutMs: 180_000, + launchTimeoutMs: 180_000, + reconcileTimeoutMs: 90_000, + stopTimeoutMs: 90_000, + }); + const adapter = new OpenCodeTeamRuntimeAdapter(readinessBridge, { + launchMode: 'dogfood', + }); + const svc = new TeamProvisioningService(); + svc.setRuntimeAdapterRegistry(new TeamRuntimeAdapterRegistry([adapter])); + return { bridgeClient, selectedModel, svc }; +} + +async function getRuntimeTranscript( + bridgeClient: OpenCodeBridgeCommandClient, + teamName: string, + memberName: string +): Promise { + return bridgeClient + .execute< + { teamId: string; teamName: string; laneId: string; memberName: string }, + { logProjection?: { messages?: unknown[] }; messages?: unknown[] } + >( + 'opencode.getRuntimeTranscript', + { teamId: teamName, teamName, laneId: 'primary', memberName }, + { cwd: PROJECT_PATH, timeoutMs: 60_000 } + ) + .catch((transcriptError) => ({ + ok: false as const, + error: String(transcriptError), + })); +} + +function createStateChangingCommands(input: { + bridge: OpenCodeBridgeCommandExecutor; + controlDir: string; +}): OpenCodeStateChangingBridgeCommandService { + const clientIdentity = createOpenCodeBridgeClientIdentity({ + appVersion: '1.3.0-e2e', + gitSha: null, + buildId: 'opencode-semantic-message-e2e', + }); + + return new OpenCodeStateChangingBridgeCommandService({ + expectedClientIdentity: clientIdentity, + handshakePort: new OpenCodeBridgeCommandHandshakePort({ + bridge: input.bridge, + clientIdentity, + }), + leaseStore: createOpenCodeBridgeCommandLeaseStore({ + filePath: path.join(input.controlDir, 'leases.json'), + }), + ledger: createOpenCodeBridgeCommandLedgerStore({ + filePath: path.join(input.controlDir, 'ledger.json'), + }), + bridge: input.bridge, + manifestReader: new StaticManifestReader(), + }); +} + +class StaticManifestReader implements RuntimeStoreManifestReader { + async read(): Promise { + return { + highWatermark: 0, + activeRunId: null, + capabilitySnapshotId: null, + }; + } +} + +async function assertExecutable(filePath: string): Promise { + await fs.access(filePath, fsConstants.X_OK); +} + +function withBunOnPath(pathValue: string): string { + const bunDir = '/Users/belief/.bun/bin'; + return pathValue.split(path.delimiter).includes(bunDir) + ? pathValue + : `${bunDir}${path.delimiter}${pathValue}`; +} + +function createStableBridgeEnv(): NodeJS.ProcessEnv { + const realHome = os.userInfo().homedir; + const env = applyOpenCodeAutoUpdatePolicy({ ...process.env }); + return { + ...env, + HOME: realHome, + USERPROFILE: realHome, + }; +} diff --git a/test/main/services/team/OpenCodeTeamRuntimeAdapter.test.ts b/test/main/services/team/OpenCodeTeamRuntimeAdapter.test.ts index 8f334b95..e8e4cf51 100644 --- a/test/main/services/team/OpenCodeTeamRuntimeAdapter.test.ts +++ b/test/main/services/team/OpenCodeTeamRuntimeAdapter.test.ts @@ -9,6 +9,7 @@ import { import type { OpenCodeTeamLaunchReadiness } from '../../../../src/main/services/team/opencode/readiness/OpenCodeTeamLaunchReadiness'; import type { OpenCodeLaunchTeamCommandData } from '../../../../src/main/services/team/opencode/bridge/OpenCodeBridgeCommandContract'; import type { PersistedTeamLaunchSnapshot } from '../../../../src/shared/types'; +import { REQUIRED_AGENT_TEAMS_APP_TOOL_IDS } from '../../../../src/main/services/team/opencode/mcp/OpenCodeMcpToolAvailability'; describe('OpenCodeTeamRuntimeAdapter', () => { it('maps readiness failures to a structured prepare block', async () => { @@ -344,6 +345,9 @@ describe('OpenCodeTeamRuntimeAdapter', () => { cwd: '/repo', text: 'hello bob', messageId: 'msg-1', + replyRecipient: 'alice', + actionMode: 'delegate', + taskRefs: [{ taskId: 'task-1', displayId: 'abcd1234', teamName: 'team-a' }], }) ).resolves.toEqual({ ok: true, @@ -366,7 +370,42 @@ describe('OpenCodeTeamRuntimeAdapter', () => { }); const sentText = sendOpenCodeTeamMessage.mock.calls[0]?.[0]?.text ?? ''; expect(sentText).toContain('hello bob'); - expect(sentText).toContain('Do not import, require, create, or run a SendMessage script'); + expect(sentText).toContain('Use teamName="team-a", to="alice", from="bob", text, and summary.'); + expect(sentText).toContain('Action mode for this message: delegate.'); + expect(sentText).toContain( + 'If your reply is about these tasks, include taskRefs exactly: [{"taskId":"task-1","displayId":"abcd1234","teamName":"team-a"}]' + ); + expect(sentText).toContain('Do not use SendMessage or runtime_deliver_message'); + }); + + it('does not parse legacy native SendMessage wording to infer OpenCode reply recipient', async () => { + const sendOpenCodeTeamMessage = vi.fn< + NonNullable + >(async () => ({ + accepted: true, + sessionId: 'oc-session-bob', + memberName: 'bob', + diagnostics: [], + })); + const adapter = new OpenCodeTeamRuntimeAdapter( + bridgePort(readiness({ state: 'ready', launchAllowed: true }), { + sendOpenCodeTeamMessage, + }) + ); + + await adapter.sendMessageToMember({ + runId: 'run-1', + teamName: 'team-a', + laneId: 'secondary:opencode:bob', + memberName: 'bob', + cwd: '/repo', + text: 'CRITICAL: The destination must be exactly to="alice". Please reply back to recipient "alice".', + messageId: 'msg-legacy-native', + }); + + const sentText = sendOpenCodeTeamMessage.mock.calls[0]?.[0]?.text ?? ''; + expect(sentText).toContain('Use teamName="team-a", to="user", from="bob", text, and summary.'); + expect(sentText).not.toContain('Use teamName="team-a", to="alice", from="bob", text, and summary.'); }); it('keeps missing bridge members pending while reconcile is still launching', async () => { @@ -640,7 +679,7 @@ function readiness( evidence: { capabilitiesReady: true, mcpToolProofRoute: '/experimental/tool/ids', - observedMcpTools: ['agent-teams_runtime_deliver_message'], + observedMcpTools: [...REQUIRED_AGENT_TEAMS_APP_TOOL_IDS], runtimeStoreReadinessReason: 'runtime_store_manifest_valid', }, ...overrides, diff --git a/test/main/services/team/TeamDataService.test.ts b/test/main/services/team/TeamDataService.test.ts index e5aeae7b..256c2c17 100644 --- a/test/main/services/team/TeamDataService.test.ts +++ b/test/main/services/team/TeamDataService.test.ts @@ -1441,6 +1441,8 @@ describe('TeamDataService', () => { member: 'alice', text: 'hello', summary: 'ping', + actionMode: 'ask', + commentId: 'comment-1', }); expect(result).toEqual({ deliveredToInbox: true, messageId: 'm-1' }); @@ -1449,6 +1451,8 @@ describe('TeamDataService', () => { member: 'alice', text: 'hello', summary: 'ping', + actionMode: 'ask', + commentId: 'comment-1', leadSessionId: 'lead-1', }) ); diff --git a/test/main/services/team/TeamProvisioningService.test.ts b/test/main/services/team/TeamProvisioningService.test.ts index b826b4fa..fff4b20c 100644 --- a/test/main/services/team/TeamProvisioningService.test.ts +++ b/test/main/services/team/TeamProvisioningService.test.ts @@ -130,9 +130,11 @@ import { createPersistedLaunchSnapshot } from '@main/services/team/TeamLaunchSta import { getTeamLaunchStatePath } from '@main/services/team/TeamLaunchStateStore'; import { getOpenCodeLaneScopedRuntimeFilePath, + getOpenCodeRuntimeManifestPath, readOpenCodeRuntimeLaneIndex, upsertOpenCodeRuntimeLaneIndexEntry, } from '@main/services/team/opencode/store/OpenCodeRuntimeManifestEvidenceReader'; +import { createDefaultRuntimeStoreManifest } from '@main/services/team/opencode/store/RuntimeStoreManifest'; import { ClaudeBinaryResolver } from '@main/services/team/ClaudeBinaryResolver'; import { TeamRuntimeAdapterRegistry } from '@main/services/team/runtime/TeamRuntimeAdapter'; import { spawnCli } from '@main/utils/childProcess'; @@ -2551,7 +2553,7 @@ describe('TeamProvisioningService', () => { ); }); - it('delivers direct messages to OpenCode secondary lanes through the runtime adapter', async () => { + it('delivers direct messages to OpenCode secondary lanes with the lane run id', async () => { const svc = new TeamProvisioningService(); const sendMessageToMember = vi.fn(async (input: Record) => ({ ok: true, @@ -2575,6 +2577,14 @@ describe('TeamProvisioningService', () => { (svc as any).getTrackedRunId = vi.fn(() => 'run-1'); (svc as any).provisioningRunByTeam.set('team-a', 'run-1'); + (svc as any).setSecondaryRuntimeRun({ + teamName: 'team-a', + runId: 'opencode-run-bob', + providerId: 'opencode', + laneId: 'secondary:opencode:bob', + memberName: 'bob', + cwd: '/repo', + }); (svc as any).configReader = { getConfig: vi.fn(async () => ({ projectPath: '/repo', @@ -2611,7 +2621,7 @@ describe('TeamProvisioningService', () => { diagnostics: [], }); expect(sendMessageToMember).toHaveBeenCalledWith({ - runId: 'run-1', + runId: 'opencode-run-bob', teamName: 'team-a', laneId: 'secondary:opencode:bob', memberName: 'bob', @@ -2621,6 +2631,192 @@ describe('TeamProvisioningService', () => { }); }); + it('uses lane-scoped manifest activeRunId for OpenCode member delivery after restart', async () => { + const svc = new TeamProvisioningService(); + const teamName = 'team-a'; + const laneId = 'secondary:opencode:bob'; + const sendMessageToMember = vi.fn(async (input: Record) => ({ + ok: true, + providerId: 'opencode', + memberName: String(input.memberName), + sessionId: 'oc-session-bob', + diagnostics: [], + })); + const registry = new TeamRuntimeAdapterRegistry([ + { + providerId: 'opencode', + prepare: vi.fn(), + launch: vi.fn(), + reconcile: vi.fn(), + stop: vi.fn(), + sendMessageToMember, + } as any, + ]); + svc.setRuntimeAdapterRegistry(registry); + + (svc as any).configReader = { + getConfig: vi.fn(async () => ({ + projectPath: '/repo', + members: [ + { name: 'team-lead', providerId: 'codex', model: 'gpt-5.4' }, + { name: 'bob', providerId: 'opencode', model: 'minimax-m2.5-free' }, + ], + })), + }; + (svc as any).teamMetaStore = { + getMeta: vi.fn(async () => ({ + launchIdentity: { providerId: 'codex' }, + providerId: 'codex', + })), + }; + (svc as any).membersMetaStore = { + getMembers: vi.fn(async () => [ + { + name: 'bob', + providerId: 'opencode', + model: 'opencode/minimax-m2.5-free', + }, + ]), + }; + await upsertOpenCodeRuntimeLaneIndexEntry({ + teamsBasePath: tempTeamsBase, + teamName, + laneId, + state: 'active', + }); + const manifestPath = getOpenCodeRuntimeManifestPath(tempTeamsBase, teamName, laneId); + await fsPromises.mkdir(path.dirname(manifestPath), { recursive: true }); + await fsPromises.writeFile( + manifestPath, + `${JSON.stringify( + { + ...createDefaultRuntimeStoreManifest(teamName, '2026-04-22T12:00:00.000Z'), + activeRunId: 'opencode-run-durable', + }, + null, + 2 + )}\n`, + 'utf8' + ); + + await expect( + svc.deliverOpenCodeMemberMessage(teamName, { + memberName: 'bob', + text: 'hello after restart', + messageId: 'msg-after-restart', + }) + ).resolves.toEqual({ + delivered: true, + diagnostics: [], + }); + expect(sendMessageToMember).toHaveBeenCalledWith( + expect.objectContaining({ + runId: 'opencode-run-durable', + teamName, + laneId, + memberName: 'bob', + cwd: '/repo', + text: 'hello after restart', + messageId: 'msg-after-restart', + }) + ); + }); + + it('falls back to lane manifest when a tracked primary run lacks the secondary lane snapshot', async () => { + const svc = new TeamProvisioningService(); + const teamName = 'team-a'; + const laneId = 'secondary:opencode:bob'; + const sendMessageToMember = vi.fn(async (input: Record) => ({ + ok: true, + providerId: 'opencode', + memberName: String(input.memberName), + sessionId: 'oc-session-bob', + diagnostics: [], + })); + const registry = new TeamRuntimeAdapterRegistry([ + { + providerId: 'opencode', + prepare: vi.fn(), + launch: vi.fn(), + reconcile: vi.fn(), + stop: vi.fn(), + sendMessageToMember, + } as any, + ]); + svc.setRuntimeAdapterRegistry(registry); + + (svc as any).resolveDeliverableTrackedRuntimeRunId = vi.fn(() => 'run-1'); + (svc as any).runs.set('run-1', { + mixedSecondaryLanes: [], + }); + (svc as any).configReader = { + getConfig: vi.fn(async () => ({ + projectPath: '/repo', + members: [ + { name: 'team-lead', providerId: 'codex', model: 'gpt-5.4' }, + { name: 'bob', providerId: 'opencode', model: 'minimax-m2.5-free' }, + ], + })), + }; + (svc as any).teamMetaStore = { + getMeta: vi.fn(async () => ({ + launchIdentity: { providerId: 'codex' }, + providerId: 'codex', + })), + }; + (svc as any).membersMetaStore = { + getMembers: vi.fn(async () => [ + { + name: 'bob', + providerId: 'opencode', + model: 'opencode/minimax-m2.5-free', + }, + ]), + }; + await upsertOpenCodeRuntimeLaneIndexEntry({ + teamsBasePath: tempTeamsBase, + teamName, + laneId, + state: 'active', + }); + const manifestPath = getOpenCodeRuntimeManifestPath(tempTeamsBase, teamName, laneId); + await fsPromises.mkdir(path.dirname(manifestPath), { recursive: true }); + await fsPromises.writeFile( + manifestPath, + `${JSON.stringify( + { + ...createDefaultRuntimeStoreManifest(teamName, '2026-04-22T12:00:00.000Z'), + activeRunId: 'opencode-run-from-manifest', + }, + null, + 2 + )}\n`, + 'utf8' + ); + + await expect( + svc.deliverOpenCodeMemberMessage(teamName, { + memberName: 'bob', + text: 'hello via manifest fallback', + messageId: 'msg-manifest-fallback', + }) + ).resolves.toEqual({ + delivered: true, + diagnostics: [], + }); + expect(sendMessageToMember).toHaveBeenCalledWith( + expect.objectContaining({ + runId: 'opencode-run-from-manifest', + teamName, + laneId, + memberName: 'bob', + cwd: '/repo', + text: 'hello via manifest fallback', + messageId: 'msg-manifest-fallback', + }) + ); + }); + it('marks an OpenCode secondary lane degraded when readiness fails before runtime materializes', async () => { const teamName = 'mixed-prelaunch-failure'; const svc = new TeamProvisioningService(); @@ -3115,6 +3311,94 @@ describe('TeamProvisioningService', () => { }); }); + it('maps runtime delivery local data.detail to public TeamChangeEvent.detail', async () => { + const svc = new TeamProvisioningService(); + const emitted: Array> = []; + const delivered = new Map< + string, + { + kind: 'member_inbox'; + teamName: string; + memberName: string; + messageId: string; + } + >(); + + svc.setTeamChangeEmitter((event) => { + emitted.push(event as unknown as Record); + }); + (svc as any).setSecondaryRuntimeRun({ + teamName: 'mixed-team', + runId: 'opencode-run-1', + providerId: 'opencode', + laneId: 'secondary:opencode:bob', + memberName: 'bob', + cwd: '/tmp/mixed-team', + }); + (svc as any).createOpenCodeRuntimeDeliveryPorts = vi.fn(() => [ + { + kind: 'member_inbox', + write: vi.fn(async ({ envelope, destinationMessageId }) => { + const location = { + kind: 'member_inbox' as const, + teamName: envelope.teamName, + memberName: + typeof envelope.to === 'object' && 'memberName' in envelope.to + ? envelope.to.memberName + : 'unknown', + messageId: destinationMessageId, + }; + delivered.set(destinationMessageId, location); + return location; + }), + verify: vi.fn(async ({ destinationMessageId }) => { + const location = delivered.get(destinationMessageId) ?? null; + return { + found: location !== null, + location, + diagnostics: [], + }; + }), + buildChangeEvent: vi.fn(({ teamName, location }) => ({ + type: 'inbox', + teamName, + data: { + detail: + location.kind === 'member_inbox' + ? `inboxes/${location.memberName}.json` + : 'inboxes', + }, + })), + }, + ]); + + const delivery = (svc as any).createOpenCodeRuntimeDeliveryService( + 'mixed-team', + 'secondary:opencode:bob' + ); + const ack = await delivery.deliver({ + idempotencyKey: 'delivery-event-shape-1', + runId: 'opencode-run-1', + teamName: 'mixed-team', + fromMemberName: 'bob', + providerId: 'opencode', + runtimeSessionId: 'session-bob', + to: { memberName: 'alice' }, + text: 'hi', + createdAt: '2026-04-22T12:05:00.000Z', + }); + + expect(ack).toMatchObject({ ok: true, delivered: true }); + expect(emitted).toContainEqual( + expect.objectContaining({ + type: 'inbox', + teamName: 'mixed-team', + detail: 'inboxes/alice.json', + }) + ); + expect(emitted[0]).not.toHaveProperty('data'); + }); + it('recovers OpenCode delivery journals from canonical launch snapshot when lane index is missing', async () => { const svc = new TeamProvisioningService(); diff --git a/test/main/services/team/TeamProvisioningServiceLiveMessages.test.ts b/test/main/services/team/TeamProvisioningServiceLiveMessages.test.ts index d9b870af..3ebbe29c 100644 --- a/test/main/services/team/TeamProvisioningServiceLiveMessages.test.ts +++ b/test/main/services/team/TeamProvisioningServiceLiveMessages.test.ts @@ -95,6 +95,7 @@ vi.mock('../../../../src/main/utils/fsRead', async (importOriginal) => { }); vi.mock('agent-teams-controller', () => ({ + AGENT_TEAMS_TEAMMATE_OPERATIONAL_TOOL_NAMES: [] as readonly string[], AGENT_TEAMS_NAMESPACED_LEAD_BOOTSTRAP_TOOL_NAMES: [] as readonly string[], AGENT_TEAMS_NAMESPACED_TEAMMATE_OPERATIONAL_TOOL_NAMES: [] as readonly string[], createController: ({ teamName }: { teamName: string }) => ({ @@ -466,6 +467,63 @@ describe('TeamProvisioningService pre-ready live messages', () => { expect(hoisted.appendSentMessage).toHaveBeenCalledTimes(1); }); + it('suppresses duplicate assistant thought text when Agent Teams message_send is already visible', () => { + const service = new TeamProvisioningService(); + seedConfig('my-team'); + const run = attachRun(service, 'my-team', { provisioningComplete: true }); + + callHandleStreamJsonMessage(service, run, { + type: 'assistant', + content: [ + { type: 'text', text: 'Sending this through the Agent Teams MCP tool now.' }, + { + type: 'tool_use', + name: 'mcp__agent-teams__message_send', + input: { + teamName: 'my-team', + to: 'user', + text: 'Task completed through MCP.', + from: 'team-lead', + summary: 'Done', + }, + }, + ], + }); + + // The MCP controller owns persistence for agent-teams_message_send. The stream + // capture path must not show the assistant narration as a second "thought". + expect(service.getLiveLeadProcessMessages('my-team')).toHaveLength(0); + expect(hoisted.appendSentMessage).not.toHaveBeenCalled(); + }); + + it('keeps assistant thought text when Agent Teams message_send payload is incomplete', () => { + const service = new TeamProvisioningService(); + seedConfig('my-team'); + const run = attachRun(service, 'my-team', { provisioningComplete: true }); + + callHandleStreamJsonMessage(service, run, { + type: 'assistant', + content: [ + { type: 'text', text: 'I need to retry this because the tool input is incomplete.' }, + { + type: 'tool_use', + name: 'mcp__agent-teams__message_send', + input: { + teamName: 'my-team', + to: 'user', + from: 'team-lead', + summary: 'Incomplete', + }, + }, + ], + }); + + const live = service.getLiveLeadProcessMessages('my-team'); + expect(live).toHaveLength(1); + expect(live[0].text).toBe('I need to retry this because the tool input is incomplete.'); + expect(live[0].source).toBe('lead_process'); + }); + it('post-ready path also uses the unified helper', () => { const service = new TeamProvisioningService(); seedConfig('my-team'); @@ -742,6 +800,7 @@ describe('TeamProvisioningService pre-ready live messages', () => { service.setCrossTeamSender(crossTeamSender); const run = attachRun(service, 'my-team', { provisioningComplete: true }); run.activeCrossTeamReplyHints = [{ toTeam: 'team-best', conversationId: 'conv-mcp-1' }]; + const taskRefs = [{ taskId: 'task-1', displayId: 'abcd1234', teamName: 'my-team' }]; callHandleStreamJsonMessage(service, run, { type: 'assistant', @@ -755,6 +814,7 @@ describe('TeamProvisioningService pre-ready live messages', () => { text: 'Ответ через MCP.', from: 'team-lead', summary: 'MCP reply', + taskRefs, }, }, ], @@ -772,6 +832,7 @@ describe('TeamProvisioningService pre-ready live messages', () => { text: 'Ответ через MCP.', conversationId: 'conv-mcp-1', replyToConversationId: 'conv-mcp-1', + taskRefs, }) ); @@ -780,6 +841,7 @@ describe('TeamProvisioningService pre-ready live messages', () => { expect(live[0].from).toBe('team-lead'); expect(live[0].source).toBe('cross_team_sent'); expect(live[0].to).toBe('cross-team:team-best'); + expect(live[0].taskRefs).toEqual(taskRefs); expect(hoisted.sendInboxMessage).not.toHaveBeenCalled(); }); diff --git a/test/main/services/team/TeamProvisioningServicePrepare.test.ts b/test/main/services/team/TeamProvisioningServicePrepare.test.ts index 4382c275..2c88a49a 100644 --- a/test/main/services/team/TeamProvisioningServicePrepare.test.ts +++ b/test/main/services/team/TeamProvisioningServicePrepare.test.ts @@ -142,6 +142,43 @@ function writeMcpConfig( return configPath; } +const REQUIRED_MOCK_AGENT_TEAMS_TOOLS = [ + 'cross_team_get_outbox', + 'cross_team_list_targets', + 'cross_team_send', + 'lead_briefing', + 'member_briefing', + 'message_send', + 'process_list', + 'process_register', + 'process_stop', + 'process_unregister', + 'review_approve', + 'review_request', + 'review_request_changes', + 'review_start', + 'runtime_bootstrap_checkin', + 'runtime_deliver_message', + 'runtime_task_event', + 'runtime_heartbeat', + 'task_add_comment', + 'task_attach_comment_file', + 'task_attach_file', + 'task_briefing', + 'task_complete', + 'task_create', + 'task_create_from_message', + 'task_get', + 'task_get_comment', + 'task_link', + 'task_list', + 'task_set_clarification', + 'task_set_owner', + 'task_set_status', + 'task_start', + 'task_unlink', +] as const; + function writeMockMcpServer( targetDir: string, variant: @@ -151,12 +188,10 @@ function writeMockMcpServer( | 'lead-briefing-error' ): string { const scriptPath = path.join(targetDir, `mock-mcp-${variant}.js`); - const tools = - variant === 'missing-member-briefing' - ? [{ name: 'lead_briefing' }] - : variant === 'missing-lead-briefing' - ? [{ name: 'member_briefing' }] - : [{ name: 'member_briefing' }, { name: 'lead_briefing' }]; + const tools = REQUIRED_MOCK_AGENT_TEAMS_TOOLS + .filter((name) => variant !== 'missing-member-briefing' || name !== 'member_briefing') + .filter((name) => variant !== 'missing-lead-briefing' || name !== 'lead_briefing') + .map((name) => ({ name })); fs.writeFileSync( scriptPath, @@ -2319,7 +2354,7 @@ describe('TeamProvisioningService prepare/auth behavior', () => { await expect( (svc as any).validateAgentTeamsMcpRuntime('/fake/claude', tempRoot, process.env, configPath) - ).rejects.toThrow('tools/list did not include member_briefing'); + ).rejects.toThrow('required tool(s): member_briefing'); }); it('fails validation when tools/list does not include lead_briefing', async () => { @@ -2334,7 +2369,7 @@ describe('TeamProvisioningService prepare/auth behavior', () => { await expect( (svc as any).validateAgentTeamsMcpRuntime('/fake/claude', tempRoot, process.env, configPath) - ).rejects.toThrow('tools/list did not include lead_briefing'); + ).rejects.toThrow('required tool(s): lead_briefing'); }); it('fails validation when member_briefing itself returns an MCP error', async () => { diff --git a/test/main/services/team/TeamProvisioningServiceRelay.test.ts b/test/main/services/team/TeamProvisioningServiceRelay.test.ts index a0957f36..aa32b36c 100644 --- a/test/main/services/team/TeamProvisioningServiceRelay.test.ts +++ b/test/main/services/team/TeamProvisioningServiceRelay.test.ts @@ -113,6 +113,7 @@ vi.mock('../../../../src/main/utils/fsRead', async (importOriginal) => { }); vi.mock('agent-teams-controller', () => ({ + AGENT_TEAMS_TEAMMATE_OPERATIONAL_TOOL_NAMES: [] as readonly string[], AGENT_TEAMS_NAMESPACED_LEAD_BOOTSTRAP_TOOL_NAMES: [] as readonly string[], AGENT_TEAMS_NAMESPACED_TEAMMATE_OPERATIONAL_TOOL_NAMES: [] as readonly string[], createController: ({ teamName }: { teamName: string }) => ({ @@ -1619,4 +1620,370 @@ describe('TeamProvisioningService relayLeadInboxMessages', () => { expect(payload).toContain('idle_notification'); expect(payload).toContain('blocked'); }); + + it('relays unread OpenCode member inbox rows to the runtime before marking them read', async () => { + const service = new TeamProvisioningService(); + const teamName = 'my-team'; + hoisted.files.set( + `/mock/teams/${teamName}/config.json`, + JSON.stringify({ + name: teamName, + projectPath: '/tmp/my-team', + members: [ + { name: 'team-lead', agentType: 'team-lead' }, + { name: 'jack', role: 'developer', providerId: 'opencode', model: 'openrouter/test' }, + ], + }) + ); + seedMemberInbox(teamName, 'jack', [ + { + from: 'bob', + to: 'jack', + text: 'Please review this.', + timestamp: '2026-02-23T17:00:00.000Z', + read: false, + messageId: 'opencode-relay-1', + taskRefs: [{ teamName, taskId: 'task-1', displayId: 'abcd1234' }], + actionMode: 'ask', + }, + ]); + const deliverSpy = vi + .spyOn(service, 'deliverOpenCodeMemberMessage') + .mockResolvedValue({ delivered: true, diagnostics: [] }); + + const relay = await service.relayOpenCodeMemberInboxMessages(teamName, 'jack'); + + expect(relay).toMatchObject({ relayed: 1, attempted: 1, delivered: 1, failed: 0 }); + expect(deliverSpy).toHaveBeenCalledWith( + teamName, + expect.objectContaining({ + memberName: 'jack', + text: 'Please review this.', + messageId: 'opencode-relay-1', + replyRecipient: 'bob', + actionMode: 'ask', + taskRefs: [{ teamName, taskId: 'task-1', displayId: 'abcd1234' }], + }) + ); + const rows = JSON.parse( + hoisted.files.get(`/mock/teams/${teamName}/inboxes/jack.json`) ?? '[]' + ); + expect(rows[0].read).toBe(true); + }); + + it('does not let an older in-flight OpenCode relay mask a specific UI-send message', async () => { + const service = new TeamProvisioningService(); + const teamName = 'my-team'; + hoisted.files.set( + `/mock/teams/${teamName}/config.json`, + JSON.stringify({ + name: teamName, + projectPath: '/tmp/my-team', + members: [ + { name: 'team-lead', agentType: 'team-lead' }, + { name: 'jack', role: 'developer', providerId: 'opencode', model: 'openrouter/test' }, + ], + }) + ); + seedMemberInbox(teamName, 'jack', [ + { + from: 'bob', + to: 'jack', + text: 'Older watcher message.', + timestamp: '2026-02-23T17:00:00.000Z', + read: false, + messageId: 'opencode-inflight-old', + }, + ]); + + const oldDeliveryStarted = createDeferred(); + const releaseOldDelivery = createDeferred(); + const deliverSpy = vi + .spyOn(service, 'deliverOpenCodeMemberMessage') + .mockImplementation(async (_teamName, input) => { + if (input.messageId === 'opencode-inflight-old') { + oldDeliveryStarted.resolve(undefined); + await releaseOldDelivery.promise; + } + return { delivered: true, diagnostics: [] }; + }); + + const watcherRelay = service.relayOpenCodeMemberInboxMessages(teamName, 'jack'); + await oldDeliveryStarted.promise; + seedMemberInbox(teamName, 'jack', [ + { + from: 'bob', + to: 'jack', + text: 'Older watcher message.', + timestamp: '2026-02-23T17:00:00.000Z', + read: false, + messageId: 'opencode-inflight-old', + }, + { + from: 'user', + to: 'jack', + text: 'New UI message.', + timestamp: '2026-02-23T17:00:01.000Z', + read: false, + messageId: 'opencode-inflight-new', + }, + ]); + + const uiRelay = service.relayOpenCodeMemberInboxMessages(teamName, 'jack', { + onlyMessageId: 'opencode-inflight-new', + source: 'ui-send', + deliveryMetadata: { replyRecipient: 'user' }, + }); + releaseOldDelivery.resolve(undefined); + + await expect(watcherRelay).resolves.toMatchObject({ + attempted: 1, + delivered: 1, + }); + await expect(uiRelay).resolves.toMatchObject({ + attempted: 1, + delivered: 1, + failed: 0, + }); + expect(deliverSpy).toHaveBeenCalledWith( + teamName, + expect.objectContaining({ messageId: 'opencode-inflight-old' }) + ); + expect(deliverSpy).toHaveBeenCalledWith( + teamName, + expect.objectContaining({ messageId: 'opencode-inflight-new' }) + ); + const rows = JSON.parse( + hoisted.files.get(`/mock/teams/${teamName}/inboxes/jack.json`) ?? '[]' + ); + expect(rows.map((row: { read?: boolean }) => row.read)).toEqual([true, true]); + }); + + it('treats an already-read specific OpenCode inbox row as delivered for UI-send relay', async () => { + const service = new TeamProvisioningService(); + const teamName = 'my-team'; + hoisted.files.set( + `/mock/teams/${teamName}/config.json`, + JSON.stringify({ + name: teamName, + projectPath: '/tmp/my-team', + members: [ + { name: 'team-lead', agentType: 'team-lead' }, + { name: 'jack', role: 'developer', providerId: 'opencode', model: 'openrouter/test' }, + ], + }) + ); + seedMemberInbox(teamName, 'jack', [ + { + from: 'user', + to: 'jack', + text: 'Already relayed.', + timestamp: '2026-02-23T17:02:00.000Z', + read: true, + messageId: 'opencode-already-read-1', + }, + ]); + const deliverSpy = vi.spyOn(service, 'deliverOpenCodeMemberMessage'); + + const relay = await service.relayOpenCodeMemberInboxMessages(teamName, 'jack', { + onlyMessageId: 'opencode-already-read-1', + source: 'ui-send', + }); + + expect(relay).toMatchObject({ + relayed: 0, + attempted: 1, + delivered: 1, + failed: 0, + lastDelivery: { delivered: true }, + }); + expect(deliverSpy).not.toHaveBeenCalled(); + }); + + it('routes watcher inbox changes for OpenCode members through direct runtime relay', async () => { + const service = new TeamProvisioningService(); + const teamName = 'my-team'; + hoisted.files.set( + `/mock/teams/${teamName}/config.json`, + JSON.stringify({ + name: teamName, + projectPath: '/tmp/my-team', + members: [ + { name: 'team-lead', agentType: 'team-lead' }, + { name: 'jack', role: 'developer', providerId: 'opencode', model: 'openrouter/test' }, + ], + }) + ); + seedMemberInbox(teamName, 'jack', [ + { + from: 'bob', + to: 'jack', + text: 'Please review this.', + timestamp: '2026-02-23T17:05:00.000Z', + read: false, + messageId: 'opencode-selector-relay-1', + }, + ]); + vi.spyOn(service, 'deliverOpenCodeMemberMessage').mockResolvedValue({ + delivered: true, + diagnostics: [], + }); + + const relay = await service.relayInboxFileToLiveRecipient(teamName, 'jack'); + + expect(relay).toMatchObject({ kind: 'opencode_member', relayed: 1 }); + const rows = JSON.parse( + hoisted.files.get(`/mock/teams/${teamName}/inboxes/jack.json`) ?? '[]' + ); + expect(rows[0].read).toBe(true); + }); + + it('leaves OpenCode lead inbox rows unread with an explicit unsupported diagnostic', async () => { + const service = new TeamProvisioningService(); + const teamName = 'my-team'; + hoisted.files.set( + `/mock/teams/${teamName}/config.json`, + JSON.stringify({ + name: teamName, + projectPath: '/tmp/my-team', + members: [ + { + name: 'team-lead', + agentType: 'team-lead', + providerId: 'opencode', + model: 'openrouter/test', + }, + ], + }) + ); + seedLeadInbox(teamName, [ + { + from: 'user', + to: 'team-lead', + text: 'Please coordinate.', + timestamp: '2026-02-23T17:06:00.000Z', + read: false, + messageId: 'opencode-lead-unread-1', + }, + ]); + + const relay = await service.relayInboxFileToLiveRecipient(teamName, 'team-lead'); + + expect(relay).toMatchObject({ kind: 'opencode_lead_unsupported', relayed: 0 }); + expect(relay.diagnostics?.join('\n')).toContain('opencode_lead_runtime_session_missing'); + expect(vi.mocked(console.warn).mock.calls[0]?.join(' ')).toContain( + 'opencode_lead_runtime_session_missing' + ); + vi.mocked(console.warn).mockClear(); + const rows = JSON.parse( + hoisted.files.get(`/mock/teams/${teamName}/inboxes/team-lead.json`) ?? '[]' + ); + expect(rows[0].read).toBe(false); + }); + + it('keeps failed OpenCode member inbox relay rows unread for retry', async () => { + const service = new TeamProvisioningService(); + const teamName = 'my-team'; + hoisted.files.set( + `/mock/teams/${teamName}/config.json`, + JSON.stringify({ + name: teamName, + projectPath: '/tmp/my-team', + members: [ + { name: 'team-lead', agentType: 'team-lead' }, + { name: 'jack', role: 'developer', providerId: 'opencode', model: 'openrouter/test' }, + ], + }) + ); + seedMemberInbox(teamName, 'jack', [ + { + from: 'bob', + to: 'jack', + text: 'Please review this.', + timestamp: '2026-02-23T17:10:00.000Z', + read: false, + messageId: 'opencode-relay-failed-1', + }, + ]); + vi.spyOn(service, 'deliverOpenCodeMemberMessage').mockResolvedValue({ + delivered: false, + reason: 'opencode_runtime_not_active', + diagnostics: ['opencode_runtime_not_active'], + }); + + const relay = await service.relayOpenCodeMemberInboxMessages(teamName, 'jack'); + + expect(relay).toMatchObject({ + relayed: 0, + attempted: 1, + delivered: 0, + failed: 1, + lastDelivery: { delivered: false, reason: 'opencode_runtime_not_active' }, + }); + expect(vi.mocked(console.warn).mock.calls[0]?.join(' ')).toContain( + 'OpenCode inbox relay failed for jack/opencode-relay-failed-1' + ); + vi.mocked(console.warn).mockClear(); + const rows = JSON.parse( + hoisted.files.get(`/mock/teams/${teamName}/inboxes/jack.json`) ?? '[]' + ); + expect(rows[0].read).toBe(false); + }); + + it('treats OpenCode mark-read failure after prompt acceptance as an uncommitted relay', async () => { + const service = new TeamProvisioningService(); + const teamName = 'my-team'; + hoisted.files.set( + `/mock/teams/${teamName}/config.json`, + JSON.stringify({ + name: teamName, + projectPath: '/tmp/my-team', + members: [ + { name: 'team-lead', agentType: 'team-lead' }, + { name: 'jack', role: 'developer', providerId: 'opencode', model: 'openrouter/test' }, + ], + }) + ); + seedMemberInbox(teamName, 'jack', [ + { + from: 'bob', + to: 'jack', + text: 'Please review this.', + timestamp: '2026-02-23T17:20:00.000Z', + read: false, + messageId: 'opencode-mark-read-failed-1', + }, + ]); + vi.spyOn(service, 'deliverOpenCodeMemberMessage').mockResolvedValue({ + delivered: true, + diagnostics: [], + }); + vi.spyOn(service as any, 'markInboxMessagesRead').mockRejectedValue( + new Error('write failed') + ); + + const relay = await service.relayOpenCodeMemberInboxMessages(teamName, 'jack'); + + expect(relay).toMatchObject({ + relayed: 0, + attempted: 1, + delivered: 0, + failed: 1, + lastDelivery: { + delivered: false, + reason: 'opencode_inbox_mark_read_failed_after_delivery', + }, + }); + expect(relay.diagnostics?.join('\n')).toContain( + 'opencode_inbox_mark_read_failed_after_delivery' + ); + expect(vi.mocked(console.warn).mock.calls[0]?.join(' ')).toContain( + 'opencode_inbox_mark_read_failed_after_delivery' + ); + vi.mocked(console.warn).mockClear(); + const rows = JSON.parse( + hoisted.files.get(`/mock/teams/${teamName}/inboxes/jack.json`) ?? '[]' + ); + expect(rows[0].read).toBe(false); + }); }); diff --git a/test/main/services/team/agentTeamsToolNames.test.ts b/test/main/services/team/agentTeamsToolNames.test.ts new file mode 100644 index 00000000..23476626 --- /dev/null +++ b/test/main/services/team/agentTeamsToolNames.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from 'vitest'; + +import { + canonicalizeAgentTeamsToolName, + isAgentTeamsToolUse, + lineHasAgentTeamsTaskBoundaryToolName, +} from '../../../../src/main/services/team/agentTeamsToolNames'; + +describe('agentTeamsToolNames', () => { + it.each([ + 'message_send', + 'agent-teams_message_send', + 'agent_teams_message_send', + 'mcp__agent-teams__message_send', + 'mcp__agent_teams__message_send', + 'proxy_agent-teams_message_send', + ])('canonicalizes %s to message_send', (toolName) => { + expect(canonicalizeAgentTeamsToolName(toolName)).toBe('message_send'); + }); + + it.each([ + '"name":"agent-teams_task_start"', + '"name":"agent_teams_task_start"', + '"name":"mcp__agent-teams__task_start"', + '"name":"proxy_agent-teams_task_complete"', + ])('detects task boundary aliases in raw log line %s', (line) => { + expect(lineHasAgentTeamsTaskBoundaryToolName(line)).toBe(true); + }); + + it('does not classify unrelated plain message_send calls without Agent Teams payload shape', () => { + expect( + isAgentTeamsToolUse({ + rawName: 'message_send', + canonicalName: 'message_send', + toolInput: { channel: 'general', body: 'hello' }, + currentTeamName: 'atlas-hq', + }) + ).toBe(false); + }); + + it('does not classify proxy-prefixed plain message_send without Agent Teams payload shape', () => { + expect( + isAgentTeamsToolUse({ + rawName: 'proxy_message_send', + canonicalName: 'message_send', + toolInput: { channel: 'general', body: 'hello' }, + currentTeamName: 'atlas-hq', + }) + ).toBe(false); + }); + + it('classifies proxy-prefixed plain message_send only when payload matches Agent Teams shape', () => { + expect( + isAgentTeamsToolUse({ + rawName: 'proxy_message_send', + canonicalName: 'message_send', + toolInput: { teamName: 'atlas-hq', to: 'user', text: 'hello' }, + currentTeamName: 'atlas-hq', + }) + ).toBe(true); + }); +}); diff --git a/test/renderer/components/team/TeamModelSelectorDisabledState.test.ts b/test/renderer/components/team/TeamModelSelectorDisabledState.test.ts index 3bd94ee2..3de2f5e4 100644 --- a/test/renderer/components/team/TeamModelSelectorDisabledState.test.ts +++ b/test/renderer/components/team/TeamModelSelectorDisabledState.test.ts @@ -783,6 +783,118 @@ describe('TeamModelSelector disabled Codex models', () => { }); }); + it('uses role-specific provider disabled copy before OpenCode readiness gating', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + storeState.cliStatus = { + providers: [ + { + providerId: 'opencode', + supported: true, + authenticated: true, + detailMessage: null, + statusMessage: null, + capabilities: { + teamLaunch: true, + }, + models: ['openrouter/minimax/minimax-m2.5-free'], + }, + ], + }; + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + const onProviderChange = vi.fn(); + + await act(async () => { + root.render( + React.createElement(TeamModelSelector, { + providerId: 'anthropic', + onProviderChange, + value: '', + onValueChange: () => undefined, + providerDisabledReasonById: { + opencode: 'OpenCode is not available for team lead.', + }, + providerDisabledBadgeLabelById: { + opencode: 'not teamlead', + }, + }) + ); + await Promise.resolve(); + }); + + const openCodeButton = Array.from(host.querySelectorAll('button')).find((button) => + button.textContent?.includes('OpenCode') + ); + expect(openCodeButton?.hasAttribute('disabled')).toBe(true); + expect(openCodeButton?.getAttribute('title')).toBe('OpenCode is not available for team lead.'); + expect(openCodeButton?.textContent).toContain('not teamlead'); + + await act(async () => { + openCodeButton?.dispatchEvent(new MouseEvent('click', { bubbles: true })); + await Promise.resolve(); + }); + + expect(onProviderChange).not.toHaveBeenCalled(); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); + + it('keeps ready OpenCode selectable when no role-specific disable is provided', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + storeState.cliStatus = { + providers: [ + { + providerId: 'opencode', + supported: true, + authenticated: true, + detailMessage: null, + statusMessage: null, + capabilities: { + teamLaunch: true, + }, + models: ['openrouter/minimax/minimax-m2.5-free'], + }, + ], + }; + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + const onProviderChange = vi.fn(); + + await act(async () => { + root.render( + React.createElement(TeamModelSelector, { + providerId: 'anthropic', + onProviderChange, + value: '', + onValueChange: () => undefined, + }) + ); + await Promise.resolve(); + }); + + const openCodeButton = Array.from(host.querySelectorAll('button')).find((button) => + button.textContent?.includes('OpenCode') + ); + expect(openCodeButton?.hasAttribute('disabled')).toBe(false); + + await act(async () => { + openCodeButton?.dispatchEvent(new MouseEvent('click', { bubbles: true })); + await Promise.resolve(); + }); + + expect(onProviderChange).toHaveBeenCalledWith('opencode'); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); + it('switches providers through tabs instead of a dropdown', async () => { vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); const host = document.createElement('div'); diff --git a/test/renderer/components/team/dialogs/LaunchTeamDialog.test.ts b/test/renderer/components/team/dialogs/LaunchTeamDialog.test.ts index cc840820..68d0c90d 100644 --- a/test/renderer/components/team/dialogs/LaunchTeamDialog.test.ts +++ b/test/renderer/components/team/dialogs/LaunchTeamDialog.test.ts @@ -101,6 +101,8 @@ vi.mock('@renderer/components/team/members/MembersEditorSection', () => ({ effort: member.effort, })), filterEditableMemberInputs: (members: unknown) => members, + normalizeLeadProviderForMode: (providerId: unknown) => + providerId === 'opencode' ? 'anthropic' : providerId, normalizeMemberDraftForProviderMode: (member: unknown) => member, normalizeProviderForMode: (providerId: unknown) => providerId, validateMemberNameInline: () => null, @@ -311,6 +313,8 @@ vi.mock('@renderer/components/team/dialogs/TeamModelSelector', () => ({ computeEffectiveTeamModel: (model: string) => model || undefined, formatTeamModelSummary: (providerId: string, model: string, effort?: string) => [providerId, model, effort].filter(Boolean).join(' '), + OPENCODE_TEAM_LEAD_DISABLED_BADGE_LABEL: 'not teamlead', + OPENCODE_TEAM_LEAD_DISABLED_REASON: 'OpenCode is not available for team lead.', })); vi.mock('@renderer/components/team/dialogs/EffortLevelSelector', () => ({ @@ -496,7 +500,7 @@ describe('LaunchTeamDialog', () => { }); }); - it('clears stale inherited member models after saved launch hydration for OpenCode', async () => { + it('normalizes saved OpenCode lead hydration away from the unsupported lead path', async () => { vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); vi.mocked(isTeamModelAvailableForUi).mockImplementation( (_providerId, model, providerStatus) => providerStatus?.models?.includes(model ?? '') ?? false @@ -533,9 +537,9 @@ describe('LaunchTeamDialog', () => { ], } as any); - const onLaunch = vi.fn< - (request: { providerId?: string; model?: string }) => Promise - >(async () => {}); + const onLaunch = vi.fn<(request: { providerId?: string; model?: string }) => Promise>( + async () => {} + ); const host = document.createElement('div'); document.body.appendChild(host); const root = createRoot(host); @@ -563,9 +567,7 @@ describe('LaunchTeamDialog', () => { const opencodePrepareCalls = vi .mocked(runProviderPrepareDiagnostics) .mock.calls.filter((call) => call[0]?.providerId === 'opencode'); - expect(opencodePrepareCalls.at(-1)?.[0]?.selectedModelIds).toEqual([ - 'opencode/minimax-m2.5-free', - ]); + expect(opencodePrepareCalls).toHaveLength(0); const submitButton = Array.from(host.querySelectorAll('button')).find( (button) => button.textContent === 'Launch team' @@ -589,15 +591,13 @@ describe('LaunchTeamDialog', () => { ], }); expect(onLaunch).toHaveBeenCalledTimes(1); - const launchRequest = (onLaunch.mock.calls as Array< - [{ providerId?: string; model?: string }] - >)[0]?.[0] as - | { providerId?: string; model?: string } - | undefined; + const launchRequest = ( + onLaunch.mock.calls as Array<[{ providerId?: string; model?: string }]> + )[0]?.[0] as { providerId?: string; model?: string } | undefined; expect(launchRequest).toMatchObject({ - providerId: 'opencode', - model: 'opencode/minimax-m2.5-free', + providerId: 'anthropic', }); + expect(launchRequest?.model).not.toBe('opencode/minimax-m2.5-free'); await act(async () => { root.unmount(); @@ -1142,7 +1142,10 @@ describe('LaunchTeamDialog', () => { await flush(); }); - expect(vi.mocked(runProviderPrepareDiagnostics)).toHaveBeenCalledTimes(1); + const inFlightOpencodePrepareCalls = vi + .mocked(runProviderPrepareDiagnostics) + .mock.calls.filter((call) => call[0]?.providerId === 'opencode'); + expect(inFlightOpencodePrepareCalls).toHaveLength(1); expect(host.textContent).toContain('Selected providers are ready.'); await act(async () => { diff --git a/test/renderer/components/team/members/membersEditorUtils.test.ts b/test/renderer/components/team/members/membersEditorUtils.test.ts index 38d87d62..7d738649 100644 --- a/test/renderer/components/team/members/membersEditorUtils.test.ts +++ b/test/renderer/components/team/members/membersEditorUtils.test.ts @@ -6,12 +6,20 @@ import { createMemberDraft, createMemberDraftsFromInputs, filterEditableMemberInputs, + normalizeLeadProviderForMode, } from '@renderer/components/team/members/MembersEditorSection'; import { buildTeamMemberColorMap } from '@shared/utils/teamMemberColors'; import { getMemberColorByName } from '@shared/constants/memberColors'; import type { ResolvedTeamMember } from '@shared/types'; describe('members editor editable input filtering', () => { + it('normalizes OpenCode away from the team lead while keeping other multimodel providers', () => { + expect(normalizeLeadProviderForMode('opencode', true)).toBe('anthropic'); + expect(normalizeLeadProviderForMode('codex', true)).toBe('codex'); + expect(normalizeLeadProviderForMode('anthropic', true)).toBe('anthropic'); + expect(normalizeLeadProviderForMode('opencode', false)).toBe('anthropic'); + }); + it('filters the canonical team lead out of editable member inputs', () => { const members = [ { @@ -28,7 +36,7 @@ describe('members editor editable input filtering', () => { }, ] satisfies Array>; - expect(filterEditableMemberInputs(members).map(member => member.name)).toEqual([ + expect(filterEditableMemberInputs(members).map((member) => member.name)).toEqual([ 'alice', 'bob', ]); @@ -50,10 +58,7 @@ describe('members editor editable input filtering', () => { effort: 'medium', }, ] satisfies Array< - Pick< - ResolvedTeamMember, - 'name' | 'agentType' | 'providerId' | 'model' | 'effort' - > + Pick >; const drafts = createMemberDraftsFromInputs(filterEditableMemberInputs(members)); @@ -179,7 +184,10 @@ describe('members editor editable input filtering', () => { }); it('prefers an explicit resolved member color map from the team screen', () => { - const existingMembers = [{ name: 'alice', color: 'brick' }, { name: 'tom', color: 'forest' }]; + const existingMembers = [ + { name: 'alice', color: 'brick' }, + { name: 'tom', color: 'forest' }, + ]; const drafts = existingMembers.map((member) => createMemberDraft({ name: member.name })); const resolvedColorMap = new Map([ ['alice', 'blue'], @@ -193,7 +201,10 @@ describe('members editor editable input filtering', () => { }); it('keeps an existing teammate color stable while the name is being edited', () => { - const existingMembers = [{ name: 'alice', color: 'blue' }, { name: 'tom', color: 'saffron' }]; + const existingMembers = [ + { name: 'alice', color: 'blue' }, + { name: 'tom', color: 'saffron' }, + ]; const renamedAliceDraft = createMemberDraft({ id: 'draft-alice', name: 'alice-renamed', diff --git a/test/renderer/components/team/messages/MessagesPanel.test.ts b/test/renderer/components/team/messages/MessagesPanel.test.ts index 9e05f746..1c0eaeb3 100644 --- a/test/renderer/components/team/messages/MessagesPanel.test.ts +++ b/test/renderer/components/team/messages/MessagesPanel.test.ts @@ -9,6 +9,7 @@ const storeState = { sendCrossTeamMessage: vi.fn().mockResolvedValue(undefined), sendingMessage: false, sendMessageError: null, + sendMessageWarning: null, lastSendMessageResult: null, teams: [], openTeamTab: vi.fn(), diff --git a/test/renderer/store/teamSlice.test.ts b/test/renderer/store/teamSlice.test.ts index ebdccc37..2d1b4d38 100644 --- a/test/renderer/store/teamSlice.test.ts +++ b/test/renderer/store/teamSlice.test.ts @@ -239,13 +239,39 @@ describe('teamSlice actions', () => { const store = createSliceStore(); hoisted.sendMessage.mockRejectedValue(new Error('Failed to verify inbox write')); - await store.getState().sendTeamMessage('my-team', { member: 'alice', text: 'hello' }); + await expect( + store.getState().sendTeamMessage('my-team', { member: 'alice', text: 'hello' }) + ).rejects.toThrow('Failed to verify inbox write'); expect(store.getState().sendMessageError).toBe( 'Message was written but not verified (race). Please try again.' ); }); + it('keeps send dialog result non-terminal when OpenCode runtime delivery fails after inbox persistence', async () => { + const store = createSliceStore(); + hoisted.sendMessage.mockResolvedValue({ + deliveredToInbox: true, + messageId: 'm-opencode-1', + runtimeDelivery: { + providerId: 'opencode', + attempted: true, + delivered: false, + reason: 'opencode_runtime_not_active', + }, + }); + + const result = await store.getState().sendTeamMessage('my-team', { + member: 'bob', + text: 'hello', + }); + + expect(result.messageId).toBe('m-opencode-1'); + expect(store.getState().lastSendMessageResult).toBeNull(); + expect(store.getState().sendMessageError).toBeNull(); + expect(store.getState().sendMessageWarning).toContain('OpenCode runtime delivery failed'); + }); + it('maps task status verify failure in updateKanban and rethrows', async () => { const store = createSliceStore(); hoisted.updateKanban.mockRejectedValue(new Error('Task status update verification failed: 12'));