feat(opencode): add semantic messaging seam

This commit is contained in:
777genius 2026-04-24 22:41:16 +03:00
parent 267a192329
commit 64c9ddc78c
63 changed files with 3408 additions and 280 deletions

View file

@ -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,

View file

@ -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,
};

View file

@ -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,
};

View file

@ -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) {

View file

@ -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,

View file

@ -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: '<lead-name>',
fromName: '<your-name>',
text: `#${task.displayId || task.id} done. <2-4 sentence summary>. Full details in task comment <short-commentId-from-step-4>. 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: "<full results>", from: "<your-name>" }
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: \\"<short-commentId-from-step-4>\\" }. 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: '<lead-name>',
fromName: '<your-name>',
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: "<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: "<taskId>", from: "<your-name>", reviewer: "<reviewer-name>" }
@ -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: "<taskId>", text: "<summary of your finding or decision>", from: "<your-name>" }
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 #<displayId> in your SendMessage summary field for traceability.
9. When sending a message about a specific task, include its short display label like #<displayId> 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: "<taskId>", value: "lead" }
b) STEP 2 THEN, add a task comment describing exactly what you need:
{ teamName: "${teamName}", taskId: "<taskId>", text: "question / blocker / missing info", from: "<your-name>" }
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)
);

View file

@ -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.');
});

View file

@ -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': {

View file

@ -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<string>;
memberBriefing(
memberName: string,
options?: { runtimeProvider?: 'native' | 'opencode' }
): Promise<string>;
leadBriefing(): Promise<string>;
taskBriefing(memberName: string): Promise<string>;
}
@ -52,7 +55,7 @@ declare module 'agent-teams-controller' {
export interface ControllerMessageApi {
appendSentMessage(flags: Record<string, unknown>): unknown;
sendMessage(flags: Record<string, unknown>): unknown;
lookupMessage(messageId: string): { message: Record<string, unknown> };
lookupMessage(messageId: string): { message: Record<string, unknown>; store: string };
}
export interface ControllerProcessApi {

View file

@ -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 } : {}),
});
}

View file

@ -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<FastMCP, 'addTool'>) {
server.addTool({
name: 'cross_team_send',
@ -22,6 +28,7 @@ export function registerCrossTeamTools(server: Pick<FastMCP, 'addTool'>) {
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<FastMCP, 'addTool'>) {
summary,
conversationId,
replyToConversationId,
taskRefs,
chainDepth,
}) =>
await Promise.resolve(
@ -44,6 +52,7 @@ export function registerCrossTeamTools(server: Pick<FastMCP, 'addTool'>) {
...(summary ? { summary } : {}),
...(conversationId ? { conversationId } : {}),
...(replyToConversationId ? { replyToConversationId } : {}),
...(taskRefs?.length ? { taskRefs } : {}),
...(chainDepth !== undefined ? { chainDepth } : {}),
})
)

View file

@ -12,7 +12,8 @@ const toolContextSchema = {
export function registerMessageTools(server: Pick<FastMCP, 'addTool'>) {
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<FastMCP, 'addTool'>) {
})
)
.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<FastMCP, 'addTool'>) {
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 } : {}),
})
)
),

View file

@ -129,7 +129,8 @@ export function registerRuntimeTools(server: Pick<FastMCP, 'addTool'>) {
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),

View file

@ -564,12 +564,15 @@ export function registerTaskTools(server: Pick<FastMCP, 'addTool'>) {
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 } : {}),
}),
},
],
}),

View file

@ -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', () => {

View file

@ -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<TeamRuntimeAdapte
}
const bridgeEnv = applyOpenCodeAutoUpdatePolicy({ ...process.env });
bridgeEnv.AGENT_TEAMS_MCP_CLAUDE_DIR = getClaudeBasePath();
try {
const mcpLaunchSpec = await resolveAgentTeamsMcpLaunchSpec();
const mcpEntry = mcpLaunchSpec.args[0];
@ -768,22 +774,19 @@ function wireFileWatcherEvents(context: ServiceContext): void {
}
// Relay inbox changes into active runtime recipients.
if (teamProvisioningService.isTeamAlive(teamName) && detail.startsWith('inboxes/')) {
if (detail.startsWith('inboxes/')) {
const match = /^inboxes\/(.+)\.json$/.exec(detail);
if (match && teamDataService) {
if (match) {
const inboxName = match[1];
void teamDataService
.getLeadMemberName(teamName)
.then((leadName) => {
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)}`)

View file

@ -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<string>();
const SEEN_RATE_LIMIT_KEYS_MAX = 500;
async function withTimeoutValue<T>(
promise: Promise<T>,
timeoutMs: number,
timeoutValue: T
): Promise<T> {
let timer: ReturnType<typeof setTimeout> | null = null;
try {
return await Promise.race([
promise,
new Promise<T>((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

View file

@ -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('||');
}

View file

@ -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<CrossTeamSendResult> {
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}`);

View file

@ -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,

View file

@ -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,

View file

@ -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,

View file

@ -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<string, unknown>):
}
}
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<string, unknown> => 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<string, Promise<number>>();
private readonly relayedLeadInboxMessageIds = new Map<string, Set<string>>();
private readonly memberInboxRelayInFlight = new Map<string, Promise<number>>();
private readonly openCodeMemberInboxRelayInFlight = new Map<
string,
Promise<OpenCodeMemberInboxRelayResult>
>();
private readonly relayedMemberInboxMessageIds = new Map<string, Set<string>>();
private readonly pendingCrossTeamFirstReplies = new Map<string, Map<string, number>>();
private readonly recentCrossTeamLeadDeliveryMessageIds = new Map<string, Map<string, number>>();
@ -4126,12 +4195,46 @@ export class TeamProvisioningService {
};
}
async isOpenCodeRuntimeRecipient(teamName: string, memberName: string): Promise<boolean> {
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<string | null> {
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<string, unknown>[]) : [];
}
private hasCapturedVisibleSendMessage(content: Record<string, unknown>[]): boolean {
private hasCapturedVisibleSendMessage(
content: Record<string, unknown>[],
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<string, unknown>;
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<LiveInboxRelayResult> {
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<OpenCodeMemberInboxRelayResult> {
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<OpenCodeMemberInboxRelayResult> => {
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<ReturnType<TeamInboxReader['getMessagesFor']>> = [];
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<string, unknown>;
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<McpToolsListResult>('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<McpToolCallResult>('tools/call', {
@ -20021,6 +20453,7 @@ export class TeamProvisioningService {
claudeDir: fixture.claudeDir,
teamName: fixture.teamName,
memberName: fixture.memberName,
runtimeProvider: 'opencode',
},
});
throwIfCancelled();

View file

@ -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<string>(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<string, unknown>;
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));
}

View file

@ -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,
},
})
: {

View file

@ -52,8 +52,8 @@ export interface OpenCodeProductionE2EEvidence {
projectPathFingerprint: string | null;
requiredSignals: Record<OpenCodeProductionE2ERequiredSignal, boolean>;
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 {

View file

@ -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',
],
};
}

View file

@ -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<string, unknown>)
: null;
const manifestData =
maybeRecord && Object.prototype.hasOwnProperty.call(maybeRecord, 'data')
? maybeRecord.data
: parsed;
return validateRuntimeStoreManifest(manifestData);
}
async function fileExists(filePath: string): Promise<boolean> {
try {
await stat(filePath);

View file

@ -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 [
'<opencode_app_message_delivery>',
'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[] {

View file

@ -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;
}

View file

@ -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);

View file

@ -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<TeamProviderId>(getStoredTeamProvider);
const [selectedProviderId, setSelectedProviderIdRaw] = useState<TeamProviderId>(() =>
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') {

View file

@ -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<string | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const [selectedProviderId, setSelectedProviderIdRaw] =
useState<TeamProviderId>(getStoredTeamProvider);
const [selectedProviderId, setSelectedProviderIdRaw] = useState<TeamProviderId>(() =>
normalizeLeadProviderForMode(getStoredTeamProvider(), multimodelEnabled)
);
const [selectedModel, setSelectedModelRaw] = useState(() =>
getStoredTeamModel(getStoredTeamProvider())
getStoredTeamModel(normalizeLeadProviderForMode(getStoredTeamProvider(), multimodelEnabled))
);
const [membersDrafts, setMembersDrafts] = useState<MemberDraft[]>([]);
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,
}}
/>
<EffortLevelSelector
value={selectedEffort}

View file

@ -64,6 +64,7 @@ interface SendMessageDialogProps {
isTeamAlive?: boolean;
sending: boolean;
sendError: string | null;
sendWarning?: string | null;
lastResult: SendMessageResult | null;
onSend: (
member: string,
@ -72,7 +73,7 @@ interface SendMessageDialogProps {
attachments?: AttachmentPayload[],
actionMode?: ActionMode,
taskRefs?: TaskRef[]
) => void;
) => void | Promise<SendMessageResult | void>;
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 = ({
<AlertCircle size={10} className="shrink-0" />
{sendError}
</span>
) : sendWarning ? (
<span className="inline-flex items-center gap-1 rounded bg-amber-500/10 px-1.5 py-0.5 text-[10px] text-amber-300">
<AlertCircle size={10} className="shrink-0" />
{sendWarning}
</span>
) : null}
{remaining < 200 ? (
<span

View file

@ -59,6 +59,8 @@ const PROVIDERS: ProviderDef[] = [
];
const OPENCODE_UI_DISABLED_REASON = 'OpenCode team launch is not ready.';
export const OPENCODE_TEAM_LEAD_DISABLED_REASON = 'OpenCode is not available for team lead.';
export const OPENCODE_TEAM_LEAD_DISABLED_BADGE_LABEL = 'not teamlead';
export function getTeamModelLabel(model: string): string {
return getCatalogTeamModelLabel(model) ?? model;
@ -142,6 +144,8 @@ export interface TeamModelSelectorProps {
onValueChange: (value: string) => void;
id?: string;
disableGeminiOption?: boolean;
providerDisabledReasonById?: Partial<Record<TeamProviderId, string | null | undefined>>;
providerDisabledBadgeLabelById?: Partial<Record<TeamProviderId, string | null | undefined>>;
modelIssueReasonByValue?: Partial<Record<string, string | null | undefined>>;
}
@ -152,6 +156,8 @@ export const TeamModelSelector: React.FC<TeamModelSelectorProps> = ({
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<TeamModelSelectorProps> = ({
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<TeamModelSelectorProps> = ({
(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;
}

View file

@ -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'),
}));

View file

@ -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}
/>
<EffortLevelSelector

View file

@ -488,6 +488,7 @@ export {
createMemberDraftsFromInputs,
filterEditableMemberInputs,
getMemberDraftRole,
normalizeLeadProviderForMode,
normalizeMemberDraftForProviderMode,
normalizeProviderForMode,
validateMemberNameInline,

View file

@ -96,6 +96,14 @@ export function normalizeProviderForMode(
return normalizeCreateLaunchProviderForUi(providerId, multimodelEnabled);
}
export function normalizeLeadProviderForMode(
providerId: TeamProviderId | undefined,
multimodelEnabled: boolean
): TeamProviderId {
const normalizedProviderId = normalizeProviderForMode(providerId, multimodelEnabled);
return normalizedProviderId === 'opencode' ? 'anthropic' : normalizedProviderId;
}
export function normalizeMemberDraftForProviderMode(
member: MemberDraft,
multimodelEnabled: boolean

View file

@ -51,6 +51,7 @@ interface MessageComposerProps {
isTeamAlive?: boolean;
sending: boolean;
sendError: string | null;
sendWarning?: string | null;
lastResult?: SendMessageResult | null;
/** Ref to the underlying textarea element for external focus management. */
textareaRef?: React.Ref<HTMLTextAreaElement>;
@ -78,6 +79,7 @@ export const MessageComposer = ({
isTeamAlive,
sending,
sendError,
sendWarning,
lastResult,
textareaRef: externalTextareaRef,
onSend,
@ -482,6 +484,11 @@ export const MessageComposer = ({
<AlertCircle size={10} className="shrink-0" />
{sendError}
</span>
) : sendWarning ? (
<span className="inline-flex items-center gap-1 rounded bg-amber-500/10 px-1.5 py-0.5 text-[10px] text-amber-300">
<AlertCircle size={10} className="shrink-0" />
{sendWarning}
</span>
) : lastResult?.deduplicated ? (
<span className="inline-flex items-center gap-1 rounded bg-amber-500/10 px-1.5 py-0.5 text-[10px] text-amber-300">
<Check size={10} className="shrink-0" />

View file

@ -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}

View file

@ -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<string, TeamProvisioningProgress>;
@ -2091,7 +2095,7 @@ export interface TeamSlice {
enabled: boolean,
delayMs?: number
) => void;
sendTeamMessage: (teamName: string, request: SendMessageRequest) => Promise<void>;
sendTeamMessage: (teamName: string, request: SendMessageRequest) => Promise<SendMessageResult>;
crossTeamTargets: {
teamName: string;
displayName: string;
@ -2341,6 +2345,7 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
selectedTeamError: null,
sendingMessage: false,
sendMessageError: null,
sendMessageWarning: null,
lastSendMessageResult: null,
crossTeamTargets: [],
crossTeamTargetsLoading: false,
@ -3976,11 +3981,25 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (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<AppState, [], [], TeamSlice> = (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<AppState, [], [], TeamSlice> = (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<AppState, [], [], TeamSlice> = (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<AppState, [], [], TeamSlice> = (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<AppState, [], [], TeamSlice> = (set,
set({
sendingMessage: false,
lastSendMessageResult: null,
sendMessageWarning: null,
sendMessageError: mapSendMessageError(error),
});
}

View file

@ -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 {

View file

@ -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<string>;
memberBriefing(
memberName: string,
options?: { runtimeProvider?: 'native' | 'opencode' }
): Promise<string>;
leadBriefing(): Promise<string>;
taskBriefing(memberName: string): Promise<string>;
}

View file

@ -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();

View file

@ -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<Record<string, unknown>>;
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];

View file

@ -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,

View file

@ -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,

View file

@ -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: {

View file

@ -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',

View file

@ -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<InboxMessage> {
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<InboxMessage & { messageId: string }> {
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<InboxMessage[]> {
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<boolean>,
timeoutMs: number,
pollMs = 500
): Promise<void> {
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<unknown> {
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<RuntimeStoreManifestEvidence> {
return {
highWatermark: 0,
activeRunId: null,
capabilitySnapshotId: null,
};
}
}
async function assertExecutable(filePath: string): Promise<void> {
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,
};
}

View file

@ -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<OpenCodeTeamRuntimeBridgePort['sendOpenCodeTeamMessage']>
>(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,

View file

@ -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',
})
);

View file

@ -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<string, unknown>) => ({
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<string, unknown>) => ({
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<string, unknown>) => ({
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<Record<string, unknown>> = [];
const delivered = new Map<
string,
{
kind: 'member_inbox';
teamName: string;
memberName: string;
messageId: string;
}
>();
svc.setTeamChangeEmitter((event) => {
emitted.push(event as unknown as Record<string, unknown>);
});
(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();

View file

@ -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();
});

View file

@ -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 () => {

View file

@ -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<void>();
const releaseOldDelivery = createDeferred<void>();
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);
});
});

View file

@ -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);
});
});

View file

@ -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');

View file

@ -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<void>
>(async () => {});
const onLaunch = vi.fn<(request: { providerId?: string; model?: string }) => Promise<void>>(
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 () => {

View file

@ -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<Pick<ResolvedTeamMember, 'name' | 'agentType'>>;
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<ResolvedTeamMember, 'name' | 'agentType' | 'providerId' | 'model' | 'effort'>
>;
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<string, string>([
['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',

View file

@ -9,6 +9,7 @@ const storeState = {
sendCrossTeamMessage: vi.fn().mockResolvedValue(undefined),
sendingMessage: false,
sendMessageError: null,
sendMessageWarning: null,
lastSendMessageResult: null,
teams: [],
openTeamTab: vi.fn(),

View file

@ -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'));