feat: harden opencode and team runtime flows

This commit is contained in:
777genius 2026-05-21 01:10:48 +03:00
parent 824f420cf5
commit 16a003416d
98 changed files with 6581 additions and 493 deletions

View file

@ -36,6 +36,14 @@ function assertKanbanColumnAllowed(context, task, column, options = {}) {
}
if (column === 'approved') {
if (transition === 'manual_approve') {
if (reviewState !== 'none') {
throw new Error(
`Task ${label} is already in reviewState=${reviewState}; use review_approve instead`
);
}
return;
}
if (transition === 'approve_review') {
if (reviewState !== 'review' && reviewState !== 'approved') {
throw new Error(`Task ${label} must be in review before approval`);

View file

@ -1,14 +1,17 @@
function normalizeRuntimeProvider(value) {
const normalized = String(value || '').trim().toLowerCase();
return normalized === 'opencode' ? 'opencode' : 'native';
if (normalized === 'opencode') return 'opencode';
if (normalized === 'codex') return 'codex';
return 'native';
}
function createMemberMessagingProtocol(runtimeProvider) {
const provider = normalizeRuntimeProvider(runtimeProvider);
if (provider === 'opencode') {
if (provider === 'opencode' || provider === 'codex') {
const runtimeLabel = provider === 'opencode' ? 'OpenCode' : 'Codex Native';
return {
runtimeProvider: 'opencode',
runtimeProvider: provider,
sendToolName: 'agent-teams_message_send',
sendToolAliases: [
'agent-teams_message_send',
@ -25,6 +28,10 @@ function createMemberMessagingProtocol(runtimeProvider) {
buildCrossTeamMessageExample({ teamName, toTeam, fromName, text, summary }) {
return `agent-teams_cross_team_send { teamName: "${teamName}", toTeam: "${toTeam}", fromMember: "${fromName}", text: "${text}", summary: "${summary}" }`;
},
visibleMessageRule:
`${runtimeLabel} 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.`,
taskToolHint:
`${runtimeLabel} task tool rule: call Agent Teams task tools directly; if prefixed MCP names are exposed, use mcp__agent-teams__task_get, mcp__agent-teams__task_start, mcp__agent-teams__task_add_comment, and mcp__agent-teams__task_complete.`,
};
}
@ -40,6 +47,8 @@ function createMemberMessagingProtocol(runtimeProvider) {
buildCrossTeamMessageExample({ teamName, toTeam, fromName, text, summary }) {
return `cross_team_send { teamName: "${teamName}", toTeam: "${toTeam}", fromMember: "${fromName}", text: "${text}", summary: "${summary}" }`;
},
visibleMessageRule: '',
taskToolHint: '',
};
}
@ -52,8 +61,18 @@ function isOpenCodeMember(member) {
return model.startsWith('opencode/');
}
function isCodexMember(member) {
const provider = String((member && (member.providerId || member.provider)) || '')
.trim()
.toLowerCase();
if (provider) return provider === 'codex';
const model = String((member && member.model) || '').trim().toLowerCase();
return model.startsWith('gpt-') || model.startsWith('openai/gpt-');
}
module.exports = {
createMemberMessagingProtocol,
isCodexMember,
isOpenCodeMember,
normalizeRuntimeProvider,
};

View file

@ -8,6 +8,7 @@ const { withTeamBoardLock } = require('./boardLock.js');
const { wrapAgentBlock } = require('./agentBlocks.js');
const {
createMemberMessagingProtocol,
isCodexMember,
isOpenCodeMember,
} = require('./memberMessagingProtocol.js');
@ -72,6 +73,12 @@ function warnNonCritical(message, error) {
console.warn(`${message}: ${error instanceof Error ? error.message : String(error)}`);
}
function resolveMemberRuntimeProvider(member) {
if (isOpenCodeMember(member)) return 'opencode';
if (isCodexMember(member)) return 'codex';
return 'native';
}
function buildAssignmentMessage(context, task, options = {}) {
const messagingProtocol = options.messagingProtocol || createMemberMessagingProtocol('native');
const description =
@ -104,10 +111,12 @@ function buildAssignmentMessage(context, task, options = {}) {
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.'
: '';
const runtimeVisibleMessageRule = messagingProtocol.visibleMessageRule
? `\n ${messagingProtocol.visibleMessageRule}`
: '';
const runtimeTaskToolHint = messagingProtocol.taskToolHint
? `\n ${messagingProtocol.taskToolHint}`
: '';
lines.push(
``,
@ -123,7 +132,7 @@ function buildAssignmentMessage(context, task, options = {}) {
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 ${messagingProtocol.sendLeadPhrase} with a brief summary and a pointer to the full comment (use the short commentId from step 4).
Example: ${notifyLeadExample}${openCodeVisibleMessageRule}`)
Example: ${notifyLeadExample}${runtimeVisibleMessageRule}${runtimeTaskToolHint}`)
);
return lines.join('\n');
@ -189,9 +198,7 @@ function maybeNotifyAssignedOwner(context, task, options = {}) {
const ownerMember = (resolved.members || []).find(
(member) => isSameMember(member && member.name, owner)
);
const messagingProtocol = createMemberMessagingProtocol(
isOpenCodeMember(ownerMember) ? 'opencode' : 'native'
);
const messagingProtocol = createMemberMessagingProtocol(resolveMemberRuntimeProvider(ownerMember));
const summary = options.summary || `New task #${task.displayId || task.id} assigned`;
try {
@ -696,10 +703,12 @@ function buildMemberTaskProtocol(teamName, messagingProtocol = createMemberMessa
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.'
: '';
const runtimeVisibleMessageRule = messagingProtocol.visibleMessageRule
? `\n - ${messagingProtocol.visibleMessageRule}`
: '';
const runtimeTaskToolHint = messagingProtocol.taskToolHint
? `\n - ${messagingProtocol.taskToolHint}`
: '';
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.
@ -722,7 +731,7 @@ function buildMemberTaskProtocol(teamName, messagingProtocol = createMemberMessa
- 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 ${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}
Example: ${notifyLeadExample}${runtimeVisibleMessageRule}${runtimeTaskToolHint}
- 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>" }
@ -891,7 +900,7 @@ async function memberBriefing(context, memberName, options = {}) {
const leadName = runtimeHelpers.inferLeadName(context.paths);
const effectiveMember = member;
const messagingProtocol = createMemberMessagingProtocol(
options.runtimeProvider || (isOpenCodeMember(effectiveMember) ? 'opencode' : 'native')
options.runtimeProvider || resolveMemberRuntimeProvider(effectiveMember)
);
const role =
@ -937,12 +946,13 @@ async function memberBriefing(context, memberName, options = {}) {
`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 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'
...(messagingProtocol.runtimeProvider !== 'native'
? [
'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.',
'OpenCode bootstrap silence rule: if this briefing was requested because the desktop app attached or reconnected you, do not send readiness, understood, idle, or no-task acknowledgements to the user, lead, or teammates.',
messagingProtocol.visibleMessageRule,
`${messagingProtocol.runtimeProvider === 'opencode' ? 'OpenCode' : 'Codex Native'} bootstrap silence rule: if this briefing was requested because the desktop app attached or reconnected you, do not send readiness, understood, idle, or no-task acknowledgements to the user, lead, or teammates.`,
'This briefing already includes your current Task briefing. If it shows no actionable tasks, stop and wait silently. Do not call task_briefing again in the same bootstrap turn just to check for work.',
'Use agent-teams_message_send only for actual app-delivered messages, actionable task coordination, blockers, or task results.',
messagingProtocol.taskToolHint,
'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.',
]
: []),

View file

@ -98,13 +98,17 @@ async function requestJson(baseUrl, pathname, options = {}) {
}
}
function isRetryableControlApiStatus(statusCode) {
return statusCode === 404 || statusCode === 408 || statusCode === 429 || statusCode >= 500;
}
async function requestJsonWithFallback(baseUrls, pathname, options = {}) {
let lastError = null;
for (const baseUrl of baseUrls) {
try {
return await requestJson(baseUrl, pathname, options);
} catch (error) {
if (error && error.controlApiStatus) {
if (error && error.controlApiStatus && !isRetryableControlApiStatus(error.controlApiStatus)) {
throw error;
}
lastError = error;

View file

@ -192,6 +192,28 @@ describe('agent-teams-controller API', () => {
expect(briefing).not.toContain('notify your team lead via SendMessage');
});
it('uses Codex-native visible-message and prefixed task tool wording for Codex 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: 'codex', model: '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 MCP tool agent-teams_message_send.'
);
expect(briefing).toContain('Codex Native visible messaging rule');
expect(briefing).toContain('Codex Native task tool rule');
expect(briefing).toContain('mcp__agent-teams__task_get');
expect(briefing).not.toContain('notify your team lead via SendMessage');
});
it('rejects OpenCode idle acknowledgements without explicit delivery context', () => {
const claudeDir = makeClaudeDir();
const configPath = path.join(claudeDir, 'teams', 'my-team', 'config.json');
@ -295,7 +317,7 @@ describe('agent-teams-controller API', () => {
expect(rows[0].summary).toBe('ready');
});
it('does not infer OpenCode briefing from a generic provider-scoped model alone', async () => {
it('infers Codex-native briefing from a generic provider-scoped GPT model', async () => {
const claudeDir = makeClaudeDir();
const configPath = path.join(claudeDir, 'teams', 'my-team', 'config.json');
const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
@ -308,8 +330,10 @@ describe('agent-teams-controller API', () => {
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');
expect(briefing).toContain(
'After task_complete, notify your team lead via MCP tool agent-teams_message_send.'
);
expect(briefing).toContain('Codex Native visible messaging rule');
});
it('keeps explicit native provider metadata stronger than OpenCode-looking model labels', async () => {
@ -318,7 +342,12 @@ describe('agent-teams-controller API', () => {
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' },
{
name: 'bob',
role: 'developer',
providerId: 'anthropic',
model: 'opencode/minimax-m2.5-free',
},
];
fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
@ -974,6 +1003,34 @@ describe('agent-teams-controller API', () => {
expect(controller.tasks.getTask(deletedTask.id).status).toBe('deleted');
});
it('allows direct manual approval kanban shortcut for completed tasks outside review', () => {
const claudeDir = makeClaudeDir();
const controller = createController({ teamName: 'my-team', claudeDir });
const task = controller.tasks.createTask({
subject: 'Completed manual shortcut',
owner: 'bob',
});
controller.tasks.completeTask(task.id, 'bob');
expect(() => controller.review.approveReview(task.id, { from: 'alice' })).toThrow(
'must be in review before approval'
);
expect(() => controller.kanban.setKanbanColumn(task.id, 'approved')).toThrow(
'must already be approved'
);
const state = controller.kanban.setKanbanColumn(task.id, 'approved', {
transition: 'manual_approve',
});
expect(state.tasks[task.id].column).toBe('approved');
const approvedTask = controller.tasks.getTask(task.id);
expect(approvedTask.reviewState).toBe('approved');
expect(
(approvedTask.historyEvents || []).filter((event) => event.type === 'review_approved')
).toHaveLength(0);
});
it('rejects review_start outside active review and keeps owner routing intact', async () => {
const claudeDir = makeClaudeDir();
const controller = createController({ teamName: 'my-team', claudeDir });
@ -1598,6 +1655,30 @@ describe('agent-teams-controller API', () => {
]);
});
it('uses Codex-native MCP wording in owner assignment notifications for Codex members', () => {
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: 'gpt-5.4-mini' },
];
fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
const controller = createController({ teamName: 'my-team', claudeDir });
controller.tasks.createTask({
subject: 'Implement Codex handoff',
owner: 'bob',
});
const inboxPath = path.join(claudeDir, 'teams', 'my-team', 'inboxes', 'bob.json');
const rows = JSON.parse(fs.readFileSync(inboxPath, 'utf8'));
expect(rows[0].text).toContain('MCP tool agent-teams_message_send');
expect(rows[0].text).toContain('Codex Native visible messaging rule');
expect(rows[0].text).toContain('mcp__agent-teams__task_get');
expect(rows[0].text).not.toContain('notify your lead via SendMessage');
});
it('does not wake owner for self-comments and keeps user clarification sticky until explicitly cleared', () => {
const claudeDir = makeClaudeDir();
const controller = createController({ teamName: 'my-team', claudeDir });

View file

@ -43,7 +43,7 @@ declare module 'agent-teams-controller' {
unlinkTask(taskId: string, targetId: string, linkType: string): unknown;
memberBriefing(
memberName: string,
options?: { runtimeProvider?: 'native' | 'opencode'; includeActiveProcesses?: boolean }
options?: { runtimeProvider?: 'native' | 'opencode' | 'codex'; includeActiveProcesses?: boolean }
): Promise<string>;
leadBriefing(): Promise<string>;
taskBriefing(memberName: string): Promise<string>;
@ -51,7 +51,7 @@ declare module 'agent-teams-controller' {
export interface ControllerKanbanApi {
getKanbanState(): unknown;
setKanbanColumn(taskId: string, column: string): unknown;
setKanbanColumn(taskId: string, column: string, options?: Record<string, unknown>): unknown;
clearKanban(taskId: string): unknown;
listReviewers(): string[];
addReviewer(reviewer: string): string[];

View file

@ -622,7 +622,7 @@ export function registerTaskTools(server: Pick<FastMCP, 'addTool'>) {
parameters: z.object({
...toolContextSchema,
memberName: z.string().min(1),
runtimeProvider: z.enum(['native', 'opencode']).optional(),
runtimeProvider: z.enum(['native', 'opencode', 'codex']).optional(),
includeActiveProcesses: z.boolean().optional(),
}),
execute: async ({

View file

@ -14,6 +14,63 @@ const controlContextSchema = {
const reportStateSchema = z.enum(['still_working', 'blocked', 'caught_up']);
function asRecord(value: unknown): Record<string, unknown> | null {
return value && typeof value === 'object' && !Array.isArray(value)
? (value as Record<string, unknown>)
: null;
}
function buildRequiredReportFollowUp(input: {
status: unknown;
teamName: string;
memberName?: string;
from?: string;
controlUrl?: string;
}) {
const status = asRecord(input.status);
const agenda = asRecord(status?.agenda);
const agendaFingerprint =
typeof agenda?.fingerprint === 'string' && agenda.fingerprint.trim()
? agenda.fingerprint.trim()
: null;
const reportToken =
typeof status?.reportToken === 'string' && status.reportToken.trim()
? status.reportToken.trim()
: null;
if (!status || !agendaFingerprint || !reportToken) {
return input.status;
}
const memberName =
input.memberName?.trim() ||
input.from?.trim() ||
(typeof status.memberName === 'string' ? status.memberName.trim() : '');
const items = Array.isArray(agenda?.items) ? agenda.items : [];
const taskIds = items
.map((item) => asRecord(item)?.taskId)
.filter((taskId): taskId is string => typeof taskId === 'string' && taskId.trim().length > 0);
const state = items.length > 0 ? 'still_working' : 'caught_up';
return {
...status,
statusOnlyIncomplete: true,
nextRequiredAction:
'Do not stop after member_work_sync_status. Call member_work_sync_report in this same turn using nextRequiredToolCall.arguments.',
nextRequiredToolCall: {
tool: 'member_work_sync_report',
arguments: {
teamName: input.teamName,
...(memberName ? { memberName } : {}),
...(input.controlUrl ? { controlUrl: input.controlUrl } : {}),
state,
agendaFingerprint,
reportToken,
...(taskIds.length ? { taskIds } : {}),
},
},
};
}
export function registerWorkSyncTools(server: Pick<FastMCP, 'addTool'>) {
server.addTool({
name: 'member_work_sync_status',
@ -26,12 +83,19 @@ export function registerWorkSyncTools(server: Pick<FastMCP, 'addTool'>) {
}),
execute: async ({ teamName, claudeDir, controlUrl, waitTimeoutMs, memberName, from }) => {
assertConfiguredTeam(teamName, claudeDir);
const status = await getController(teamName, claudeDir).workSync.memberWorkSyncStatus({
...(memberName ? { memberName } : {}),
...(from ? { from } : {}),
...(controlUrl ? { controlUrl } : {}),
...(waitTimeoutMs ? { waitTimeoutMs } : {}),
});
return jsonTextContent(
await getController(teamName, claudeDir).workSync.memberWorkSyncStatus({
buildRequiredReportFollowUp({
status,
teamName,
...(memberName ? { memberName } : {}),
...(from ? { from } : {}),
...(controlUrl ? { controlUrl } : {}),
...(waitTimeoutMs ? { waitTimeoutMs } : {}),
})
);
},

View file

@ -2,6 +2,7 @@ import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process';
import http from 'node:http';
import { fileURLToPath } from 'node:url';
function parseJsonToolResult(result: unknown) {
@ -16,7 +17,17 @@ function parseJsonToolResult(result: unknown) {
return JSON.parse(text ?? 'null');
}
async function writeTeamConfig(claudeDir: string, teamName: string) {
type TestTeamMember = Record<string, unknown>;
async function writeTeamConfig(
claudeDir: string,
teamName: string,
members: TestTeamMember[] = [
{ name: 'team-lead', agentType: 'team-lead' },
{ name: 'alice', agentType: 'teammate', role: 'developer' },
{ name: 'bob', agentType: 'teammate', role: 'reviewer' },
]
) {
const teamDir = path.join(claudeDir, 'teams', teamName);
await mkdir(teamDir, { recursive: true });
await writeFile(
@ -24,11 +35,7 @@ async function writeTeamConfig(claudeDir: string, teamName: string) {
JSON.stringify(
{
name: teamName,
members: [
{ name: 'team-lead', agentType: 'team-lead' },
{ name: 'alice', agentType: 'teammate', role: 'developer' },
{ name: 'bob', agentType: 'teammate', role: 'reviewer' },
],
members,
},
null,
2
@ -37,6 +44,45 @@ async function writeTeamConfig(claudeDir: string, teamName: string) {
);
}
async function startControlServer(
handler: (request: {
method?: string;
url?: string;
body?: unknown;
}) => Promise<{ statusCode?: number; body: unknown }> | { statusCode?: number; body: unknown }
) {
const server = http.createServer(async (req, res) => {
const chunks: Buffer[] = [];
req.on('data', (chunk) => chunks.push(chunk));
req.on('end', async () => {
try {
const bodyText = Buffer.concat(chunks).toString('utf8');
const body = bodyText ? JSON.parse(bodyText) : undefined;
const result = await handler({ method: req.method, url: req.url, body });
res.writeHead(result.statusCode ?? 200, { 'content-type': 'application/json' });
res.end(JSON.stringify(result.body));
} catch (error) {
res.writeHead(500, { 'content-type': 'application/json' });
res.end(JSON.stringify({ error: error instanceof Error ? error.message : String(error) }));
}
});
});
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', () => resolve()));
const address = server.address();
if (!address || typeof address === 'string') {
throw new Error('Failed to bind control server');
}
return {
baseUrl: `http://127.0.0.1:${address.port}`,
close: async () =>
await new Promise<void>((resolve, reject) =>
server.close((error) => (error ? reject(error) : resolve()))
),
};
}
async function writeBulkTaskRows(claudeDir: string, teamName: string, count: number) {
const tasksDir = path.join(claudeDir, 'tasks', teamName);
await mkdir(tasksDir, { recursive: true });
@ -1991,4 +2037,452 @@ describe('agent-teams-mcp stdio e2e', () => {
await client.close();
}
});
it('exposes Codex-native briefing and owner notifications over stdio MCP', async () => {
await writeTeamConfig(claudeDir, 'stdio-codex-team', [
{ name: 'team-lead', agentType: 'team-lead', providerId: 'codex', model: 'gpt-5.5' },
{
name: 'bob',
agentType: 'teammate',
role: 'developer',
providerId: 'codex',
model: 'gpt-5.4-mini',
},
]);
const client = new McpStdIoClient(serverPath, workspaceRoot);
try {
await client.initialize();
const briefingResult = await client.callTool(
'member_briefing',
{
claudeDir,
teamName: 'stdio-codex-team',
memberName: 'bob',
runtimeProvider: 'codex',
},
201
);
const briefingText = (
((briefingResult as { result: { content?: Array<{ text?: string }> } }).result
?.content?.[0]?.text as string | undefined) ?? ''
);
expect(briefingText).toContain('Codex Native visible messaging rule');
expect(briefingText).toContain('Codex Native task tool rule');
expect(briefingText).toContain('agent-teams_message_send');
expect(briefingText).toContain('mcp__agent-teams__task_get');
expect(briefingText).not.toContain('notify your team lead via SendMessage');
await client.callTool(
'task_create',
{
claudeDir,
teamName: 'stdio-codex-team',
subject: 'Codex stdio assignment',
owner: 'bob',
description: 'Verify Codex sees Agent Teams MCP tools over stdio.',
},
202
);
const inboxRaw = await readFile(
path.join(claudeDir, 'teams', 'stdio-codex-team', 'inboxes', 'bob.json'),
'utf8'
);
const inbox = JSON.parse(inboxRaw) as Array<{ text?: string }>;
const assignmentText = inbox[0]?.text ?? '';
expect(assignmentText).toContain('MCP tool agent-teams_message_send');
expect(assignmentText).toContain('Codex Native visible messaging rule');
expect(assignmentText).toContain('mcp__agent-teams__task_get');
expect(assignmentText).not.toContain('notify your lead via SendMessage');
} finally {
await client.close();
}
});
it('forwards work-sync status and report through real stdio MCP JSON-RPC', async () => {
await writeTeamConfig(claudeDir, 'stdio-work-sync-team', [
{ name: 'team-lead', agentType: 'team-lead' },
{ name: 'alice', agentType: 'teammate', role: 'developer' },
]);
const calls: Array<{ method?: string; url?: string; body?: unknown }> = [];
const controlServer = await startControlServer(async ({ method, url, body }) => {
calls.push({ method, url, body });
if (
method === 'POST' &&
url === '/api/teams/stdio-work-sync-team/member-work-sync/alice/refresh'
) {
return {
body: {
teamName: 'stdio-work-sync-team',
memberName: 'alice',
state: 'needs_sync',
agenda: {
teamName: 'stdio-work-sync-team',
memberName: 'alice',
generatedAt: '2026-04-29T00:00:00.000Z',
fingerprint: 'agenda:v1:stdio',
items: [],
diagnostics: [],
},
reportToken: 'wrs:v1.stdio.token',
reportTokenExpiresAt: '2026-04-29T00:15:00.000Z',
evaluatedAt: '2026-04-29T00:00:00.000Z',
diagnostics: ['no_current_report'],
},
};
}
if (method === 'POST' && url === '/api/teams/stdio-work-sync-team/member-work-sync/report') {
return { body: { accepted: true, code: 'accepted', status: body } };
}
return { statusCode: 404, body: { error: `Unhandled ${method} ${url}` } };
});
const client = new McpStdIoClient(serverPath, workspaceRoot);
try {
await client.initialize();
const statusResult = await client.callTool(
'member_work_sync_status',
{
claudeDir,
teamName: 'stdio-work-sync-team',
controlUrl: controlServer.baseUrl,
from: 'alice',
},
203
);
const status = parseJsonToolResult((statusResult as { result: unknown }).result);
expect(status.state).toBe('needs_sync');
expect(status.agenda.fingerprint).toBe('agenda:v1:stdio');
expect(status.statusOnlyIncomplete).toBe(true);
expect(status.nextRequiredToolCall).toMatchObject({
tool: 'member_work_sync_report',
arguments: {
teamName: 'stdio-work-sync-team',
memberName: 'alice',
controlUrl: controlServer.baseUrl,
state: 'caught_up',
agendaFingerprint: 'agenda:v1:stdio',
reportToken: 'wrs:v1.stdio.token',
},
});
const reportResult = await client.callTool(
'member_work_sync_report',
{
claudeDir,
teamName: 'stdio-work-sync-team',
controlUrl: controlServer.baseUrl,
memberName: 'alice',
state: 'still_working',
agendaFingerprint: 'agenda:v1:stdio',
reportToken: 'wrs:v1.stdio.token',
taskIds: ['task-1'],
note: 'Still working',
leaseTtlMs: 120000,
},
204
);
const report = parseJsonToolResult((reportResult as { result: unknown }).result);
expect(report.accepted).toBe(true);
expect(calls).toEqual([
{
method: 'POST',
url: '/api/teams/stdio-work-sync-team/member-work-sync/alice/refresh',
body: {},
},
{
method: 'POST',
url: '/api/teams/stdio-work-sync-team/member-work-sync/report',
body: expect.objectContaining({
memberName: 'alice',
state: 'still_working',
agendaFingerprint: 'agenda:v1:stdio',
reportToken: 'wrs:v1.stdio.token',
taskIds: ['task-1'],
note: 'Still working',
leaseTtlMs: 120000,
}),
},
]);
} finally {
await client.close();
await controlServer.close();
}
});
it('discovers work-sync control endpoint from env in a real stdio MCP process', async () => {
await writeTeamConfig(claudeDir, 'stdio-work-sync-env-team', [
{ name: 'team-lead', agentType: 'team-lead' },
{ name: 'alice', agentType: 'teammate', role: 'developer' },
]);
const calls: Array<{ method?: string; url?: string; body?: unknown }> = [];
const controlServer = await startControlServer(async ({ method, url, body }) => {
calls.push({ method, url, body });
if (
method === 'POST' &&
url === '/api/teams/stdio-work-sync-env-team/member-work-sync/alice/refresh'
) {
return {
body: {
teamName: 'stdio-work-sync-env-team',
memberName: 'alice',
state: 'needs_sync',
agenda: {
teamName: 'stdio-work-sync-env-team',
memberName: 'alice',
generatedAt: '2026-04-29T00:00:00.000Z',
fingerprint: 'agenda:v1:stdio-env',
items: [{ taskId: 'task-env-1' }],
diagnostics: [],
},
reportToken: 'wrs:v1.stdio.env.token',
reportTokenExpiresAt: '2026-04-29T00:15:00.000Z',
evaluatedAt: '2026-04-29T00:00:00.000Z',
diagnostics: ['no_current_report'],
},
};
}
if (
method === 'POST' &&
url === '/api/teams/stdio-work-sync-env-team/member-work-sync/report'
) {
return { body: { accepted: true, code: 'accepted', status: body } };
}
return { statusCode: 404, body: { error: `Unhandled ${method} ${url}` } };
});
const previousControlUrl = process.env.CLAUDE_TEAM_CONTROL_URL;
process.env.CLAUDE_TEAM_CONTROL_URL = controlServer.baseUrl;
const client = new McpStdIoClient(serverPath, workspaceRoot);
try {
await client.initialize();
const statusResult = await client.callTool(
'member_work_sync_status',
{
claudeDir,
teamName: 'stdio-work-sync-env-team',
from: 'alice',
},
205
);
const status = parseJsonToolResult((statusResult as { result: unknown }).result);
expect(status.nextRequiredToolCall).toMatchObject({
tool: 'member_work_sync_report',
arguments: {
teamName: 'stdio-work-sync-env-team',
memberName: 'alice',
state: 'still_working',
agendaFingerprint: 'agenda:v1:stdio-env',
reportToken: 'wrs:v1.stdio.env.token',
taskIds: ['task-env-1'],
},
});
const reportResult = await client.callTool(
'member_work_sync_report',
{
claudeDir,
teamName: 'stdio-work-sync-env-team',
memberName: 'alice',
state: 'still_working',
agendaFingerprint: 'agenda:v1:stdio-env',
reportToken: 'wrs:v1.stdio.env.token',
taskIds: ['task-env-1'],
},
206
);
const report = parseJsonToolResult((reportResult as { result: unknown }).result);
expect(report.accepted).toBe(true);
expect(calls.map((call) => call.url)).toEqual([
'/api/teams/stdio-work-sync-env-team/member-work-sync/alice/refresh',
'/api/teams/stdio-work-sync-env-team/member-work-sync/report',
]);
} finally {
if (previousControlUrl === undefined) {
delete process.env.CLAUDE_TEAM_CONTROL_URL;
} else {
process.env.CLAUDE_TEAM_CONTROL_URL = previousControlUrl;
}
await client.close();
await controlServer.close();
}
});
it('falls back from stale explicit work-sync control URL to state file in real stdio MCP', async () => {
await writeTeamConfig(claudeDir, 'stdio-work-sync-stale-team', [
{ name: 'team-lead', agentType: 'team-lead' },
{ name: 'alice', agentType: 'teammate', role: 'developer' },
]);
const staleCalls: Array<{ method?: string; url?: string }> = [];
const freshCalls: Array<{ method?: string; url?: string; body?: unknown }> = [];
const staleServer = await startControlServer(async ({ method, url }) => {
staleCalls.push({ method, url });
return { statusCode: 404, body: { error: 'stale control server' } };
});
const freshServer = await startControlServer(async ({ method, url, body }) => {
freshCalls.push({ method, url, body });
if (
method === 'POST' &&
url === '/api/teams/stdio-work-sync-stale-team/member-work-sync/alice/refresh'
) {
return {
body: {
teamName: 'stdio-work-sync-stale-team',
memberName: 'alice',
state: 'needs_sync',
agenda: {
teamName: 'stdio-work-sync-stale-team',
memberName: 'alice',
generatedAt: '2026-04-29T00:00:00.000Z',
fingerprint: 'agenda:v1:stdio-stale-fallback',
items: [{ taskId: 'task-stale-1' }],
diagnostics: [],
},
reportToken: 'wrs:v1.stdio.stale.token',
reportTokenExpiresAt: '2026-04-29T00:15:00.000Z',
evaluatedAt: '2026-04-29T00:00:00.000Z',
diagnostics: ['no_current_report'],
},
};
}
if (
method === 'POST' &&
url === '/api/teams/stdio-work-sync-stale-team/member-work-sync/report'
) {
return { body: { accepted: true, code: 'accepted', status: body } };
}
return { statusCode: 404, body: { error: `Unhandled ${method} ${url}` } };
});
await writeFile(
path.join(claudeDir, 'team-control-api.json'),
JSON.stringify({ baseUrl: freshServer.baseUrl, updatedAt: new Date().toISOString() }),
'utf8'
);
const client = new McpStdIoClient(serverPath, workspaceRoot);
try {
await client.initialize();
const statusResult = await client.callTool(
'member_work_sync_status',
{
claudeDir,
teamName: 'stdio-work-sync-stale-team',
controlUrl: staleServer.baseUrl,
from: 'alice',
},
207
);
const status = parseJsonToolResult((statusResult as { result: unknown }).result);
expect(status.agenda.fingerprint).toBe('agenda:v1:stdio-stale-fallback');
const reportResult = await client.callTool(
'member_work_sync_report',
{
claudeDir,
teamName: 'stdio-work-sync-stale-team',
controlUrl: staleServer.baseUrl,
memberName: 'alice',
state: 'still_working',
agendaFingerprint: 'agenda:v1:stdio-stale-fallback',
reportToken: 'wrs:v1.stdio.stale.token',
taskIds: ['task-stale-1'],
},
208
);
const report = parseJsonToolResult((reportResult as { result: unknown }).result);
expect(report.accepted).toBe(true);
expect(staleCalls.map((call) => call.url)).toEqual([
'/api/teams/stdio-work-sync-stale-team/member-work-sync/alice/refresh',
'/api/teams/stdio-work-sync-stale-team/member-work-sync/report',
]);
expect(freshCalls.map((call) => call.url)).toEqual([
'/api/teams/stdio-work-sync-stale-team/member-work-sync/alice/refresh',
'/api/teams/stdio-work-sync-stale-team/member-work-sync/report',
]);
} finally {
await client.close();
await staleServer.close();
await freshServer.close();
}
});
it('records a pending work-sync report when control API is unavailable in real stdio MCP', async () => {
await writeTeamConfig(claudeDir, 'stdio-work-sync-pending-team', [
{ name: 'team-lead', agentType: 'team-lead' },
{ name: 'alice', agentType: 'teammate', role: 'developer' },
]);
const previousControlUrl = process.env.CLAUDE_TEAM_CONTROL_URL;
delete process.env.CLAUDE_TEAM_CONTROL_URL;
const client = new McpStdIoClient(serverPath, workspaceRoot);
try {
await client.initialize();
const reportResult = await client.callTool(
'member_work_sync_report',
{
claudeDir,
teamName: 'stdio-work-sync-pending-team',
memberName: 'alice',
state: 'still_working',
agendaFingerprint: 'agenda:v1:offline',
reportToken: 'wrs:v1.offline.token',
taskIds: ['task-offline-1'],
},
209
);
const report = parseJsonToolResult((reportResult as { result: unknown }).result);
expect(report).toMatchObject({
accepted: false,
pendingValidation: true,
code: 'pending_validation',
});
const pendingFile = JSON.parse(
await readFile(
path.join(
claudeDir,
'teams',
'stdio-work-sync-pending-team',
'.member-work-sync',
'pending-reports.json'
),
'utf8'
)
) as {
intents?: Record<
string,
{
reason?: string;
status?: string;
request?: { memberName?: string; agendaFingerprint?: string; taskIds?: string[] };
}
>;
};
const intents = Object.values(pendingFile.intents ?? {});
expect(intents).toHaveLength(1);
expect(intents[0]).toMatchObject({
reason: 'control_api_unavailable',
status: 'pending',
request: {
memberName: 'alice',
agendaFingerprint: 'agenda:v1:offline',
taskIds: ['task-offline-1'],
},
});
} finally {
if (previousControlUrl === undefined) {
delete process.env.CLAUDE_TEAM_CONTROL_URL;
} else {
process.env.CLAUDE_TEAM_CONTROL_URL = previousControlUrl;
}
await client.close();
}
});
});

View file

@ -448,6 +448,18 @@ describe('agent-teams-mcp tools', () => {
})
);
expect(status.state).toBe('needs_sync');
expect(status.statusOnlyIncomplete).toBe(true);
expect(status.nextRequiredToolCall).toMatchObject({
tool: 'member_work_sync_report',
arguments: {
teamName: 'alpha',
memberName: 'alice',
controlUrl: server.baseUrl,
state: 'caught_up',
agendaFingerprint: 'agenda:v1:abc',
reportToken: 'wrs:v1.test.token',
},
});
const report = parseJsonToolResult(
await getTool('member_work_sync_report').execute({
@ -538,6 +550,180 @@ describe('agent-teams-mcp tools', () => {
}
});
it('discovers the work-sync control endpoint from the published state file', async () => {
const claudeDir = makeClaudeDir();
writeTeamConfig(claudeDir, 'alpha', {
members: [
{ name: 'lead', role: 'team-lead' },
{ name: 'alice', role: 'developer' },
],
});
const statePath = path.join(claudeDir, 'team-control-api.json');
const calls: Array<{ method?: string; url?: string; body?: unknown }> = [];
const server = await startControlServer(async ({ method, url, body }) => {
calls.push({ method, url, body });
if (method === 'POST' && url === '/api/teams/alpha/member-work-sync/alice/refresh') {
return {
body: {
teamName: 'alpha',
memberName: 'alice',
state: 'needs_sync',
agenda: {
teamName: 'alpha',
memberName: 'alice',
generatedAt: '2026-04-29T00:00:00.000Z',
fingerprint: 'agenda:v1:state-file',
items: [{ taskId: 'task-1' }],
diagnostics: [],
},
reportToken: 'wrs:v1.state.file.token',
reportTokenExpiresAt: '2026-04-29T00:15:00.000Z',
evaluatedAt: '2026-04-29T00:00:00.000Z',
diagnostics: ['no_current_report'],
},
};
}
if (method === 'POST' && url === '/api/teams/alpha/member-work-sync/report') {
return { body: { accepted: true, code: 'accepted', status: body } };
}
return { statusCode: 404, body: { error: `Unhandled ${method} ${url}` } };
});
try {
fs.writeFileSync(
statePath,
JSON.stringify({ baseUrl: server.baseUrl, updatedAt: new Date().toISOString() }, null, 2)
);
const status = parseJsonToolResult(
await getTool('member_work_sync_status').execute({
claudeDir,
teamName: 'alpha',
from: 'alice',
})
);
expect(status.nextRequiredToolCall).toMatchObject({
tool: 'member_work_sync_report',
arguments: {
teamName: 'alpha',
memberName: 'alice',
state: 'still_working',
agendaFingerprint: 'agenda:v1:state-file',
reportToken: 'wrs:v1.state.file.token',
taskIds: ['task-1'],
},
});
const report = parseJsonToolResult(
await getTool('member_work_sync_report').execute({
claudeDir,
teamName: 'alpha',
memberName: 'alice',
state: 'still_working',
agendaFingerprint: 'agenda:v1:state-file',
reportToken: 'wrs:v1.state.file.token',
taskIds: ['task-1'],
})
);
expect(report.accepted).toBe(true);
expect(calls.map((call) => call.url)).toEqual([
'/api/teams/alpha/member-work-sync/alice/refresh',
'/api/teams/alpha/member-work-sync/report',
]);
} finally {
await server.close();
}
});
it('falls back from a stale explicit work-sync control URL to the published state file', async () => {
const claudeDir = makeClaudeDir();
writeTeamConfig(claudeDir, 'alpha', {
members: [
{ name: 'lead', role: 'team-lead' },
{ name: 'alice', role: 'developer' },
],
});
const statePath = path.join(claudeDir, 'team-control-api.json');
const staleCalls: Array<{ method?: string; url?: string }> = [];
const freshCalls: Array<{ method?: string; url?: string; body?: unknown }> = [];
const staleServer = await startControlServer(async ({ method, url }) => {
staleCalls.push({ method, url });
return { statusCode: 404, body: { error: 'stale control server' } };
});
const freshServer = await startControlServer(async ({ method, url, body }) => {
freshCalls.push({ method, url, body });
if (method === 'POST' && url === '/api/teams/alpha/member-work-sync/alice/refresh') {
return {
body: {
teamName: 'alpha',
memberName: 'alice',
state: 'needs_sync',
agenda: {
teamName: 'alpha',
memberName: 'alice',
generatedAt: '2026-04-29T00:00:00.000Z',
fingerprint: 'agenda:v1:fresh-state-file',
items: [{ taskId: 'task-1' }],
diagnostics: [],
},
reportToken: 'wrs:v1.fresh.state.token',
reportTokenExpiresAt: '2026-04-29T00:15:00.000Z',
evaluatedAt: '2026-04-29T00:00:00.000Z',
diagnostics: ['no_current_report'],
},
};
}
if (method === 'POST' && url === '/api/teams/alpha/member-work-sync/report') {
return { body: { accepted: true, code: 'accepted', status: body } };
}
return { statusCode: 404, body: { error: `Unhandled ${method} ${url}` } };
});
try {
fs.writeFileSync(
statePath,
JSON.stringify({ baseUrl: freshServer.baseUrl, updatedAt: new Date().toISOString() })
);
const status = parseJsonToolResult(
await getTool('member_work_sync_status').execute({
claudeDir,
teamName: 'alpha',
controlUrl: staleServer.baseUrl,
from: 'alice',
})
);
expect(status.agenda.fingerprint).toBe('agenda:v1:fresh-state-file');
const report = parseJsonToolResult(
await getTool('member_work_sync_report').execute({
claudeDir,
teamName: 'alpha',
controlUrl: staleServer.baseUrl,
memberName: 'alice',
state: 'still_working',
agendaFingerprint: 'agenda:v1:fresh-state-file',
reportToken: 'wrs:v1.fresh.state.token',
taskIds: ['task-1'],
})
);
expect(report.accepted).toBe(true);
expect(staleCalls.map((call) => call.url)).toEqual([
'/api/teams/alpha/member-work-sync/alice/refresh',
'/api/teams/alpha/member-work-sync/report',
]);
expect(freshCalls.map((call) => call.url)).toEqual([
'/api/teams/alpha/member-work-sync/alice/refresh',
'/api/teams/alpha/member-work-sync/report',
]);
} finally {
await staleServer.close();
await freshServer.close();
}
});
it('covers task lifecycle, attachments, relationships, kanban, and review flows', async () => {
const claudeDir = makeClaudeDir();
const teamName = 'alpha';
@ -813,6 +999,20 @@ describe('agent-teams-mcp tools', () => {
);
expect(openCodeMemberBriefingText).not.toContain('task_get_comment {');
expect(openCodeMemberBriefingText).not.toContain('notify your team lead via SendMessage');
const codexMemberBriefing = await getTool('member_briefing').execute({
claudeDir,
teamName,
memberName: 'alice',
runtimeProvider: 'codex',
});
const codexMemberBriefingText = (
codexMemberBriefing as { content: Array<{ text: string }> }
).content[0]?.text;
expect(codexMemberBriefingText).toContain('agent-teams_message_send');
expect(codexMemberBriefingText).toContain('Codex Native visible messaging rule');
expect(codexMemberBriefingText).toContain('mcp__agent-teams__task_get');
expect(codexMemberBriefingText).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 () => {
@ -1011,6 +1211,31 @@ describe('agent-teams-mcp tools', () => {
);
});
it('uses Codex-native MCP wording for task_create owner notifications', async () => {
const claudeDir = makeClaudeDir();
const teamName = 'codex-owner';
writeTeamConfig(claudeDir, teamName, {
members: [
{ name: 'lead', role: 'team-lead' },
{ name: 'bob', role: 'developer', providerId: 'codex', model: 'gpt-5.4-mini' },
],
});
await getTool('task_create').execute({
claudeDir,
teamName,
subject: 'Codex assigned work',
owner: 'bob',
});
const ownerInboxPath = path.join(claudeDir, 'teams', teamName, 'inboxes', 'bob.json');
const ownerInbox = JSON.parse(fs.readFileSync(ownerInboxPath, 'utf8'));
expect(ownerInbox[0].text).toContain('MCP tool agent-teams_message_send');
expect(ownerInbox[0].text).toContain('Codex Native visible messaging rule');
expect(ownerInbox[0].text).toContain('mcp__agent-teams__task_get');
expect(ownerInbox[0].text).not.toContain('notify your lead via SendMessage');
});
it('returns compact lead_briefing output and filtered task_list inventory', async () => {
const claudeDir = makeClaudeDir();
const teamName = 'lead-queue';

View file

@ -3,9 +3,10 @@
* Adapted from agent-flow's draw-edges.ts (Apache 2.0).
*/
import type { GraphNode, GraphEdge, GraphEdgeType } from '../ports/types';
import { COLORS } from '../constants/colors';
import { BEAM, MIN_VISIBLE_OPACITY } from '../constants/canvas-constants';
import { COLORS } from '../constants/colors';
import type { GraphEdge, GraphEdgeType, GraphNode } from '../ports/types';
// ─── Edge Type → Color/Width Mapping ────────────────────────────────────────
@ -93,6 +94,9 @@ export function drawEdges(
const isActive = hasActiveParticles.has(edge.id);
const isSelected = selectedEdgeId === edge.id;
const isHovered = !isSelected && hoveredEdgeId === edge.id;
if (edge.type === 'message' && !isActive && !isSelected && !isHovered) {
continue;
}
// Pulse alpha when particles are travelling: base 0.3 + 0.2 * sin wave
const alpha = isActive ? BEAM.activeAlpha + 0.2 * Math.sin(_time * 6) : BEAM.idleAlpha;
const focusAlpha = focusEdgeIds && !focusEdgeIds.has(edge.id) ? 0.1 : 1;

View file

@ -159,7 +159,7 @@ const GRID_UNDER_LEAD_DEFAULT_COLUMN_COUNT = 2;
const GRID_UNDER_LEAD_LEAD_GAP = 77.7;
const GRID_UNDER_LEAD_ROW_GAP = 77.7;
const ROW_ORBIT_MIN_OWNER_COUNT = 6;
const ROW_ORBIT_MAX_OWNER_COUNT = 12;
const ROW_ORBIT_MAX_OWNER_COUNT = 14;
const ROW_ORBIT_HORIZONTAL_GAP = Math.max(112, STABLE_SLOT_GEOMETRY.slotHorizontalGap);
const ROW_ORBIT_VERTICAL_GAP = Math.max(144, GRID_UNDER_LEAD_ROW_GAP);
const ROW_ORBIT_CENTRAL_GAP = 160;
@ -216,6 +216,7 @@ const ROW_ORBIT_ROW_COUNTS_BY_OWNER_COUNT: Readonly<Record<number, readonly numb
10: [3, 2, 2, 3],
11: [3, 3, 2, 3],
12: [3, 3, 3, 3],
14: [3, 3, 2, 3, 3],
};
const ROW_ORBIT_ASSIGNMENTS_BY_OWNER_COUNT: Readonly<
@ -1306,7 +1307,7 @@ function buildRowOrbitSlotConfigs(
layout?: GraphLayoutPort
): RowOrbitSlotConfig[] | null {
const rowCount = rowCounts.length;
const middleRowIndex = rowCount === 3 ? 1 : -1;
const middleRowIndex = getRowOrbitMiddleRowIndex(rowCounts);
const configs: RowOrbitSlotConfig[] = [];
const actualAssignments = ownerFootprints
.map((footprint) => layout?.slotAssignments?.[footprint.ownerId])
@ -1432,10 +1433,17 @@ function buildRowOrbitSlotFrames(
runtimeCentralExclusion: StableRect
): SlotFrame[] {
const rowConfigs = groupRowOrbitSlotConfigs(slotConfigs, rowCounts.length);
const middleRowIndex = rowCounts.length === 3 ? 1 : -1;
const middleRowIndex = getRowOrbitMiddleRowIndex(rowCounts);
const rowTopByIndex = resolveRowOrbitRowTops(rowConfigs, middleRowIndex, runtimeCentralExclusion);
const framesByOwnerId = new Map<string, SlotFrame>();
const fallbackColumnWidth = Math.max(...slotConfigs.map((config) => config.footprint.slotWidth));
const alignedColumnCenters = resolveAlignedThreeColumnCenters(
rowConfigs,
rowCounts,
middleRowIndex,
fallbackColumnWidth,
runtimeCentralExclusion
);
for (const row of rowConfigs) {
if (row.length === 0) {
@ -1444,8 +1452,9 @@ function buildRowOrbitSlotFrames(
if (row[0]?.band === 'middle') {
for (const config of row) {
const ownerX =
config.columnIndex === 0
const ownerX = alignedColumnCenters
? alignedColumnCenters[config.columnIndex === 0 ? 0 : 2]!
: config.columnIndex === 0
? runtimeCentralExclusion.left - ROW_ORBIT_CENTRAL_GAP - config.footprint.slotWidth / 2
: runtimeCentralExclusion.right +
ROW_ORBIT_CENTRAL_GAP +
@ -1464,10 +1473,12 @@ function buildRowOrbitSlotFrames(
const nextLeft = -getRowOrbitRowWidth(columnWidths) / 2;
for (const config of row) {
const ownerX =
nextLeft +
columnWidths.slice(0, config.columnIndex).reduce((sum, width) => sum + width, 0) +
config.columnIndex * ROW_ORBIT_HORIZONTAL_GAP +
columnWidths[config.columnIndex]! / 2;
alignedColumnCenters && columnCount === alignedColumnCenters.length
? alignedColumnCenters[config.columnIndex]!
: nextLeft +
columnWidths.slice(0, config.columnIndex).reduce((sum, width) => sum + width, 0) +
config.columnIndex * ROW_ORBIT_HORIZONTAL_GAP +
columnWidths[config.columnIndex]! / 2;
const ownerY = rowTop + getOwnerAnchorTopOffset();
framesByOwnerId.set(
config.footprint.ownerId,
@ -1482,6 +1493,79 @@ function buildRowOrbitSlotFrames(
});
}
function getRowOrbitMiddleRowIndex(rowCounts: readonly number[]): number {
const candidateIndex = Math.floor(rowCounts.length / 2);
return rowCounts.length % 2 === 1 && rowCounts[candidateIndex] === 2 ? candidateIndex : -1;
}
function resolveAlignedThreeColumnCenters(
rowConfigs: readonly (readonly RowOrbitSlotConfig[])[],
rowCounts: readonly number[],
middleRowIndex: number,
fallbackColumnWidth: number,
runtimeCentralExclusion: StableRect
): readonly [number, number, number] | null {
if (
rowCounts.length !== 5 ||
middleRowIndex < 0 ||
rowCounts[middleRowIndex] !== 2 ||
!rowCounts.every((columnCount, rowIndex) =>
rowIndex === middleRowIndex ? columnCount === 2 : columnCount === 3
)
) {
return null;
}
const columnWidths: [number, number, number] = [
fallbackColumnWidth,
fallbackColumnWidth,
fallbackColumnWidth,
];
for (const row of rowConfigs) {
for (const config of row) {
const columnIndex =
config.rowIndex === middleRowIndex && config.columnCount === 2
? config.columnIndex === 0
? 0
: 2
: config.columnIndex;
columnWidths[columnIndex] = Math.max(
columnWidths[columnIndex] ?? fallbackColumnWidth,
config.footprint.slotWidth
);
}
}
const baseCenters = resolveRowOrbitColumnCenters(columnWidths);
const centralClearance = ROW_ORBIT_CENTRAL_GAP + SLOT_GEOMETRY.centralHorizontalGap;
const minSideDistance = Math.max(
Math.abs(baseCenters[0]),
Math.abs(baseCenters[2]),
Math.abs(runtimeCentralExclusion.left) + centralClearance + columnWidths[0] / 2,
Math.abs(runtimeCentralExclusion.right) + centralClearance + columnWidths[2] / 2
);
return [-minSideDistance, 0, minSideDistance];
}
function resolveRowOrbitColumnCenters(
columnWidths: readonly [number, number, number]
): readonly [number, number, number] {
const rowWidth = getRowOrbitRowWidth(columnWidths);
const nextLeft = -rowWidth / 2;
const leftCenter = nextLeft + columnWidths[0] / 2;
const middleCenter = nextLeft + columnWidths[0] + ROW_ORBIT_HORIZONTAL_GAP + columnWidths[1] / 2;
const rightCenter =
nextLeft +
columnWidths[0] +
columnWidths[1] +
ROW_ORBIT_HORIZONTAL_GAP * 2 +
columnWidths[2] / 2;
return [leftCenter, middleCenter, rightCenter];
}
function groupRowOrbitSlotConfigs(
slotConfigs: readonly RowOrbitSlotConfig[],
rowCount: number

View file

@ -36,6 +36,8 @@ export interface GraphConfigPort {
showLogs?: boolean;
showTasks?: boolean;
showProcesses?: boolean;
/** Whether to show static graph edges by default */
showEdges?: boolean;
showCompletedTasks?: boolean;
showEdgeLabels?: boolean;

View file

@ -10,27 +10,10 @@
* ALL animation state (positions, particles, effects, time) lives in refs.
*/
import { useState, useCallback, useEffect, useLayoutEffect, useMemo, useRef } from 'react';
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { autoUpdate, computePosition, flip, offset, shift } from '@floating-ui/dom';
import type { GraphDataPort } from '../ports/GraphDataPort';
import type { GraphEventPort } from '../ports/GraphEventPort';
import type { GraphConfigPort } from '../ports/GraphConfigPort';
import type {
GraphEdge,
GraphLayoutMode,
GraphNode,
GraphOwnerSlotAssignment,
} from '../ports/types';
import type { StableRect } from '../layout/stableSlots';
import { GraphCanvas, type GraphCanvasHandle } from './GraphCanvas';
import { GraphControls, type GraphFilterState } from './GraphControls';
import { GraphOverlay } from './GraphOverlay';
import { GraphEdgeOverlay } from './GraphEdgeOverlay';
import { buildFocusState } from './buildFocusState';
import type { TransientHandoffCard } from './transientHandoffs';
import { useGraphSimulation } from '../hooks/useGraphSimulation';
import { useGraphCamera } from '../hooks/useGraphCamera';
import { useGraphInteraction } from '../hooks/useGraphInteraction';
import {
collectInteractiveEdgesInViewport,
findEdgeAt,
@ -38,8 +21,29 @@ import {
getEdgeMidpoint,
} from '../canvas/hit-detection';
import { ANIM, ANIM_SPEED } from '../constants/canvas-constants';
import { useGraphCamera } from '../hooks/useGraphCamera';
import { useGraphInteraction } from '../hooks/useGraphInteraction';
import { useGraphSimulation } from '../hooks/useGraphSimulation';
import { getLaunchAnchorScreenPlacement as buildLaunchAnchorScreenPlacement } from '../layout/launchAnchor';
import { buildFocusState } from './buildFocusState';
import { GraphCanvas, type GraphCanvasHandle } from './GraphCanvas';
import { GraphControls, type GraphFilterState } from './GraphControls';
import { GraphEdgeOverlay } from './GraphEdgeOverlay';
import { GraphOverlay } from './GraphOverlay';
import type { StableRect } from '../layout/stableSlots';
import type { GraphConfigPort } from '../ports/GraphConfigPort';
import type { GraphDataPort } from '../ports/GraphDataPort';
import type { GraphEventPort } from '../ports/GraphEventPort';
import type {
GraphEdge,
GraphLayoutMode,
GraphNode,
GraphOwnerSlotAssignment,
} from '../ports/types';
import type { TransientHandoffCard } from './transientHandoffs';
export interface GraphViewProps {
data: GraphDataPort;
events?: GraphEventPort;
@ -96,6 +100,20 @@ export interface GraphViewProps {
}) => React.ReactNode;
}
export function filterVisibleGraphEdges(
edges: GraphEdge[],
visibleNodeIds: ReadonlySet<string>,
showEdges: boolean,
activeParticleEdgeIds?: ReadonlySet<string>
): GraphEdge[] {
return edges.filter((edge) => {
if (!showEdges && !activeParticleEdgeIds?.has(edge.id)) {
return false;
}
return visibleNodeIds.has(edge.source) && visibleNodeIds.has(edge.target);
});
}
export function GraphView({
data,
events,
@ -127,7 +145,7 @@ export function GraphView({
showLogs: config?.showLogs ?? config?.showActivity ?? true,
showTasks: config?.showTasks ?? true,
showProcesses: config?.showProcesses ?? true,
showEdges: true,
showEdges: config?.showEdges ?? false,
paused: !(config?.animationEnabled ?? true),
});
const effectivePaused = filters.paused || suspendAnimation;
@ -214,13 +232,12 @@ export function GraphView({
);
const getVisibleEdges = useCallback(
(edges: GraphEdge[], visibleNodeIds: ReadonlySet<string>): GraphEdge[] =>
edges.filter((edge) => {
if (!filters.showEdges && edge.type !== 'parent-child') {
return false;
}
return visibleNodeIds.has(edge.source) && visibleNodeIds.has(edge.target);
}),
(
edges: GraphEdge[],
visibleNodeIds: ReadonlySet<string>,
activeParticleEdgeIds?: ReadonlySet<string>
): GraphEdge[] =>
filterVisibleGraphEdges(edges, visibleNodeIds, filters.showEdges, activeParticleEdgeIds),
[filters.showEdges]
);
@ -376,7 +393,8 @@ export function GraphView({
const state = simulationRef.current.stateRef.current;
const visibleNodes = getVisibleNodes(state.nodes);
const visibleNodeIds = new Set(visibleNodes.map((node) => node.id));
const visibleEdges = getVisibleEdges(state.edges, visibleNodeIds);
const activeParticleEdgeIds = new Set(state.particles.map((particle) => particle.edgeId));
const visibleEdges = getVisibleEdges(state.edges, visibleNodeIds, activeParticleEdgeIds);
// 4. Draw canvas imperatively (NO React re-render)
canvasHandle.current?.draw({

View file

@ -479,6 +479,9 @@ importers:
'@mdi/js':
specifier: ^7.4.47
version: 7.4.47
'@mux/mux-video':
specifier: ^0.31.0
version: 0.31.0
'@nuxtjs/i18n':
specifier: ^9.5.6
version: 9.5.6(@vue/compiler-dom@3.5.30)(eslint@9.39.2(jiti@2.6.1))(magicast@0.5.2)(rollup@4.60.0)(vue@3.5.30(typescript@5.9.3))
@ -2015,6 +2018,15 @@ packages:
'@cfworker/json-schema':
optional: true
'@mux/mux-data-google-ima@0.3.17':
resolution: {integrity: sha512-4wpH6dYybyZhqLn9qGn/+67Z8MZnQRAdqTFEEZw2bx61M9q01uPYYHxd8qwOnYtUGEeafsdTwVHVxKHGD3oc1A==}
'@mux/mux-video@0.31.0':
resolution: {integrity: sha512-DvO2GynIJhPDc0LMuWvC144lCF+E07NI+chrg/vcRQlCf2fPdNNqCSMoaRdWu2S2v/Orsc85giT9K6GRbjbzgA==}
'@mux/playback-core@0.35.0':
resolution: {integrity: sha512-7Zi1EJ9sQNIUlQVBjJCXV0CB+rUVEsU3vNRElRV4xnD7dbpoioIAhe1SjZNBifjnK5aBKLDwQHypbnL3Cw3a5A==}
'@napi-rs/wasm-runtime@0.2.12':
resolution: {integrity: sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==}
@ -5552,6 +5564,9 @@ packages:
caniuse-lite@1.0.30001792:
resolution: {integrity: sha512-hVLMUZFgR4JJ6ACt1uEESvQN1/dBVqPAKY0hgrV70eN3391K6juAfTjKZLKvOMsx8PxA7gsY1/tLMMTcfFLLpw==}
castable-video@1.1.16:
resolution: {integrity: sha512-wBhe2dZu2afhewL3EaGgVYTyDsa9HvNhY98clMZkNzDrLelOValSrTaoMos9YX7PPBCrgpd1j6YmNyyI2Vbq3w==}
ccount@2.0.1:
resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==}
@ -5904,6 +5919,9 @@ packages:
csstype@3.2.3:
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
custom-media-element@1.4.6:
resolution: {integrity: sha512-/HRYqJOa1ob5ik4q7FIJVYxTJCFs/FL3+cQPAJjUf2uiqrDEzbTgB315gQ2rG8oK3w094W9m5tcB8S5Qah+caA==}
cytoscape-cose-bilkent@4.1.0:
resolution: {integrity: sha512-wgQlVIUJF13Quxiv5e1gstZ08rnZj2XaLHGoFMYXz7SkNfCDOOteKBE6SYRfA9WxxI/iBc3ajfDoc6hb/MRAHQ==}
peerDependencies:
@ -7274,6 +7292,9 @@ packages:
resolution: {integrity: sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==}
engines: {node: '>=12.0.0'}
hls.js@1.6.16:
resolution: {integrity: sha512-VSIRpLfRwlAAdGL4wiTucx2ScRipo0ed1FBatWkyt832jC4CReKstga6yIhYVwGu9LOBjuX9wzmRMeQdBJtzEA==}
hono@4.12.5:
resolution: {integrity: sha512-3qq+FUBtlTHhtYxbxheZgY8NIFnkkC/MR8u5TTsr7YZ3wixryQ3cCwn3iZbg8p8B88iDBBAYSfZDS75t8MN7Vg==}
engines: {node: '>=16.9.0'}
@ -8129,6 +8150,9 @@ packages:
mdurl@2.0.0:
resolution: {integrity: sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==}
media-tracks@0.3.5:
resolution: {integrity: sha512-l54rkKXlLBt3ob3zOLWHcnjvwUmX5bNEZ70igyapOZZC9imzqBmq1oz8p2roiV04KhjblFIi2hetLPF1oYVLRA==}
media-typer@1.1.0:
resolution: {integrity: sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==}
engines: {node: '>= 0.8'}
@ -8406,6 +8430,9 @@ packages:
multimath@2.0.0:
resolution: {integrity: sha512-toRx66cAMJ+Ccz7pMIg38xSIrtnbozk0dchXezwQDMgQmbGpfxjtv68H+L00iFL8hxDaVjrmwAFSb3I6bg8Q2g==}
mux-embed@5.18.1:
resolution: {integrity: sha512-ePsHjiEKY+FgrSBiMmaF+LOtTQSSBWv/1zqpREQFN96JE93xlsArT/MEi30yKOE06MgjOlL70YI750molu3y7g==}
mz@2.7.0:
resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==}
@ -12772,6 +12799,23 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@mux/mux-data-google-ima@0.3.17':
dependencies:
mux-embed: 5.18.1
'@mux/mux-video@0.31.0':
dependencies:
'@mux/mux-data-google-ima': 0.3.17
'@mux/playback-core': 0.35.0
castable-video: 1.1.16
custom-media-element: 1.4.6
media-tracks: 0.3.5
'@mux/playback-core@0.35.0':
dependencies:
hls.js: 1.6.16
mux-embed: 5.18.1
'@napi-rs/wasm-runtime@0.2.12':
dependencies:
'@emnapi/core': 1.8.1
@ -16590,6 +16634,10 @@ snapshots:
caniuse-lite@1.0.30001792: {}
castable-video@1.1.16:
dependencies:
custom-media-element: 1.4.6
ccount@2.0.1: {}
chai@5.3.3:
@ -16939,6 +16987,8 @@ snapshots:
csstype@3.2.3: {}
custom-media-element@1.4.6: {}
cytoscape-cose-bilkent@4.1.0(cytoscape@3.33.1):
dependencies:
cose-base: 1.0.3
@ -18805,6 +18855,8 @@ snapshots:
highlight.js@11.11.1: {}
hls.js@1.6.16: {}
hono@4.12.5: {}
hookable@5.5.3: {}
@ -19816,6 +19868,8 @@ snapshots:
mdurl@2.0.0: {}
media-tracks@0.3.5: {}
media-typer@1.1.0: {}
medium-zoom@1.1.0: {}
@ -20189,6 +20243,8 @@ snapshots:
glur: 1.1.2
object-assign: 4.1.1
mux-embed@5.18.1: {}
mz@2.7.0:
dependencies:
any-promise: 1.3.0

View file

@ -999,6 +999,7 @@ export class TeamGraphAdapter {
memberNodeIdByAlias: ReadonlyMap<string, string>
): void {
const ordered = [...messages].reverse();
TeamGraphAdapter.#ensureMessageEdges(messages, leadId, leadName, edges, memberNodeIdByAlias);
// First call: record all existing message IDs without creating particles.
// This prevents old messages from spawning particles when the graph opens.
@ -1079,24 +1080,21 @@ export class TeamGraphAdapter {
continue;
}
const edgeId = TeamGraphAdapter.#resolveMessageEdge(
const edge = TeamGraphAdapter.#resolveMessageEdge(
msg,
leadId,
leadName,
edges,
memberNodeIdByAlias
);
if (!edgeId) continue;
if (!edge) continue;
// Determine direction: messages FROM a teammate TO lead should reverse
// (edges are always lead→member, but message goes member→lead)
const fromId = TeamGraphAdapter.#resolveParticipantId(
msg.from ?? '',
leadId,
leadName,
memberNodeIdByAlias
);
const isFromTeammate = fromId !== leadId;
const particleLabel =
getIdleGraphLabel(msgText) ??
@ -1104,7 +1102,7 @@ export class TeamGraphAdapter {
particles.push({
id: `particle:msg:${teamName}:${msgKey}`,
edgeId,
edgeId: edge.id,
progress: 0,
kind: 'inbox_message',
color: msg.color ?? '#66ccff',
@ -1112,7 +1110,7 @@ export class TeamGraphAdapter {
preview:
getIdleGraphLabel(msgText) ??
TeamGraphAdapter.#buildParticlePreview(msg.summary ?? msg.text),
reverse: isFromTeammate,
reverse: edge.source !== fromId,
});
}
@ -1128,6 +1126,26 @@ export class TeamGraphAdapter {
}
}
static #ensureMessageEdges(
messages: readonly InboxMessage[],
leadId: string,
leadName: string,
edges: GraphEdge[],
memberNodeIdByAlias: ReadonlyMap<string, string>
): void {
for (const msg of messages) {
if (!msg.from || !msg.to) continue;
if (msg.summary?.startsWith('Comment on ')) continue;
if (msg.source === 'cross_team' || msg.source === 'cross_team_sent') continue;
const msgText = msg.text ?? '';
const idleSemantic = classifyIdleNotificationText(msgText);
if (!idleSemantic && isInboxNoiseMessage(msgText)) continue;
TeamGraphAdapter.#resolveMessageEdge(msg, leadId, leadName, edges, memberNodeIdByAlias);
}
}
#buildCommentParticles(
particles: GraphParticle[],
data: TeamGraphData,
@ -1137,6 +1155,15 @@ export class TeamGraphAdapter {
edges: GraphEdge[],
memberNodeIdByAlias: ReadonlyMap<string, string>
): void {
TeamGraphAdapter.#ensureTaskCommentEdges(
data,
teamName,
leadId,
leadName,
edges,
memberNodeIdByAlias
);
// First call: record current comment counts without creating particles.
// This prevents pre-existing comments from spawning particles when the graph opens.
if (!this.#initialCommentsSeen) {
@ -1178,35 +1205,26 @@ export class TeamGraphAdapter {
leadName,
memberNodeIdByAlias
);
const taskNodeId = `task:${teamName}:${task.id}`;
const authorEdge =
edges.find((e) => e.source === authorNodeId && e.target === taskNodeId) ??
edges.find((e) => e.source === taskNodeId && e.target === authorNodeId);
const edge = TeamGraphAdapter.#resolveTaskCommentEdge(
task,
newComment.author,
teamName,
leadId,
leadName,
edges,
memberNodeIdByAlias
);
const edgeId =
authorEdge?.id ??
(() => {
const syntheticEdgeId = `edge:msg:${authorNodeId}:${taskNodeId}`;
if (!edges.some((edge) => edge.id === syntheticEdgeId)) {
edges.push({
id: syntheticEdgeId,
source: authorNodeId,
target: taskNodeId,
type: 'message',
});
}
return syntheticEdgeId;
})();
if (authorNodeId) {
if (edge) {
particles.push({
id: `particle:comment:${teamName}:${task.id}:${index + 1}`,
edgeId,
edgeId: edge.id,
progress: 0,
kind: 'task_comment',
color: memberColors.get(newComment.author) ?? '#cc88ff',
label: TeamGraphAdapter.#buildParticleLabel(newComment.text, 'comment'),
preview: TeamGraphAdapter.#buildParticlePreview(newComment.text),
reverse: edge.source !== authorNodeId,
});
}
}
@ -1216,6 +1234,31 @@ export class TeamGraphAdapter {
}
}
static #ensureTaskCommentEdges(
data: TeamGraphData,
teamName: string,
leadId: string,
leadName: string,
edges: GraphEdge[],
memberNodeIdByAlias: ReadonlyMap<string, string>
): void {
for (const task of data.tasks) {
if (task.status === 'deleted') continue;
for (const comment of task.comments ?? []) {
if (comment.type !== 'regular') continue;
TeamGraphAdapter.#resolveTaskCommentEdge(
task,
comment.author,
teamName,
leadId,
leadName,
edges,
memberNodeIdByAlias
);
}
}
}
// ─── Static mappers ──────────────────────────────────────────────────────
static #buildBlockingEdgeId(sourceNodeId: string, targetNodeId: string): string {
@ -1313,7 +1356,7 @@ export class TeamGraphAdapter {
leadName: string,
edges: GraphEdge[],
memberNodeIdByAlias: ReadonlyMap<string, string>
): string | null {
): GraphEdge | null {
const { from, to } = msg;
if (from && to) {
@ -1329,11 +1372,7 @@ export class TeamGraphAdapter {
leadName,
memberNodeIdByAlias
);
return (
edges.find((e) => e.source === fromId && e.target === toId)?.id ??
edges.find((e) => e.source === toId && e.target === fromId)?.id ??
null
);
return TeamGraphAdapter.#resolveNodePairMessageEdge(fromId, toId, edges);
}
if (from && !to) {
@ -1348,13 +1387,83 @@ export class TeamGraphAdapter {
(e) =>
(e.source === leadId && e.target === fromId) ||
(e.source === fromId && e.target === leadId)
)?.id ?? null
) ?? null
);
}
return null;
}
static #resolveTaskCommentEdge(
task: TeamGraphData['tasks'][number],
authorName: string,
teamName: string,
leadId: string,
leadName: string,
edges: GraphEdge[],
memberNodeIdByAlias: ReadonlyMap<string, string>
): GraphEdge | null {
const authorNodeId = TeamGraphAdapter.#resolveParticipantId(
authorName,
leadId,
leadName,
memberNodeIdByAlias
);
const ownerNodeId = TeamGraphAdapter.#resolveTaskOwnerId(
task.owner,
leadId,
leadName,
memberNodeIdByAlias
);
if (ownerNodeId && ownerNodeId !== authorNodeId) {
return TeamGraphAdapter.#resolveNodePairMessageEdge(authorNodeId, ownerNodeId, edges);
}
const taskNodeId = `task:${teamName}:${task.id}`;
const authorEdge =
edges.find((e) => e.source === authorNodeId && e.target === taskNodeId) ??
edges.find((e) => e.source === taskNodeId && e.target === authorNodeId);
if (authorEdge) {
return authorEdge;
}
const syntheticEdge: GraphEdge = {
id: `edge:msg:${authorNodeId}:${taskNodeId}`,
source: authorNodeId,
target: taskNodeId,
type: 'message',
targetTaskIds: [task.id],
};
edges.push(syntheticEdge);
return syntheticEdge;
}
static #resolveNodePairMessageEdge(
fromId: string,
toId: string,
edges: GraphEdge[]
): GraphEdge | null {
const existingEdge =
edges.find((e) => e.source === fromId && e.target === toId) ??
edges.find((e) => e.source === toId && e.target === fromId);
if (existingEdge) {
return existingEdge;
}
if (fromId === toId) {
return null;
}
const [sourceId, targetId] = fromId.localeCompare(toId) <= 0 ? [fromId, toId] : [toId, fromId];
const syntheticEdge: GraphEdge = {
id: `edge:msg:${sourceId}:${targetId}`,
source: sourceId,
target: targetId,
type: 'message',
};
edges.push(syntheticEdge);
return syntheticEdge;
}
static #resolveParticipantId(
name: string,
leadId: string,

View file

@ -48,6 +48,7 @@ interface GraphActivityHudProps {
getViewportSize?: () => { width: number; height: number };
focusNodeIds: ReadonlySet<string> | null;
enabled?: boolean;
showConnectors?: boolean;
onOpenTaskDetail?: (taskId: string) => void;
onOpenMemberProfile?: (
memberName: string,
@ -72,6 +73,7 @@ export const GraphActivityHud = ({
getViewportSize,
focusNodeIds,
enabled = true,
showConnectors = true,
onOpenTaskDetail,
onOpenMemberProfile,
}: GraphActivityHudProps): React.JSX.Element | null => {
@ -87,8 +89,8 @@ export const GraphActivityHud = ({
);
const { teamData, teams } = useGraphActivityContext(teamName);
const teamSnapshot = teamData;
const members = teamData?.members ?? [];
const messages = teamData?.messageFeed ?? [];
const members = useMemo(() => teamData?.members ?? [], [teamData?.members]);
const messages = useMemo(() => teamData?.messageFeed ?? [], [teamData?.messageFeed]);
const ownerNodes = useMemo(
() =>
@ -134,11 +136,12 @@ export const GraphActivityHud = ({
}, [teamName]);
useEffect(() => {
const timers = highlightTimersRef.current;
return () => {
for (const timer of highlightTimersRef.current.values()) {
for (const timer of timers.values()) {
clearTimeout(timer);
}
highlightTimersRef.current.clear();
timers.clear();
};
}, []);
@ -515,24 +518,27 @@ export const GraphActivityHud = ({
return (
<>
<svg
ref={(element) => {
connectorRefs.current.set(lane.node.id, element);
}}
className="pointer-events-none absolute z-[9] overflow-visible opacity-0"
>
<path
{showConnectors ? (
<svg
ref={(element) => {
connectorPathRefs.current.set(lane.node.id, element);
connectorRefs.current.set(lane.node.id, element);
}}
d=""
fill="none"
stroke="rgba(148, 163, 184, 0.3)"
strokeWidth="1.25"
strokeLinecap="round"
strokeDasharray="3 4"
/>
</svg>
data-activity-connector={lane.node.id}
className="pointer-events-none absolute z-[9] overflow-visible opacity-0"
>
<path
ref={(element) => {
connectorPathRefs.current.set(lane.node.id, element);
}}
d=""
fill="none"
stroke="rgba(148, 163, 184, 0.3)"
strokeWidth="1.25"
strokeLinecap="round"
strokeDasharray="3 4"
/>
</svg>
) : null}
<div
ref={(element) => {
shellRefs.current.set(lane.node.id, element);
@ -543,9 +549,6 @@ export const GraphActivityHud = ({
maxWidth: `${laneWidth}px`,
height: `${laneHeight}px`,
}}
onDragStart={(event) => {
event.preventDefault();
}}
>
<div className="flex h-full min-w-0 max-w-full flex-col overflow-hidden">
<div className="mb-1 px-1 text-[10px] font-semibold tracking-[0.2em] text-slate-400/70">

View file

@ -163,6 +163,7 @@ export const TeamGraphOverlay = ({
getViewportSize={getViewportSize}
focusNodeIds={focusNodeIds}
enabled={filters?.showActivity ?? true}
showConnectors={filters?.showEdges ?? false}
onOpenTaskDetail={interactions.openTaskDetail}
onOpenMemberProfile={interactions.openMemberProfile}
/>

View file

@ -163,6 +163,7 @@ export const TeamGraphTab = ({
getViewportSize={getViewportSize}
focusNodeIds={focusNodeIds}
enabled={isActive && (filters?.showActivity ?? true)}
showConnectors={filters?.showEdges ?? false}
onOpenTaskDetail={interactions.openTaskDetail}
onOpenMemberProfile={interactions.openMemberProfile}
/>

View file

@ -1,7 +1,4 @@
import {
isReviewPickupAgenda,
isStrictReviewPickupItem,
} from './MemberWorkSyncNudgeAgendaPredicates';
import { isStrictReviewPickupItem } from './MemberWorkSyncNudgeAgendaPredicates';
import {
decideMemberWorkSyncTargetedRecovery,
type MemberWorkSyncTargetedRecoveryReason,
@ -187,15 +184,23 @@ export function decideMemberWorkSyncNudgeActivation(input: {
return { active: true, reason: 'review_pickup_required' };
}
const targetedRecovery = decideMemberWorkSyncTargetedRecovery(input.status);
if (targetedRecovery.active) {
return { active: true, reason: targetedRecovery.reason };
}
if (isNativeStaleInProgressCandidate(input)) {
return { active: true, reason: 'native_stale_in_progress' };
}
const targetedRecovery = decideMemberWorkSyncTargetedRecovery(input.status);
if (targetedRecovery.active) {
if (targetedRecovery.reason !== 'native_targeted_shadow_collecting') {
return { active: true, reason: targetedRecovery.reason };
}
if (hasBlockingMetrics(input.metrics)) {
return { active: false, reason: 'blocking_metrics' };
}
if (input.metrics.phase2Readiness.state !== 'shadow_ready') {
return { active: true, reason: targetedRecovery.reason };
}
}
if (hasBlockingMetrics(input.metrics)) {
return { active: false, reason: 'blocking_metrics' };
}

View file

@ -72,6 +72,10 @@ function getProofMissingRecoveryOriginalMessageId(item: MemberWorkSyncOutboxItem
return originalMessageId.length > 0 ? originalMessageId : null;
}
function isStatusOnlyRecoveryOutboxItem(item: MemberWorkSyncOutboxItem): boolean {
return item.payload.workSyncIntentKey?.startsWith('status-only:') === true;
}
function getPayloadReviewRequestEventIds(item: MemberWorkSyncOutboxItem): string[] {
return [...new Set(item.payload.workSyncReviewRequestEventIds ?? [])]
.filter((id) => id.length > 0)
@ -504,7 +508,10 @@ export class MemberWorkSyncNudgeDispatcher {
workSyncIntentKey: item.payload.workSyncIntentKey,
taskRefs: item.payload.taskRefs,
});
if (busy?.busy) {
if (
busy?.busy &&
!(isStatusOnlyRecoveryOutboxItem(item) && busy.reason === 'recent_tool_activity')
) {
return {
ok: false,
reason: `member_busy:${busy.reason ?? 'unknown'}`,

View file

@ -1,11 +1,17 @@
import { buildMemberWorkSyncOutboxEnsureInput } from '../domain';
import {
buildMemberWorkSyncNudgeId,
buildMemberWorkSyncNudgePayloadHash,
buildMemberWorkSyncOutboxEnsureInput,
} from '../domain';
import { appendMemberWorkSyncAudit } from './MemberWorkSyncAudit';
import { decideMemberWorkSyncNudgeActivation } from './MemberWorkSyncNudgeActivationPolicy';
import type { MemberWorkSyncStatus } from '../../contracts';
import type { MemberWorkSyncOutboxEnsureInput, MemberWorkSyncStatus } from '../../contracts';
import type { MemberWorkSyncUseCaseDeps } from './ports';
const STATUS_ONLY_RECOVERY_INTENT_PREFIX = 'status-only';
function getReviewRequestEventIds(status: MemberWorkSyncStatus): string[] {
return [
...new Set(
@ -33,6 +39,26 @@ function filterReviewPickupStatusByRequestIds(
};
}
function isTurnSettledReconcile(status: MemberWorkSyncStatus): boolean {
return status.shadow?.triggerReasons?.includes('turn_settled') === true;
}
function shouldPlanStatusOnlyRecovery(input: {
status: MemberWorkSyncStatus;
baseInput: MemberWorkSyncOutboxEnsureInput;
existingItemStatus: string;
}): boolean {
return (
input.status.state === 'needs_sync' &&
input.status.shadow?.wouldNudge === true &&
isTurnSettledReconcile(input.status) &&
input.baseInput.payload.workSyncIntent === 'agenda_sync' &&
input.baseInput.payload.workSyncIntentKey === undefined &&
input.existingItemStatus === 'delivered' &&
input.status.report?.accepted !== true
);
}
export interface MemberWorkSyncNudgeOutboxPlanResult {
planned: boolean;
code:
@ -52,6 +78,34 @@ export interface MemberWorkSyncNudgeOutboxPlanResult {
export class MemberWorkSyncNudgeOutboxPlanner {
constructor(private readonly deps: MemberWorkSyncUseCaseDeps) {}
private buildStatusOnlyRecoveryInput(
status: MemberWorkSyncStatus,
baseInput: MemberWorkSyncOutboxEnsureInput
): MemberWorkSyncOutboxEnsureInput {
const intentKey = `${STATUS_ONLY_RECOVERY_INTENT_PREFIX}:${status.agenda.fingerprint}`;
const payload = {
...baseInput.payload,
workSyncIntentKey: intentKey,
text: [
'Status-only recovery: the previous work-sync turn appears to have stopped after member_work_sync_status without member_work_sync_report.',
'You must now call member_work_sync_status again, then member_work_sync_report with the returned agendaFingerprint/reportToken.',
baseInput.payload.text,
].join('\n'),
};
return {
...baseInput,
id: buildMemberWorkSyncNudgeId({
teamName: status.teamName,
memberName: status.memberName,
agendaFingerprint: status.agenda.fingerprint,
intentKey,
}),
payload,
payloadHash: buildMemberWorkSyncNudgePayloadHash(this.deps.hash, payload),
};
}
async plan(status: MemberWorkSyncStatus): Promise<MemberWorkSyncNudgeOutboxPlanResult> {
if (!this.deps.outboxStore) {
return { planned: false, code: 'outbox_unavailable' };
@ -159,6 +213,35 @@ export class MemberWorkSyncNudgeOutboxPlanner {
await this.appendPlanAudit(status, { planned: false, code });
return { planned: false, code };
}
if (
shouldPlanStatusOnlyRecovery({
status,
baseInput: input,
existingItemStatus: result.item.status,
})
) {
const recoveryInput = this.buildStatusOnlyRecoveryInput(status, input);
const recoveryResult = await this.deps.outboxStore.ensurePending(recoveryInput);
if (!recoveryResult.ok) {
this.deps.logger?.warn('member work sync status-only recovery payload conflict', {
teamName: status.teamName,
memberName: status.memberName,
outboxId: recoveryInput.id,
existingPayloadHash: recoveryResult.existingPayloadHash,
requestedPayloadHash: recoveryResult.requestedPayloadHash,
});
await this.appendPlanAudit(status, { planned: false, code: 'payload_conflict' });
return { planned: false, code: 'payload_conflict' };
}
const recoveryPlanned = recoveryResult.item.status !== 'delivered';
const recoveryPlanResult = {
planned: recoveryPlanned,
code: recoveryResult.outcome,
} as const;
await this.appendPlanAudit(status, recoveryPlanResult);
return recoveryPlanResult;
}
if (
input.payload.workSyncIntent === 'review_pickup' &&
result.item.status === 'failed_terminal'

View file

@ -134,9 +134,7 @@ export class MemberWorkSyncReconciler {
});
await this.deps.statusStore.write(status);
if ((context.reconciledBy ?? 'request') === 'queue') {
await this.planNudgeOutbox(status);
}
await this.planNudgeOutbox(status);
return status;
}

View file

@ -4,11 +4,13 @@ import type { MemberWorkSyncStatus } from '../../contracts';
export type MemberWorkSyncTargetedRecoveryReason =
| 'opencode_targeted_shadow_collecting'
| 'lead_targeted_shadow_collecting';
| 'lead_targeted_shadow_collecting'
| 'native_targeted_shadow_collecting';
export type MemberWorkSyncTargetedRecoveryCapability =
| 'opencode_runtime_delivery'
| 'lead_inbox_relay';
| 'lead_inbox_relay'
| 'native_inbox_watch';
export type MemberWorkSyncTargetedRecoveryDecision =
| {
@ -31,6 +33,10 @@ function isLeadLikeMemberName(memberName: string): boolean {
);
}
function isNativeInboxWatchProvider(providerId: MemberWorkSyncStatus['providerId']): boolean {
return providerId === 'anthropic' || providerId === 'codex' || providerId === 'gemini';
}
function resolveTargetedRecoveryCapability(status: MemberWorkSyncStatus): {
capability: MemberWorkSyncTargetedRecoveryCapability;
reason: MemberWorkSyncTargetedRecoveryReason;
@ -49,6 +55,13 @@ function resolveTargetedRecoveryCapability(status: MemberWorkSyncStatus): {
};
}
if (isNativeInboxWatchProvider(status.providerId)) {
return {
capability: 'native_inbox_watch',
reason: 'native_targeted_shadow_collecting',
};
}
return null;
}

View file

@ -127,6 +127,8 @@ function buildReviewPickupNudgePayload(status: MemberWorkSyncStatus): MemberWork
preview ? `Review agenda: ${preview}.` : '',
'Open the task, verify the current reviewState/status, then start or continue the review only if it is still assigned to you.',
`If you cannot pick it up now, call member_work_sync_status with teamName "${status.teamName}" and memberName "${status.memberName}", then report "blocked" or "still_working" only for the real current state.`,
'Do not stop after member_work_sync_status. A status-only tool call is incomplete; member_work_sync_report is the required proof.',
'If your runtime exposes prefixed Agent Teams MCP tool names, use mcp__agent-teams__member_work_sync_status and mcp__agent-teams__member_work_sync_report.',
'Do not mark the review complete from this prompt alone, and do not reply only with acknowledgement.',
]
.filter(Boolean)
@ -169,6 +171,8 @@ export function buildMemberWorkSyncNudgePayload(
...buildProofMissingRecoveryText(status),
preview ? `Current agenda: ${preview}.` : '',
`Required sync action: call member_work_sync_status with teamName "${status.teamName}" and memberName "${status.memberName}", then call member_work_sync_report with the same teamName/memberName and the returned agendaFingerprint and reportToken.`,
'Do not stop after member_work_sync_status. A status-only tool call is incomplete; member_work_sync_report is the required proof.',
'If your runtime exposes prefixed Agent Teams MCP tool names, use mcp__agent-teams__member_work_sync_status and mcp__agent-teams__member_work_sync_report. Do not search the filesystem or list MCP resources before the first direct tool attempt.',
taskIds.length
? `When reporting, include taskIds: ${taskIds.map((id) => `"${id}"`).join(', ')}.`
: '',

View file

@ -6,7 +6,8 @@ import type { MemberWorkSyncInboxNudgePort } from '../../../core/application';
export class TeamInboxMemberWorkSyncNudgeSink implements MemberWorkSyncInboxNudgePort {
constructor(
private readonly inboxReader: Pick<TeamInboxReader, 'getMessagesFor'> = new TeamInboxReader(),
private readonly inboxWriter: Pick<TeamInboxWriter, 'sendMessage'> = new TeamInboxWriter()
private readonly inboxWriter: Pick<TeamInboxWriter, 'sendMessage'> = new TeamInboxWriter(),
private readonly controlUrlResolver?: () => Promise<string | null> | string | null
) {}
async insertIfAbsent(input: Parameters<MemberWorkSyncInboxNudgePort['insertIfAbsent']>[0]) {
@ -19,13 +20,17 @@ export class TeamInboxMemberWorkSyncNudgeSink implements MemberWorkSyncInboxNudg
return { inserted: false, messageId: input.messageId };
}
const controlUrl = await this.resolveControlUrl();
const text = controlUrl
? this.withControlUrl(input.payload.text, controlUrl)
: input.payload.text;
const result = await this.inboxWriter.sendMessage(input.teamName, {
member: input.memberName,
from: input.payload.from,
to: input.payload.to,
messageId: input.messageId,
timestamp: input.timestamp,
text: input.payload.text,
text,
taskRefs: input.payload.taskRefs,
actionMode: input.payload.actionMode,
summary: 'Work sync check',
@ -42,4 +47,28 @@ export class TeamInboxMemberWorkSyncNudgeSink implements MemberWorkSyncInboxNudg
messageId: result.messageId,
};
}
private async resolveControlUrl(): Promise<string | null> {
if (!this.controlUrlResolver) {
return null;
}
try {
const value = await this.controlUrlResolver();
const trimmed = value?.trim();
return trimmed ? trimmed : null;
} catch {
return null;
}
}
private withControlUrl(text: string, controlUrl: string): string {
if (text.includes('controlUrl')) {
return text;
}
return [
text,
`Required control API: pass controlUrl "${controlUrl}" in both member_work_sync_status and member_work_sync_report.`,
].join('\n');
}
}

View file

@ -176,6 +176,7 @@ export function createMemberWorkSyncFeature(deps: {
extraBusySignals?: MemberWorkSyncBusySignalPort[];
proofMissingRecoveryGuard?: MemberWorkSyncProofMissingRecoveryGuardPort;
nudgeDeliveryWake?: MemberWorkSyncNudgeDeliveryWakePort;
resolveControlUrl?: () => Promise<string | null> | string | null;
reviewPickupDelivery?: MemberWorkSyncReviewPickupDeliveryPort;
reviewPickupEscalation?: MemberWorkSyncReviewPickupEscalationPort;
logger?: MemberWorkSyncLoggerPort;
@ -229,7 +230,11 @@ export function createMemberWorkSyncFeature(deps: {
busySignals.length === 1
? toolActivityBusySignal
: new CompositeMemberWorkSyncBusySignal(busySignals, deps.logger);
const inboxNudge = new TeamInboxMemberWorkSyncNudgeSink();
const inboxNudge = new TeamInboxMemberWorkSyncNudgeSink(
undefined,
undefined,
deps.resolveControlUrl
);
const useCaseDeps = {
clock,
hash,

View file

@ -148,6 +148,7 @@ export interface RuntimeProviderDirectoryEntryDto {
hasKnownModels: boolean;
requiresManualConfig: boolean;
supportedInlineAuth: boolean;
configuredAuthless: boolean;
};
}
@ -170,6 +171,7 @@ export interface RuntimeProviderManagementViewDto {
title: string;
runtime: RuntimeProviderManagementRuntimeDto;
providers: readonly RuntimeProviderConnectionDto[];
configuredModels?: readonly RuntimeProviderModelDto[];
defaultModel: string | null;
fallbackModel: string | null;
diagnostics: readonly string[];
@ -228,6 +230,28 @@ export type RuntimeProviderModelAvailabilityDto =
| 'unknown'
| 'untested';
export type RuntimeProviderModelAccessKindDto =
| 'no_model'
| 'unknown_model'
| 'credentialed'
| 'builtin_free'
| 'configured_authless'
| 'verified'
| 'not_authenticated'
| 'execution_failed';
export type RuntimeProviderModelRouteKindDto =
| 'connected_provider'
| 'builtin_free'
| 'configured_local'
| 'catalog_provider';
export type RuntimeProviderModelProofStateDto =
| 'not_required'
| 'needs_probe'
| 'verified'
| 'failed';
export interface RuntimeProviderModelDto {
modelId: string;
providerId: string;
@ -236,6 +260,11 @@ export interface RuntimeProviderModelDto {
free: boolean;
default: boolean;
availability: RuntimeProviderModelAvailabilityDto;
accessKind?: RuntimeProviderModelAccessKindDto;
routeKind?: RuntimeProviderModelRouteKindDto;
proofState?: RuntimeProviderModelProofStateDto;
requiresExecutionProof?: boolean;
accessReason?: string | null;
}
export interface RuntimeProviderManagementModelsDto {

View file

@ -138,6 +138,37 @@ function buildFailedModelTestResult(
};
}
function applyModelTestResultToModel(
model: RuntimeProviderModelDto,
result: RuntimeProviderModelTestResultDto
): RuntimeProviderModelDto {
if (model.modelId !== result.modelId) {
return model;
}
return {
...model,
availability: result.availability,
proofState: result.ok ? 'verified' : 'failed',
accessKind: result.ok ? 'verified' : model.accessKind,
requiresExecutionProof: result.ok ? false : model.requiresExecutionProof,
};
}
function applyModelTestResultToView(
view: RuntimeProviderManagementViewDto | null,
result: RuntimeProviderModelTestResultDto
): RuntimeProviderManagementViewDto | null {
if (!view?.configuredModels) {
return view;
}
return {
...view,
configuredModels: view.configuredModels.map((model) =>
applyModelTestResultToModel(model, result)
),
};
}
function resolveSavedModelForNewTeams(models: readonly RuntimeProviderModelDto[]): string | null {
const savedModelId = getOpenCodeModelForNewTeams();
if (!savedModelId) {
@ -829,29 +860,44 @@ export function useRuntimeProviderManagement(
);
if (response.error) {
if (shouldRecordProbeResult()) {
const result = buildFailedModelTestResult(providerId, modelId, response.error!.message);
setModelResults((current) => ({
...current,
[modelId]: buildFailedModelTestResult(providerId, modelId, response.error!.message),
[modelId]: result,
}));
setModels((current) =>
current.map((model) => applyModelTestResultToModel(model, result))
);
setView((current) => applyModelTestResultToView(current, result));
}
return;
}
if (response.result && shouldRecordProbeResult()) {
const result = response.result;
setModelResults((current) => ({
...current,
[modelId]: response.result!,
[modelId]: result,
}));
setModels((current) =>
current.map((model) => applyModelTestResultToModel(model, result))
);
setView((current) => applyModelTestResultToView(current, result));
}
} catch (testError) {
if (shouldRecordProbeResult()) {
const result = buildFailedModelTestResult(
providerId,
modelId,
testError instanceof Error ? testError.message : 'Failed to test model'
);
setModelResults((current) => ({
...current,
[modelId]: buildFailedModelTestResult(
providerId,
modelId,
testError instanceof Error ? testError.message : 'Failed to test model'
),
[modelId]: result,
}));
setModels((current) =>
current.map((model) => applyModelTestResultToModel(model, result))
);
setView((current) => applyModelTestResultToView(current, result));
}
} finally {
setTestingModelIds((current) => current.filter((entry) => entry !== modelId));
@ -881,15 +927,32 @@ export function useRuntimeProviderManagement(
setError(response.error.message);
return;
}
const proofResult: RuntimeProviderModelTestResultDto = {
providerId,
modelId,
ok: true,
availability: 'available',
message: 'Model probe passed',
diagnostics: [],
};
if (response.view) {
setView(response.view);
setView(applyModelTestResultToView(response.view, proofResult));
}
setModelResults((current) => ({
...current,
[modelId]: proofResult,
}));
setSelectedModelId(modelId);
setModels((current) =>
current.map((model) => ({
...model,
default: model.modelId === modelId,
}))
current.map((model) =>
applyModelTestResultToModel(
{
...model,
default: model.modelId === modelId,
},
proofResult
)
)
);
setSuccessMessage(`OpenCode default set to ${modelId}`);
await options.onProviderChanged?.();

View file

@ -83,6 +83,9 @@ function getDirectoryAction(
}
function formatDirectorySetupKind(provider: RuntimeProviderDirectoryEntryDto): string {
if (provider.metadata.configuredAuthless) {
return 'Configured local';
}
switch (provider.setupKind) {
case 'connected':
return 'Connected';
@ -137,6 +140,9 @@ function directoryEntryMatchesQuery(
}
function directorySetupKindClassName(provider: RuntimeProviderDirectoryEntryDto): string {
if (provider.metadata.configuredAuthless) {
return 'border-cyan-400/35 bg-cyan-400/10 text-cyan-100';
}
switch (provider.setupKind) {
case 'connected':
return 'border-emerald-300/70 bg-emerald-600 text-emerald-50';
@ -458,8 +464,8 @@ function RuntimeSummary({
className="mt-2 space-y-1 text-[11px]"
style={{ color: 'var(--color-text-muted)' }}
>
{state.view.diagnostics.slice(0, 3).map((diagnostic) => (
<div key={diagnostic}>{diagnostic}</div>
{state.view.diagnostics.slice(0, 3).map((diagnostic, index) => (
<div key={`diagnostic-${index}`}>{diagnostic}</div>
))}
</div>
) : null}
@ -710,8 +716,10 @@ function ProviderRow({
actions,
}: ProviderRowProps): JSX.Element {
const connect = getProviderAction(provider, 'connect');
const test = getProviderAction(provider, 'test');
const canOpenConnect = provider.state !== 'connected' && connect?.enabled === true;
const canSelectModels = provider.state === 'connected' && provider.modelCount > 0;
const canSelectModels =
provider.modelCount > 0 && (provider.state === 'connected' || test?.enabled === true);
const clickable = !disabled && (canOpenConnect || canSelectModels);
const visuallyActive = active && (canSelectModels || formOpen);
const handleActivate = (): void => {
@ -813,7 +821,7 @@ function ProviderRow({
/>
) : null}
{active && provider.state === 'connected' && provider.modelCount > 0 ? (
{active && canSelectModels ? (
<ProviderModelList
state={state}
actions={actions}
@ -845,8 +853,13 @@ function DirectoryProviderRow({
const connect = getDirectoryAction(provider, 'connect');
const configure = getDirectoryAction(provider, 'configure');
const forget = getDirectoryAction(provider, 'forget');
const test = getDirectoryAction(provider, 'test');
const canOpenConnect = provider.state !== 'connected' && connect?.enabled === true;
const canSelectModels = provider.state === 'connected' && provider.modelCount !== 0;
const canSelectModels =
provider.modelCount !== 0 &&
(provider.state === 'connected' ||
provider.metadata.configuredAuthless === true ||
test?.enabled === true);
const clickable = !disabled && (canOpenConnect || canSelectModels);
const visuallyActive = active && (canSelectModels || formOpen);
const handleActivate = (): void => {
@ -984,7 +997,7 @@ function DirectoryProviderRow({
/>
) : null}
{active && provider.state === 'connected' && provider.modelCount !== 0 ? (
{active && canSelectModels ? (
<ProviderModelList
state={state}
actions={actions}
@ -1004,8 +1017,34 @@ function ModelBadges({
readonly usedForNewTeams: boolean;
}): JSX.Element | null {
const modelRecommendation = getOpenCodeTeamModelRecommendation(model.modelId);
const localRoute = model.routeKind === 'configured_local';
const builtinFreeRoute = model.routeKind === 'builtin_free';
const connectedRoute = model.routeKind === 'connected_provider';
const verified =
model.proofState === 'verified' ||
model.availability === 'available' ||
model.accessKind === 'verified';
const needsTest = model.proofState === 'needs_probe' || model.requiresExecutionProof === true;
const failed =
model.proofState === 'failed' ||
model.accessKind === 'execution_failed' ||
model.availability === 'unavailable' ||
model.availability === 'not-authenticated';
const unknown = model.accessKind === 'unknown_model' || model.accessKind === 'no_model';
if (!model.free && !model.default && !usedForNewTeams && !modelRecommendation) {
if (
!model.free &&
!builtinFreeRoute &&
!model.default &&
!usedForNewTeams &&
!modelRecommendation &&
!localRoute &&
!connectedRoute &&
!verified &&
!needsTest &&
!failed &&
!unknown
) {
return null;
}
@ -1049,6 +1088,34 @@ function ModelBadges({
{model.free ? (
<Badge className="bg-emerald-400/15 px-1.5 py-0 text-[10px] text-emerald-200">free</Badge>
) : null}
{localRoute ? (
<>
<Badge className="bg-cyan-400/15 px-1.5 py-0 text-[10px] text-cyan-200">local</Badge>
<Badge className="bg-sky-400/15 px-1.5 py-0 text-[10px] text-sky-200">configured</Badge>
</>
) : null}
{builtinFreeRoute && !model.free ? (
<Badge className="bg-emerald-400/15 px-1.5 py-0 text-[10px] text-emerald-200">free</Badge>
) : null}
{connectedRoute ? (
<Badge className="bg-emerald-400/15 px-1.5 py-0 text-[10px] text-emerald-100">
connected
</Badge>
) : null}
{verified ? (
<Badge className="bg-emerald-400/15 px-1.5 py-0 text-[10px] text-emerald-100">
verified
</Badge>
) : null}
{needsTest && !verified ? (
<Badge className="bg-amber-400/15 px-1.5 py-0 text-[10px] text-amber-200">needs test</Badge>
) : null}
{failed ? (
<Badge className="bg-red-400/15 px-1.5 py-0 text-[10px] text-red-200">failed</Badge>
) : null}
{unknown ? (
<Badge className="bg-slate-400/15 px-1.5 py-0 text-[10px] text-slate-200">unknown</Badge>
) : null}
{model.default ? (
<Badge className="bg-amber-400/15 px-1.5 py-0 text-[10px] text-amber-200">default</Badge>
) : null}
@ -1056,6 +1123,42 @@ function ModelBadges({
);
}
function isUnknownOpenCodeModelRoute(model: RuntimeProviderModelDto): boolean {
return model.accessKind === 'unknown_model' || model.accessKind === 'no_model';
}
function canTestOpenCodeModelRoute(model: RuntimeProviderModelDto): boolean {
return !isUnknownOpenCodeModelRoute(model);
}
function canUseOpenCodeModelRoute(model: RuntimeProviderModelDto): boolean {
return (
!isUnknownOpenCodeModelRoute(model) &&
model.accessKind !== 'not_authenticated' &&
model.accessKind !== 'execution_failed' &&
model.proofState !== 'failed'
);
}
function canSetOpenCodeDefaultModelRoute(model: RuntimeProviderModelDto): boolean {
return canUseOpenCodeModelRoute(model) && !model.default;
}
function getOpenCodeRouteUnavailableTitle(model: RuntimeProviderModelDto): string | undefined {
if (isUnknownOpenCodeModelRoute(model)) {
return 'This model is the current OpenCode default, but it is not available in the live catalog yet.';
}
if (model.accessKind === 'not_authenticated') {
return (
model.accessReason ?? 'This provider requires authentication before this model can be used.'
);
}
if (model.accessKind === 'execution_failed' || model.proofState === 'failed') {
return model.accessReason ?? 'This model route failed its last execution test.';
}
return undefined;
}
function ModelResult({
result,
}: {
@ -1171,6 +1274,140 @@ function ModelRow({
);
}
function ConfiguredOpenCodeModelsPanel({
state,
actions,
disabled,
}: {
readonly state: RuntimeProviderManagementState;
readonly actions: RuntimeProviderManagementActions;
readonly disabled: boolean;
}): JSX.Element | null {
const models = state.view?.configuredModels ?? [];
if (models.length === 0) {
return null;
}
return (
<div
className="rounded-lg border p-3"
style={{
borderColor: 'var(--color-border-subtle)',
backgroundColor: 'rgba(255, 255, 255, 0.025)',
}}
>
<div className="flex flex-wrap items-start justify-between gap-2">
<div className="min-w-0">
<div className="text-sm font-medium text-[var(--color-text)]">
Configured OpenCode models
</div>
<div className="text-xs text-[var(--color-text-muted)]">
Ready model routes from OpenCode config, built-in free models, and the current default.
</div>
</div>
</div>
<div className="mt-3 space-y-2">
{models.map((model) => {
const selected = state.selectedModelId === model.modelId;
const testing = state.testingModelIds.includes(model.modelId);
const savingDefault = state.savingDefaultModelId === model.modelId;
const result = state.modelResults[model.modelId];
const unavailableTitle = getOpenCodeRouteUnavailableTitle(model);
const canTest = !disabled && !testing && canTestOpenCodeModelRoute(model);
const canUse = !disabled && canUseOpenCodeModelRoute(model);
const canSetDefault =
!disabled && !savingDefault && canSetOpenCodeDefaultModelRoute(model);
return (
<div
key={model.modelId}
data-testid={`configured-opencode-model-row-${model.modelId}`}
className="rounded-md border px-3 py-2.5"
style={{
borderColor: selected ? 'rgba(96, 165, 250, 0.45)' : 'var(--color-border-subtle)',
backgroundColor: selected ? 'rgba(96, 165, 250, 0.06)' : 'rgba(255,255,255,0.02)',
}}
>
<div className="grid grid-cols-[minmax(0,1fr)_auto] items-start gap-3">
<div className="min-w-0">
<div
className="text-sm font-medium leading-5"
style={{ color: 'var(--color-text)', overflowWrap: 'anywhere' }}
>
{model.displayName}
</div>
<div className="mt-1 flex flex-wrap items-center gap-x-2 gap-y-1 text-[11px] text-[var(--color-text-muted)]">
<span className="break-all">{model.modelId}</span>
<span>{model.sourceLabel}</span>
</div>
<ModelBadges model={model} usedForNewTeams={selected} />
</div>
<div className="flex shrink-0 flex-wrap justify-end gap-1.5">
<Button
type="button"
size="sm"
variant="outline"
className="h-8"
disabled={!canTest}
title={canTest ? undefined : unavailableTitle}
onClick={() => {
if (!canTest) return;
void actions.testModel(model.providerId, model.modelId);
}}
>
{testing ? (
<Loader2 className="mr-1 size-3.5 animate-spin" />
) : (
<CheckCircle2 className="mr-1 size-3.5" />
)}
Test
</Button>
<Button
type="button"
size="sm"
variant="ghost"
className="h-8"
disabled={!canUse}
title={canUse ? undefined : unavailableTitle}
onClick={() => {
if (!canUse) return;
actions.useModelForNewTeams(model.modelId);
}}
>
Use for new teams
</Button>
<Button
type="button"
size="sm"
variant="ghost"
className="h-8"
disabled={!canSetDefault}
title={
canSetDefault
? undefined
: model.default
? 'This is already the OpenCode default.'
: unavailableTitle
}
onClick={() => {
if (!canSetDefault) return;
void actions.setDefaultModel(model.providerId, model.modelId);
}}
>
{savingDefault ? <Loader2 className="mr-1 size-3.5 animate-spin" /> : null}
Set OpenCode default
</Button>
</div>
</div>
<ModelResult result={result} />
</div>
);
})}
</div>
</div>
);
}
function ProviderModelList({
state,
actions,
@ -1359,6 +1596,8 @@ export function RuntimeProviderManagementPanelView({
</div>
) : null}
<ConfiguredOpenCodeModelsPanel state={state} actions={actions} disabled={disabled} />
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="min-w-0">
<div className="text-sm font-medium text-[var(--color-text)]">Providers</div>

View file

@ -1846,6 +1846,7 @@ async function initializeServices(): Promise<void> {
isBusy: (input) => teamProvisioningService.getOpenCodeMemberDeliveryBusyStatus(input),
},
],
resolveControlUrl: async () => getTeamControlApiBaseUrl(),
proofMissingRecoveryGuard: {
shouldDispatch: async (input) => {
const status = await teamProvisioningService.getOpenCodeRuntimeDeliveryStatus(

View file

@ -65,6 +65,7 @@ import {
TEAM_REQUEST_REVIEW,
TEAM_RESTART_MEMBER,
TEAM_RESTORE,
TEAM_RESTORE_MEMBER,
TEAM_RESTORE_TASK,
TEAM_RETRY_FAILED_OPENCODE_SECONDARY_LANES,
TEAM_SAVE_TASK_ATTACHMENT,
@ -148,7 +149,10 @@ import { TeamConfigReader } from '../services/team/TeamConfigReader';
import { readTeamLaunchFailureDiagnosticsBundle } from '../services/team/TeamLaunchFailureArtifactPack';
import { TeamMembersMetaStore } from '../services/team/TeamMembersMetaStore';
import { TeamMetaStore } from '../services/team/TeamMetaStore';
import { buildAddMemberSpawnMessage } from '../services/team/TeamProvisioningService';
import {
buildAddMemberSpawnMessage,
type RuntimeBootstrapMemberMcpLaunchConfig,
} from '../services/team/TeamProvisioningService';
import { TeamTaskAttachmentStore } from '../services/team/TeamTaskAttachmentStore';
import { TeamWorktreeGitService } from '../services/team/TeamWorktreeGitService';
@ -745,6 +749,7 @@ export function registerTeamHandlers(ipcMain: IpcMain): void {
ipcMain.handle(TEAM_ADD_MEMBER, handleAddMember);
ipcMain.handle(TEAM_REPLACE_MEMBERS, handleReplaceMembers);
ipcMain.handle(TEAM_REMOVE_MEMBER, handleRemoveMember);
ipcMain.handle(TEAM_RESTORE_MEMBER, handleRestoreMember);
ipcMain.handle(TEAM_UPDATE_MEMBER_ROLE, handleUpdateMemberRole);
ipcMain.handle(TEAM_GET_PROJECT_BRANCH, handleGetProjectBranch);
ipcMain.handle(TEAM_GET_ATTACHMENTS, handleGetAttachments);
@ -832,6 +837,7 @@ export function removeTeamHandlers(ipcMain: IpcMain): void {
ipcMain.removeHandler(TEAM_ADD_MEMBER);
ipcMain.removeHandler(TEAM_REPLACE_MEMBERS);
ipcMain.removeHandler(TEAM_REMOVE_MEMBER);
ipcMain.removeHandler(TEAM_RESTORE_MEMBER);
ipcMain.removeHandler(TEAM_UPDATE_MEMBER_ROLE);
ipcMain.removeHandler(TEAM_GET_PROJECT_BRANCH);
ipcMain.removeHandler(TEAM_GET_ATTACHMENTS);
@ -1560,6 +1566,7 @@ interface RuntimeRosterMutationMember {
role?: string;
workflow?: string;
isolation?: 'worktree';
cwd?: string;
providerId?: TeamProviderId;
providerBackendId?: TeamProviderBackendId;
model?: string;
@ -1599,6 +1606,40 @@ function isOpenCodeLedRoster(members: RuntimeRosterMutationMember[]): boolean {
return normalizeOptionalTeamProviderId(leadMember?.providerId) === 'opencode';
}
async function sendLiveAddMemberSpawnPrompt(input: {
provisioning: TeamProvisioningService;
teamName: string;
displayName: string;
leadName: string;
projectPath?: string;
member: RuntimeRosterMutationMember;
}): Promise<void> {
let mcpLaunchConfig: RuntimeBootstrapMemberMcpLaunchConfig | null = null;
try {
mcpLaunchConfig = await input.provisioning.prepareLiveMemberMcpLaunchConfig({
teamName: input.teamName,
cwd: input.member.cwd?.trim() || input.projectPath,
mcpPolicy: input.member.mcpPolicy,
});
const spawnMessage = buildAddMemberSpawnMessage(
input.teamName,
input.displayName,
input.leadName,
input.member,
mcpLaunchConfig
);
await input.provisioning.sendMessageToTeam(input.teamName, spawnMessage);
} catch (error) {
await input.provisioning
.discardLiveMemberMcpLaunchConfig({
teamName: input.teamName,
mcpLaunchConfig,
})
.catch(() => {});
throw error;
}
}
function didOpenCodeRosterMemberChange(
previous: RuntimeRosterMutationMember | undefined,
next: RuntimeRosterMutationMember | undefined
@ -4343,8 +4384,8 @@ async function handleAddMember(
const memberName = vName.value!;
const teamDataService = getTeamDataService();
const previousMembersMeta = await new TeamMembersMetaStore().getMeta(tn).catch(() => null);
const previousMembers = (await teamDataService.getTeamData(tn))
.members as RuntimeRosterMutationMember[];
const previousTeamData = await teamDataService.getTeamData(tn);
const previousMembers = previousTeamData.members as RuntimeRosterMutationMember[];
const provisioning = getTeamProvisioningService();
const isTeamAlive = provisioning.isTeamAlive(tn);
if (isTeamAlive && isOpenCodeLedRoster(previousMembers)) {
@ -4396,20 +4437,29 @@ async function handleAddMember(
} catch {
// Best-effort: fall back to default lead and team names
}
const spawnMessage = buildAddMemberSpawnMessage(tn, displayName, leadName, {
name: memberName,
...(typeof role === 'string' ? { role } : {}),
...(typeof workflow === 'string' ? { workflow } : {}),
...(isolation === 'worktree' ? { isolation: 'worktree' as const } : {}),
...(providerValidation.value ? { providerId: providerValidation.value } : {}),
...(typeof model === 'string' && model.trim() ? { model: model.trim() } : {}),
...(effortValidation.value ? { effort: effortValidation.value } : {}),
});
try {
await provisioning.sendMessageToTeam(tn, spawnMessage);
} catch {
await sendLiveAddMemberSpawnPrompt({
provisioning,
teamName: tn,
displayName,
leadName,
projectPath: previousTeamData.config?.projectPath,
member: {
name: memberName,
...(typeof role === 'string' ? { role } : {}),
...(typeof workflow === 'string' ? { workflow } : {}),
...(isolation === 'worktree' ? { isolation: 'worktree' as const } : {}),
...(providerValidation.value ? { providerId: providerValidation.value } : {}),
...(typeof model === 'string' && model.trim() ? { model: model.trim() } : {}),
...(effortValidation.value ? { effort: effortValidation.value } : {}),
mcpPolicy: normalizeTeamMemberMcpPolicy(mcpPolicy),
},
});
} catch (error) {
// Best-effort: lead process may not be responsive
logger.warn(`Failed to notify lead about new member "${memberName}" in ${tn}`);
logger.warn(
`Failed to notify lead about new member "${memberName}" in ${tn}: ${getErrorMessage(error)}`
);
}
}
});
@ -4517,8 +4567,8 @@ async function handleReplaceMembers(
const tn = vTeam.value!;
const teamDataService = getTeamDataService();
const previousMembersMeta = await new TeamMembersMetaStore().getMeta(tn).catch(() => null);
const previousMembers = (await teamDataService.getTeamData(tn))
.members as RuntimeRosterMutationMember[];
const previousTeamData = await teamDataService.getTeamData(tn);
const previousMembers = previousTeamData.members as RuntimeRosterMutationMember[];
const provisioning = getTeamProvisioningService();
const isTeamAlive = provisioning.isTeamAlive(tn);
const useSecondaryOpenCodeLaneRouting = isTeamAlive && !isOpenCodeLedRoster(previousMembers);
@ -4636,11 +4686,19 @@ async function handleReplaceMembers(
}
for (const addedMember of primaryDiff.added) {
const spawnMessage = buildAddMemberSpawnMessage(tn, displayName, leadName, addedMember);
try {
await provisioning.sendMessageToTeam(tn, spawnMessage);
} catch {
logger.warn(`Failed to notify lead about new member "${addedMember.name}" in ${tn}`);
await sendLiveAddMemberSpawnPrompt({
provisioning,
teamName: tn,
displayName,
leadName,
projectPath: previousTeamData.config?.projectPath,
member: addedMember,
});
} catch (error) {
logger.warn(
`Failed to notify lead about new member "${addedMember.name}" in ${tn}: ${getErrorMessage(error)}`
);
}
}
@ -4671,8 +4729,8 @@ async function handleRemoveMember(
const name = vMember.value!;
const teamDataService = getTeamDataService();
const previousMembersMeta = await new TeamMembersMetaStore().getMeta(tn).catch(() => null);
const previousMembers = (await teamDataService.getTeamData(tn))
.members as RuntimeRosterMutationMember[];
const previousTeamData = await teamDataService.getTeamData(tn);
const previousMembers = previousTeamData.members as RuntimeRosterMutationMember[];
const provisioning = getTeamProvisioningService();
const isTeamAlive = provisioning.isTeamAlive(tn);
if (isTeamAlive && isOpenCodeLedRoster(previousMembers)) {
@ -4715,6 +4773,85 @@ async function handleRemoveMember(
});
}
async function handleRestoreMember(
_event: IpcMainInvokeEvent,
teamName: unknown,
memberName: unknown
): Promise<IpcResult<void>> {
const vTeam = validateTeamName(teamName);
if (!vTeam.valid) return { success: false, error: vTeam.error ?? 'Invalid teamName' };
const vMember = validateMemberName(memberName);
if (!vMember.valid) return { success: false, error: vMember.error ?? 'Invalid memberName' };
return wrapTeamHandler('restoreMember', async () => {
const tn = vTeam.value!;
const name = vMember.value!;
const teamDataService = getTeamDataService();
const previousMembersMeta = await new TeamMembersMetaStore().getMeta(tn).catch(() => null);
const previousTeamData = await teamDataService.getTeamData(tn);
const previousMembers = previousTeamData.members as RuntimeRosterMutationMember[];
const provisioning = getTeamProvisioningService();
const isTeamAlive = provisioning.isTeamAlive(tn);
if (isTeamAlive && isOpenCodeLedRoster(previousMembers)) {
throw new Error(OPENCODE_LEAD_LIVE_ROSTER_MUTATION_BLOCK_MESSAGE);
}
const restoredMember = await teamDataService.restoreMember(tn, name);
invalidateTeamRosterSnapshotCaches(tn);
if (!isTeamAlive) {
return;
}
if (isOpenCodeRosterMutationMember(restoredMember)) {
try {
await provisioning.reattachOpenCodeOwnedMemberLane(tn, name, {
reason: 'member_added',
});
} catch (error) {
await rollbackOpenCodeLiveRosterMutation({
teamName: tn,
teamDataService,
provisioning,
previousMembers,
previousMembersMeta,
detachOpenCodeMemberNames: [name],
});
throw error;
}
return;
}
let leadName = 'team-lead';
let displayName = tn;
try {
const [resolvedLeadName, resolvedDisplayName] = await Promise.all([
teamDataService.getLeadMemberName(tn),
teamDataService.getTeamDisplayName(tn),
]);
leadName = resolvedLeadName || 'team-lead';
displayName = resolvedDisplayName || tn;
} catch {
// Best-effort: fall back to default lead and team names
}
try {
await sendLiveAddMemberSpawnPrompt({
provisioning,
teamName: tn,
displayName,
leadName,
projectPath: previousTeamData.config?.projectPath,
member: restoredMember,
});
} catch (error) {
logger.warn(
`Failed to notify lead about restore of "${name}" in ${tn}: ${getErrorMessage(error)}`
);
}
});
}
async function handleUpdateTaskFields(
_event: IpcMainInvokeEvent,
teamName: unknown,

View file

@ -108,6 +108,77 @@ describe('ClaudeMultimodelBridgeService runtime status mapping', () => {
expect(provider.subscriptionRateLimits).toBeNull();
});
test('preserves OpenCode route metadata in runtime model catalog mapping', () => {
const provider = mapRuntimeProviderStatus('opencode', {
supported: true,
authenticated: true,
authMethod: 'opencode_configured_local',
verificationState: 'verified',
canLoginFromUi: false,
models: ['llama.cpp/qwen-test:0.5b'],
capabilities: {
teamLaunch: true,
oneShot: false,
},
modelCatalog: {
schemaVersion: 1,
providerId: 'opencode',
source: 'app-server',
status: 'ready',
fetchedAt: '2026-05-21T00:00:00.000Z',
staleAt: '2026-05-21T00:10:00.000Z',
defaultModelId: 'llama.cpp/qwen-test:0.5b',
defaultLaunchModel: 'llama.cpp/qwen-test:0.5b',
models: [
{
id: 'llama.cpp/qwen-test:0.5b',
launchModel: 'llama.cpp/qwen-test:0.5b',
displayName: 'qwen-test:0.5b',
hidden: false,
supportedReasoningEfforts: [],
defaultReasoningEffort: null,
inputModalities: ['text'],
supportsPersonality: true,
isDefault: true,
upgrade: false,
source: 'app-server',
metadata: {
cost: null,
context: 32768,
limits: null,
free: false,
opencode: {
providerId: 'llama.cpp',
modelId: 'qwen-test:0.5b',
sourceLabel: 'llama.cpp',
accessKind: 'configured_authless',
routeKind: 'configured_local',
proofState: 'needs_probe',
requiresExecutionProof: true,
reason: 'Execution proof required',
},
},
},
],
diagnostics: {
configReadState: 'ready',
appServerState: 'healthy',
},
},
});
expect(provider.modelCatalog?.models[0]?.metadata?.opencode).toEqual({
providerId: 'llama.cpp',
modelId: 'qwen-test:0.5b',
sourceLabel: 'llama.cpp',
accessKind: 'configured_authless',
routeKind: 'configured_local',
proofState: 'needs_probe',
requiresExecutionProof: true,
reason: 'Execution proof required',
});
});
test('ignores Anthropic subscription rate limits for API key auth', () => {
const provider = mapRuntimeProviderStatus('anthropic', {
supported: true,

View file

@ -18,6 +18,7 @@ import type {
CliProviderReasoningEffort,
CliProviderStatus,
CliProviderSubscriptionRateLimitSnapshot,
OpenCodeModelRouteMetadata,
} from '@shared/types';
const logger = createLogger('ClaudeMultimodelBridgeService');
@ -483,6 +484,61 @@ function collectRuntimeReasoningEfforts(values?: string[]): CliProviderReasoning
);
}
const OPENCODE_ACCESS_KINDS = new Set([
'no_model',
'unknown_model',
'credentialed',
'builtin_free',
'configured_authless',
'verified',
'not_authenticated',
'execution_failed',
]);
const OPENCODE_ROUTE_KINDS = new Set([
'connected_provider',
'builtin_free',
'configured_local',
'catalog_provider',
]);
const OPENCODE_PROOF_STATES = new Set(['not_required', 'needs_probe', 'verified', 'failed']);
function asStringOrNull(value: unknown): string | null {
return typeof value === 'string' ? value : null;
}
function mapOpenCodeModelRouteMetadata(value: unknown): OpenCodeModelRouteMetadata | null {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return null;
}
const record = value as Record<string, unknown>;
const accessKind = record.accessKind;
const routeKind = record.routeKind;
const proofState = record.proofState;
if (
typeof accessKind !== 'string' ||
typeof routeKind !== 'string' ||
typeof proofState !== 'string' ||
!OPENCODE_ACCESS_KINDS.has(accessKind) ||
!OPENCODE_ROUTE_KINDS.has(routeKind) ||
!OPENCODE_PROOF_STATES.has(proofState)
) {
return null;
}
return {
providerId: asStringOrNull(record.providerId),
modelId: asStringOrNull(record.modelId),
sourceLabel: asStringOrNull(record.sourceLabel),
accessKind: accessKind as OpenCodeModelRouteMetadata['accessKind'],
routeKind: routeKind as OpenCodeModelRouteMetadata['routeKind'],
proofState: proofState as OpenCodeModelRouteMetadata['proofState'],
requiresExecutionProof: record.requiresExecutionProof === true,
reason: asStringOrNull(record.reason),
};
}
function mapRuntimeProviderModelMetadata(
metadata?: Record<string, unknown> | null
): NonNullable<CliProviderStatus['modelCatalog']>['models'][number]['metadata'] {
@ -490,11 +546,13 @@ function mapRuntimeProviderModelMetadata(
return null;
}
const context = metadata.context;
const opencode = mapOpenCodeModelRouteMetadata(metadata.opencode);
return {
cost: metadata.cost ?? null,
context: typeof context === 'number' && Number.isFinite(context) ? context : null,
limits: metadata.limits ?? null,
free: metadata.free === true,
...(opencode ? { opencode } : {}),
};
}

View file

@ -1948,6 +1948,38 @@ export class TeamDataService {
await this.membersMetaStore.writeMembers(teamName, members);
}
async restoreMember(teamName: string, memberName: string): Promise<TeamMember> {
const normalizedName = memberName.trim().toLowerCase();
const members = await this.membersMetaStore.getMembers(teamName);
const memberIndex = members.findIndex(
(candidate) => candidate.name.trim().toLowerCase() === normalizedName
);
const member = memberIndex >= 0 ? members[memberIndex] : undefined;
if (!member) {
throw new Error(`Member "${memberName}" not found`);
}
if (member.removedAt == null) {
throw new Error(`Member "${memberName}" is not removed`);
}
if (isLeadMember(member)) {
throw new Error('Cannot restore team lead');
}
const restoredMember: TeamMember = {
...member,
agentId: undefined,
removedAt: undefined,
};
const nextMembers = applyDistinctRosterColors(
members.map((candidate, index) => (index === memberIndex ? restoredMember : candidate))
);
await this.assertRosterMutationAllowed(teamName, toProvisioningMemberShape(nextMembers));
await this.membersMetaStore.writeMembers(teamName, nextMembers);
return nextMembers[memberIndex] ?? restoredMember;
}
async createTask(teamName: string, request: CreateTaskRequest): Promise<TeamTask> {
const controller = this.getController(teamName);
const blockedBy = request.blockedBy?.filter((id) => id.length > 0) ?? [];
@ -2944,6 +2976,45 @@ export class TeamDataService {
});
}
private getControllerTaskWorkflowColumn(
controller: AgentTeamsController,
taskId: string
): 'review' | 'approved' | undefined | null {
if (!controller.tasks?.getTask || !controller.kanban?.getKanbanState) {
return null;
}
const task = controller.tasks.getTask(taskId) as TeamTask | null | undefined;
if (!task || typeof task.status !== 'string') {
return null;
}
const kanbanState = controller.kanban.getKanbanState() as KanbanState | null | undefined;
const kanbanColumn = kanbanState?.tasks?.[task.id]?.column;
const kanbanWorkflowColumn = kanbanColumn
? getTeamTaskWorkflowColumn({
status: task.status,
reviewState: 'none',
kanbanColumn,
})
: undefined;
if (kanbanWorkflowColumn) {
return kanbanWorkflowColumn;
}
const reviewState = getReviewStateFromTask({
historyEvents: task.historyEvents,
reviewState: task.reviewState,
status: task.status,
...(kanbanColumn ? { kanbanColumn } : {}),
});
return getTeamTaskWorkflowColumn({
status: task.status,
reviewState,
...(kanbanColumn ? { kanbanColumn } : {}),
});
}
async createTeamConfig(request: TeamCreateConfigRequest): Promise<void> {
const teamDir = path.join(getTeamsBasePath(), request.teamName);
const configPath = path.join(teamDir, 'config.json');
@ -3440,12 +3511,19 @@ export class TeamDataService {
});
} else {
const { leadName, leadSessionId } = await this.resolveLeadRuntimeContext(teamName);
controller.review.approveReview(taskId, {
from: leadName,
suppressTaskComment: true,
'notify-owner': true,
...(leadSessionId ? { leadSessionId } : {}),
});
const workflowColumn = this.getControllerTaskWorkflowColumn(controller, taskId);
if (workflowColumn === undefined) {
controller.kanban.setKanbanColumn(taskId, 'approved', {
transition: 'manual_approve',
});
} else {
controller.review.approveReview(taskId, {
from: leadName,
suppressTaskComment: true,
'notify-owner': true,
...(leadSessionId ? { leadSessionId } : {}),
});
}
}
return;
}

View file

@ -34,10 +34,12 @@ export interface McpLaunchSpecResolveOptions {
interface WriteMcpConfigOptions {
mcpPolicy?: TeamMemberMcpPolicy;
controlApiBaseUrl?: string | null;
}
const MCP_SERVER_NAME = 'agent-teams';
const MCP_CLAUDE_DIR_ENV = 'AGENT_TEAMS_MCP_CLAUDE_DIR';
const MCP_CONTROL_URL_ENV = 'CLAUDE_TEAM_CONTROL_URL';
const logger = createLogger('Service:TeamMcpConfigBuilder');
const MCP_CONFIG_PREFIX = 'agent-teams-mcp-';
const MCP_CONFIG_REMOVE_RETRY_DELAYS_MS = [25, 75, 150] as const;
@ -236,6 +238,14 @@ function mergePathValues(...values: (string | undefined)[]): string | undefined
return merged.length > 0 ? merged.join(path.delimiter) : undefined;
}
function isWriteMcpConfigOptions(value: unknown): value is WriteMcpConfigOptions {
return (
value !== null &&
typeof value === 'object' &&
('mcpPolicy' in value || 'controlApiBaseUrl' in value)
);
}
function buildNodeResolveEnv(shellEnv: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
const env: NodeJS.ProcessEnv = {
...process.env,
@ -440,10 +450,14 @@ export class TeamMcpConfigBuilder {
configDir,
`${MCP_CONFIG_PREFIX}${process.pid}-${Date.now()}-${randomUUID()}.json`
);
const mcpPolicy =
optionsOrPolicy && 'mcpPolicy' in optionsOrPolicy
? optionsOrPolicy.mcpPolicy
: (optionsOrPolicy as TeamMemberMcpPolicy | undefined);
const options = isWriteMcpConfigOptions(optionsOrPolicy)
? optionsOrPolicy
: ({
mcpPolicy: optionsOrPolicy as TeamMemberMcpPolicy | undefined,
} satisfies WriteMcpConfigOptions);
const mcpPolicy = options.mcpPolicy;
const controlApiBaseUrl =
options.controlApiBaseUrl?.trim() || process.env[MCP_CONTROL_URL_ENV]?.trim() || '';
// Keep the team bootstrap config minimal: recent Claude sidechain runs can
// lose the agent-teams tool surface when we inline large user MCP bundles
// into the generated --mcp-config. User/project/local MCP remain loaded
@ -455,6 +469,7 @@ export class TeamMcpConfigBuilder {
enabled: true,
env: {
[MCP_CLAUDE_DIR_ENV]: getClaudeBasePath(),
...(controlApiBaseUrl ? { [MCP_CONTROL_URL_ENV]: controlApiBaseUrl } : {}),
},
};
if (mcpPolicy?.mode === 'strictAllowlist') {

View file

@ -110,7 +110,8 @@ function buildSyntheticBootstrapDisplayPrompt(
const modelLine = member.model?.trim()
? `\nModel override for this teammate: ${member.model.trim()}.`
: '';
const runtimeProviderField = providerId === 'opencode' ? ', runtimeProvider: "opencode"' : '';
const runtimeProviderField =
providerId === 'opencode' || providerId === 'codex' ? `, runtimeProvider: "${providerId}"` : '';
return `You are ${member.name}, a ${role} on team "${displayName}" (${config.name}).${providerLine}${modelLine}

View file

@ -4502,7 +4502,8 @@ function buildAgentToolArgsSuffix(
member: Pick<
TeamCreateRequest['members'][number],
'providerId' | 'model' | 'effort' | 'isolation'
>
>,
mcpLaunchConfig?: RuntimeBootstrapMemberMcpLaunchConfig | null
): string {
const providerPart =
member.providerId && member.providerId !== 'anthropic'
@ -4511,7 +4512,17 @@ function buildAgentToolArgsSuffix(
const modelPart = member.model?.trim() ? `, model="${member.model.trim()}"` : '';
const effortPart = member.effort ? `, effort="${member.effort}"` : '';
const isolationPart = member.isolation === 'worktree' ? ', isolation="worktree"' : '';
return `${providerPart}${modelPart}${effortPart}${isolationPart}`;
const mcpConfigPart = mcpLaunchConfig?.mcpConfigPath
? `, mcp_config="${mcpLaunchConfig.mcpConfigPath}"`
: '';
const mcpSettingSourcesPart = mcpLaunchConfig?.mcpSettingSources
? `, mcp_setting_sources="${mcpLaunchConfig.mcpSettingSources}"`
: '';
const strictMcpConfigPart =
mcpLaunchConfig?.strictMcpConfig === undefined
? ''
: `, strict_mcp_config=${mcpLaunchConfig.strictMcpConfig ? 'true' : 'false'}`;
return `${providerPart}${modelPart}${effortPart}${isolationPart}${mcpConfigPart}${mcpSettingSourcesPart}${strictMcpConfigPart}`;
}
export function buildAddMemberSpawnMessage(
@ -4521,7 +4532,8 @@ export function buildAddMemberSpawnMessage(
member: Pick<
TeamCreateRequest['members'][number],
'name' | 'role' | 'workflow' | 'providerId' | 'model' | 'effort' | 'isolation'
>
>,
mcpLaunchConfig?: RuntimeBootstrapMemberMcpLaunchConfig | null
): string {
const roleHint =
typeof member.role === 'string' && member.role.trim()
@ -4545,7 +4557,7 @@ export function buildAddMemberSpawnMessage(
teamName,
leadName
);
const agentArgs = buildAgentToolArgsSuffix(member);
const agentArgs = buildAgentToolArgsSuffix(member, mcpLaunchConfig);
return (
`A new teammate "${member.name}"${roleHint} has been added to the team. ` +
@ -4561,7 +4573,8 @@ export function buildRestartMemberSpawnMessage(
member: Pick<
TeamCreateRequest['members'][number],
'name' | 'role' | 'workflow' | 'providerId' | 'model' | 'effort' | 'isolation'
>
>,
mcpLaunchConfig?: RuntimeBootstrapMemberMcpLaunchConfig | null
): string {
const roleHint =
typeof member.role === 'string' && member.role.trim()
@ -4585,7 +4598,7 @@ export function buildRestartMemberSpawnMessage(
teamName,
leadName
);
const agentArgs = buildAgentToolArgsSuffix(member);
const agentArgs = buildAgentToolArgsSuffix(member, mcpLaunchConfig);
return (
`Teammate "${member.name}"${roleHint} was restarted from the UI. ` +
@ -4615,7 +4628,7 @@ interface RuntimeBootstrapMemberSpec {
nativeAppManagedBootstrap?: NativeAppManagedBootstrapSpec;
}
interface RuntimeBootstrapMemberMcpLaunchConfig {
export interface RuntimeBootstrapMemberMcpLaunchConfig {
mcpConfigPath: string;
mcpSettingSources: string;
strictMcpConfig: boolean;
@ -9074,6 +9087,15 @@ export class TeamProvisioningService {
taskRefs: input.ledgerRecord.taskRefs,
visibleReply: visibleReplyForProof,
}) && taskRefsSatisfied;
const previousTerminalSuccess =
input.ledgerRecord.status === 'responded' &&
Boolean(input.ledgerRecord.inboxReadCommittedAt || input.ledgerRecord.visibleReplyMessageId);
const shouldEmitRecoveryAdvisoryRefresh =
semantic &&
(!previousTerminalSuccess ||
input.ledgerRecord.status === 'failed_terminal' ||
Boolean(input.ledgerRecord.failedAt) ||
Boolean(input.ledgerRecord.lastReason?.trim()));
const ledgerRecord = await input.ledger.applyDestinationProof({
id: input.ledgerRecord.id,
visibleReplyInbox: visibleReplyForProof.inboxName,
@ -9089,6 +9111,9 @@ export class TeamProvisioningService {
],
observedAt: nowIso(),
});
if (shouldEmitRecoveryAdvisoryRefresh) {
this.emitRuntimeDeliveryReplyAdvisoryRefresh(input.teamName, visibleReplyForProof.message);
}
return { ledgerRecord, visibleReply: visibleReplyForProof };
}
@ -12213,6 +12238,7 @@ export class TeamProvisioningService {
cwd: string;
members: TeamCreateRequest['members'];
run: ProvisioningRun;
controlApiBaseUrl?: string | null;
}): Promise<Map<string, RuntimeBootstrapMemberMcpLaunchConfig>> {
const configs = new Map<string, RuntimeBootstrapMemberMcpLaunchConfig>();
for (const member of input.members) {
@ -12222,8 +12248,11 @@ export class TeamProvisioningService {
}
const memberCwd = member.cwd?.trim() || input.cwd;
const mcpConfigPath = await this.mcpConfigBuilder.writeConfigFile(memberCwd, mcpPolicy);
input.run.memberMcpConfigPaths.push(mcpConfigPath);
const mcpConfigPath = await this.mcpConfigBuilder.writeConfigFile(memberCwd, {
mcpPolicy,
controlApiBaseUrl: input.controlApiBaseUrl,
});
(input.run.memberMcpConfigPaths ??= []).push(mcpConfigPath);
configs.set(member.name, {
mcpConfigPath,
mcpSettingSources: buildTeamMemberMcpSettingSources(mcpPolicy),
@ -12233,6 +12262,89 @@ export class TeamProvisioningService {
return configs;
}
private async buildTrackedMemberMcpLaunchConfig(input: {
cwd: string;
mcpPolicy: unknown;
run: ProvisioningRun;
controlApiBaseUrl?: string | null;
}): Promise<RuntimeBootstrapMemberMcpLaunchConfig | null> {
const mcpPolicy = normalizeTeamMemberMcpPolicy(input.mcpPolicy);
if (!mcpPolicy) {
return null;
}
const mcpConfigPath = await this.mcpConfigBuilder.writeConfigFile(input.cwd, {
mcpPolicy,
controlApiBaseUrl: input.controlApiBaseUrl,
});
(input.run.memberMcpConfigPaths ??= []).push(mcpConfigPath);
return {
mcpConfigPath,
mcpSettingSources: buildTeamMemberMcpSettingSources(mcpPolicy),
strictMcpConfig: requiresStrictTeamMemberMcpConfig(mcpPolicy),
};
}
private async removeTrackedMemberMcpLaunchConfig(
run: ProvisioningRun,
mcpLaunchConfig: RuntimeBootstrapMemberMcpLaunchConfig | null | undefined
): Promise<void> {
if (!mcpLaunchConfig?.mcpConfigPath) {
return;
}
const memberMcpConfigPaths = (run.memberMcpConfigPaths ??= []);
const index = memberMcpConfigPaths.indexOf(mcpLaunchConfig.mcpConfigPath);
if (index >= 0) {
memberMcpConfigPaths.splice(index, 1);
}
await this.mcpConfigBuilder.removeConfigFile(mcpLaunchConfig.mcpConfigPath);
}
async prepareLiveMemberMcpLaunchConfig(input: {
teamName: string;
cwd?: string;
mcpPolicy?: unknown;
}): Promise<RuntimeBootstrapMemberMcpLaunchConfig | null> {
const mcpPolicy = normalizeTeamMemberMcpPolicy(input.mcpPolicy);
if (!mcpPolicy) {
return null;
}
const runId = this.getAliveRunId(input.teamName);
const run = runId ? this.runs.get(runId) : undefined;
if (!run || run.processKilled || run.cancelRequested) {
throw new Error(`Team "${input.teamName}" is not currently running`);
}
const cwd = input.cwd?.trim() || run.request.cwd?.trim();
if (!cwd) {
throw new Error(`Team "${input.teamName}" project path is not available`);
}
await ensureCwdExists(cwd);
return this.buildTrackedMemberMcpLaunchConfig({
cwd,
mcpPolicy,
run,
controlApiBaseUrl: await this.resolveControlApiBaseUrl().catch(() => null),
});
}
async discardLiveMemberMcpLaunchConfig(input: {
teamName: string;
mcpLaunchConfig: RuntimeBootstrapMemberMcpLaunchConfig | null | undefined;
}): Promise<void> {
const runId = this.getAliveRunId(input.teamName);
const run = runId ? this.runs.get(runId) : undefined;
if (!run) {
if (input.mcpLaunchConfig?.mcpConfigPath) {
await this.mcpConfigBuilder.removeConfigFile(input.mcpLaunchConfig.mcpConfigPath);
}
return;
}
await this.removeTrackedMemberMcpLaunchConfig(run, input.mcpLaunchConfig);
}
private async removeRunMemberMcpConfigFiles(run: ProvisioningRun): Promise<void> {
const paths = run.memberMcpConfigPaths?.splice(0) ?? [];
await Promise.all(
@ -16873,7 +16985,11 @@ export class TeamProvisioningService {
}
const memberMcpPolicy = normalizeTeamMemberMcpPolicy(input.configuredMember.mcpPolicy);
const mcpConfigPath = await this.mcpConfigBuilder.writeConfigFile(cwd, memberMcpPolicy);
const mcpConfigPath = await this.mcpConfigBuilder.writeConfigFile(cwd, {
mcpPolicy: memberMcpPolicy,
controlApiBaseUrl: provisioningEnv.env.CLAUDE_TEAM_CONTROL_URL,
});
(input.run.memberMcpConfigPaths ??= []).push(mcpConfigPath);
const memberMcpSettingSources = buildTeamMemberMcpSettingSources(memberMcpPolicy);
const strictMemberMcpConfig = requiresStrictTeamMemberMcpConfig(memberMcpPolicy);
const agentId = `${input.configuredMember.name}@${input.teamName}`;
@ -17020,7 +17136,11 @@ export class TeamProvisioningService {
}
const memberMcpPolicy = normalizeTeamMemberMcpPolicy(input.configuredMember.mcpPolicy);
const mcpConfigPath = await this.mcpConfigBuilder.writeConfigFile(cwd, memberMcpPolicy);
const mcpConfigPath = await this.mcpConfigBuilder.writeConfigFile(cwd, {
mcpPolicy: memberMcpPolicy,
controlApiBaseUrl: provisioningEnv.env.CLAUDE_TEAM_CONTROL_URL,
});
(input.run.memberMcpConfigPaths ??= []).push(mcpConfigPath);
const memberMcpSettingSources = buildTeamMemberMcpSettingSources(memberMcpPolicy);
const strictMemberMcpConfig = requiresStrictTeamMemberMcpConfig(memberMcpPolicy);
const agentId = `${input.configuredMember.name}@${input.teamName}`;
@ -17755,24 +17875,31 @@ export class TeamProvisioningService {
}
}
const restartMessage = buildRestartMemberSpawnMessage(
teamName,
config?.name?.trim() || teamName,
leadName,
{
name: configuredMember.name,
role: configuredMember.role,
workflow: configuredMember.workflow,
isolation: configuredMember.isolation === 'worktree' ? ('worktree' as const) : undefined,
providerId: configuredMember.providerId,
model: configuredMember.model,
effort: configuredMember.effort,
}
);
let restartMcpLaunchConfig: RuntimeBootstrapMemberMcpLaunchConfig | null = null;
try {
restartMcpLaunchConfig = await this.buildTrackedMemberMcpLaunchConfig({
cwd: configuredMember.cwd?.trim() || config.projectPath?.trim() || run.request.cwd,
mcpPolicy: configuredMember.mcpPolicy,
run,
});
const restartMessage = buildRestartMemberSpawnMessage(
teamName,
config?.name?.trim() || teamName,
leadName,
{
name: configuredMember.name,
role: configuredMember.role,
workflow: configuredMember.workflow,
isolation: configuredMember.isolation === 'worktree' ? ('worktree' as const) : undefined,
providerId: configuredMember.providerId,
model: configuredMember.model,
effort: configuredMember.effort,
},
restartMcpLaunchConfig
);
await this.sendMessageToRun(run, restartMessage);
} catch (error) {
await this.removeTrackedMemberMcpLaunchConfig(run, restartMcpLaunchConfig).catch(() => {});
run.pendingMemberRestarts.delete(memberName);
this.setMemberSpawnStatus(
run,
@ -21405,7 +21532,9 @@ export class TeamProvisioningService {
} catch {
logger.warn(`[${run.teamName}] MCP config ${existingConfigPath} missing, regenerating`);
try {
const newConfigPath = await this.mcpConfigBuilder.writeConfigFile(ctx.cwd);
const newConfigPath = await this.mcpConfigBuilder.writeConfigFile(ctx.cwd, {
controlApiBaseUrl: ctx.env.CLAUDE_TEAM_CONTROL_URL,
});
ctx.args[mcpFlagIdx + 1] = newConfigPath;
run.mcpConfigPath = newConfigPath;
logger.info(`[${run.teamName}] Regenerated MCP config at ${newConfigPath}`);
@ -22170,6 +22299,7 @@ export class TeamProvisioningService {
members: effectiveMemberSpecs,
});
const memberMcpLaunchConfigs = await this.buildRuntimeBootstrapMemberMcpLaunchConfigs({
controlApiBaseUrl: provisioningEnv.env.CLAUDE_TEAM_CONTROL_URL,
cwd: request.cwd,
members: effectiveMemberSpecs,
run,
@ -22210,7 +22340,9 @@ export class TeamProvisioningService {
run.requiresFirstRealTurnSuccess = true;
}
emitProvisioningCheckpoint(run, 'Writing MCP config file');
mcpConfigPath = await this.mcpConfigBuilder.writeConfigFile(request.cwd);
mcpConfigPath = await this.mcpConfigBuilder.writeConfigFile(request.cwd, {
controlApiBaseUrl: provisioningEnv.env.CLAUDE_TEAM_CONTROL_URL,
});
run.mcpConfigPath = mcpConfigPath;
emitProvisioningCheckpoint(run, 'Validating agent-teams MCP runtime');
await this.validateAgentTeamsMcpRuntime(claudePath, request.cwd, shellEnv, mcpConfigPath, {
@ -23464,6 +23596,7 @@ export class TeamProvisioningService {
members: effectiveMemberSpecs,
});
const memberMcpLaunchConfigs = await this.buildRuntimeBootstrapMemberMcpLaunchConfigs({
controlApiBaseUrl: provisioningEnv.env.CLAUDE_TEAM_CONTROL_URL,
cwd: request.cwd,
members: effectiveMemberSpecs,
run,
@ -23501,7 +23634,9 @@ export class TeamProvisioningService {
run.bootstrapUserPromptPath = bootstrapUserPromptPath;
run.requiresFirstRealTurnSuccess = true;
emitProvisioningCheckpoint(run, 'Writing MCP config file');
mcpConfigPath = await this.mcpConfigBuilder.writeConfigFile(request.cwd);
mcpConfigPath = await this.mcpConfigBuilder.writeConfigFile(request.cwd, {
controlApiBaseUrl: provisioningEnv.env.CLAUDE_TEAM_CONTROL_URL,
});
run.mcpConfigPath = mcpConfigPath;
emitProvisioningCheckpoint(run, 'Validating agent-teams MCP runtime');
await this.validateAgentTeamsMcpRuntime(claudePath, request.cwd, shellEnv, mcpConfigPath, {
@ -24228,6 +24363,7 @@ export class TeamProvisioningService {
[
`CRITICAL: Do NOT send any message to="user" for this relay turn. The ONLY valid destination is to="${memberName}".`,
getCanonicalSendMessageToolRule(memberName),
`If an inbox item has Message kind: member_work_sync_nudge, a member_work_sync_status call alone is incomplete; the recipient must also call member_work_sync_report with the returned agendaFingerprint/reportToken.`,
getCanonicalSendMessageFieldRule(),
`Preserve task IDs and critical instructions. Do NOT add extra narration outside the SendMessage calls.`,
`If an inbox item is marked Source: system_notification, forward that notification exactly once without paraphrasing.`,
@ -24257,6 +24393,12 @@ export class TeamProvisioningService {
` Timestamp: ${m.timestamp}`,
` MessageId: ${m.messageId}`,
...(summaryLine ? [` ${summaryLine}`] : []),
...(typeof m.messageKind === 'string' && m.messageKind.trim()
? [` Message kind: ${m.messageKind.trim()}`]
: []),
...(typeof m.workSyncIntent === 'string' && m.workSyncIntent.trim()
? [` Work-sync intent: ${m.workSyncIntent.trim()}`]
: []),
...(typeof m.source === 'string' && m.source.trim()
? [` Source: ${m.source.trim()}`]
: []),
@ -25465,6 +25607,7 @@ export class TeamProvisioningService {
[
`Internal note: for task assignments, prefer task_create and rely on the board/runtime notification path instead of sending a separate SendMessage for the same assignment.`,
`For any MCP board tool call in this turn, teamName MUST be "${teamName}". Never use the lead/member name "${leadName}" as teamName.`,
`A member_work_sync_status call alone is incomplete for Message kind: member_work_sync_nudge. Do not stop until member_work_sync_report succeeds or a real blocker is recorded.`,
`Use task_create_from_message only for messages below that explicitly say "Eligible for task_create_from_message: yes" and provide a User MessageId. Never use task_create_from_message for teammate messages, system notifications, cross-team messages, or any inbox row that is not explicitly marked eligible.`,
`If a message below is marked Source: system_notification and its summary looks like "Comment on #...", reply via task_add_comment only when you have a substantive board update (decision, blocker, clarification answer, review result, or concrete next-step change).`,
`If a message below has Message kind: member_work_sync_nudge, it is actionable work-sync control traffic, not routine notification noise. Do NOT ignore it as a pure system notification. Call member_work_sync_status with teamName="${teamName}", memberName="${leadName}"${workSyncControlUrlClause}, then call member_work_sync_report with the same teamName/memberName${workSyncControlUrlClause}, the returned agendaFingerprint/reportToken, and taskIds from the nudge task refs. Do not use provider names, runtime names, or team names as memberName. If the agenda still has actionable work you are continuing, use state "still_working"; if blocked, use state "blocked" and record the blocker on the task.`,
@ -25505,6 +25648,12 @@ export class TeamProvisioningService {
`${idx + 1}) From: ${m.from || 'unknown'}`,
` Timestamp: ${m.timestamp}`,
...(summaryLine ? [` ${summaryLine}`] : []),
...(typeof m.messageKind === 'string' && m.messageKind.trim()
? [` Message kind: ${m.messageKind.trim()}`]
: []),
...(typeof m.workSyncIntent === 'string' && m.workSyncIntent.trim()
? [` Work-sync intent: ${m.workSyncIntent.trim()}`]
: []),
...(typeof m.source === 'string' && m.source.trim()
? [` Source: ${m.source.trim()}`]
: []),
@ -36541,6 +36690,7 @@ export class TeamProvisioningService {
if (!baseUrl) {
throw new Error('Team control API resolver returned no base URL after startup.');
}
process.env.CLAUDE_TEAM_CONTROL_URL = baseUrl;
return baseUrl;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);

View file

@ -42,6 +42,23 @@ export function isNativeAppManagedBootstrapProvider(providerId?: TeamProviderId)
return providerId == null || providerId === 'anthropic' || providerId === 'codex';
}
function resolveMemberBriefingRuntimeProvider(providerId?: TeamProviderId): 'native' | 'codex' {
return providerId === 'codex' ? 'codex' : 'native';
}
function buildCodexNativeStartupToolRules(providerId?: TeamProviderId): string[] {
if (providerId !== 'codex') {
return [];
}
return [
'- Codex Native after bootstrap: use Agent Teams MCP tools directly for board work.',
'- For task work, if prefixed MCP names are exposed, use mcp__agent-teams__task_get, mcp__agent-teams__task_start, mcp__agent-teams__task_add_comment, and mcp__agent-teams__task_complete.',
'- For work sync, if prefixed MCP names are exposed, use mcp__agent-teams__member_work_sync_status and mcp__agent-teams__member_work_sync_report.',
'- For visible replies after launch, call agent-teams_message_send or mcp__agent-teams__message_send with teamName, to, from, text, and summary. Do not use SendMessage.',
];
}
export function canonicalizeNativeBootstrapContextText(input: string): string {
return input
.replace(/\r\n/g, '\n')
@ -194,6 +211,7 @@ function buildLocalNativeMemberBriefing(params: {
'- Treat yourself as unavailable until the private bootstrap turn succeeds.',
'- Do not call member_briefing for launch readiness in this flow.',
'- Use Agent Teams messaging/task tools only after launch readiness is confirmed.',
...buildCodexNativeStartupToolRules(params.providerId),
]
.filter((line) => line.length > 0)
.join('\n');
@ -248,6 +266,7 @@ function buildCompactNativeMemberBriefing(params: {
'- Start real work only from an assigned task or a direct app-delivered instruction.',
'- Post durable task results as task comments before completing tasks.',
'- Ask the lead when blocked instead of guessing.',
...buildCodexNativeStartupToolRules(params.providerId),
'',
'Current task briefing:',
boundText(redactNativeBootstrapContextText(params.taskBriefing), taskBriefingLimit),
@ -301,7 +320,7 @@ export async function buildNativeAppManagedBootstrapSpecsWithDiagnostics(params:
try {
briefing = String(
await controller.tasks.memberBriefing(member.name, {
runtimeProvider: 'native',
runtimeProvider: resolveMemberBriefingRuntimeProvider(providerId),
includeActiveProcesses: false,
})
);

View file

@ -1,4 +1,4 @@
import type { EffortLevel, TeamProviderId } from '@shared/types';
import type { EffortLevel, TeamMemberMcpPolicy, TeamProviderId } from '@shared/types';
export interface MemberDiffInput {
name: string;
@ -8,6 +8,7 @@ export interface MemberDiffInput {
providerId?: TeamProviderId;
model?: string;
effort?: EffortLevel;
mcpPolicy?: TeamMemberMcpPolicy;
removedAt?: number | string | null;
}
@ -20,6 +21,7 @@ export interface ReplaceMembersDiff {
providerId?: TeamProviderId;
model?: string;
effort?: EffortLevel;
mcpPolicy?: TeamMemberMcpPolicy;
}[];
removed: string[];
updated: {
@ -65,6 +67,46 @@ function describeWorkflowChange(
return 'workflow instructions were cleared';
}
function describeProviderChange(
previousProviderId: TeamProviderId | undefined,
nextProviderId: TeamProviderId | undefined
): string | null {
if (previousProviderId === nextProviderId) {
return null;
}
return 'provider changed - restart required';
}
function describeModelChange(
previousModel: string | undefined,
nextModel: string | undefined
): string | null {
if (previousModel === nextModel) {
return null;
}
return 'model changed - restart required';
}
function describeEffortChange(
previousEffort: EffortLevel | undefined,
nextEffort: EffortLevel | undefined
): string | null {
if (previousEffort === nextEffort) {
return null;
}
return 'reasoning effort changed - restart required';
}
function describeMcpPolicyChange(
previousMcpPolicy: TeamMemberMcpPolicy | undefined,
nextMcpPolicy: TeamMemberMcpPolicy | undefined
): string | null {
if (JSON.stringify(previousMcpPolicy) === JSON.stringify(nextMcpPolicy)) {
return null;
}
return 'MCP access policy changed - restart required';
}
export function buildReplaceMembersDiff(
previousMembers: MemberDiffInput[],
nextMembers: {
@ -75,6 +117,7 @@ export function buildReplaceMembersDiff(
providerId?: TeamProviderId;
model?: string;
effort?: EffortLevel;
mcpPolicy?: TeamMemberMcpPolicy;
}[]
): ReplaceMembersDiff {
const previousByName = new Map(
@ -90,6 +133,7 @@ export function buildReplaceMembersDiff(
providerId: member.providerId,
model: normalizeOptionalText(member.model),
effort: member.effort,
mcpPolicy: member.mcpPolicy,
},
])
);
@ -106,6 +150,7 @@ export function buildReplaceMembersDiff(
providerId: member.providerId,
model: normalizeOptionalText(member.model),
effort: member.effort,
mcpPolicy: member.mcpPolicy,
},
])
);
@ -133,6 +178,10 @@ export function buildReplaceMembersDiff(
? 'worktree isolation enabled'
: 'worktree isolation disabled'
: null,
describeProviderChange(previousMember.providerId, nextMember.providerId),
describeModelChange(previousMember.model, nextMember.model),
describeEffortChange(previousMember.effort, nextMember.effort),
describeMcpPolicyChange(previousMember.mcpPolicy, nextMember.mcpPolicy),
].filter((value): value is string => value !== null);
if (changes.length === 0) {
return [];

View file

@ -494,23 +494,34 @@ export class OpenCodePromptDeliveryLedgerStore {
input.visibleReplyCorrelation === 'plain_assistant_text'
? 'responded_plain_text'
: 'responded_visible_message';
return await this.updateExisting(input.id, (record) => ({
...record,
status: input.semanticallySufficient ? 'responded' : record.status,
responseState,
lastObservedAt: input.observedAt,
respondedAt: input.semanticallySufficient
? (record.respondedAt ?? input.observedAt)
: record.respondedAt,
visibleReplyInbox: input.visibleReplyInbox,
visibleReplyMessageId: input.visibleReplyMessageId,
visibleReplyCorrelation: input.visibleReplyCorrelation,
lastReason: input.semanticallySufficient
? record.lastReason
: selectOpenCodeDestinationProofInsufficientReason(input.diagnostics),
diagnostics: mergeDiagnostics(record.diagnostics, input.diagnostics ?? []),
updatedAt: input.observedAt,
}));
return await this.updateExisting(input.id, (record) => {
const diagnostics = input.semanticallySufficient
? mergeDiagnostics(record.diagnostics, [
...(record.lastReason ? [record.lastReason] : []),
...(input.diagnostics ?? []),
])
: mergeDiagnostics(record.diagnostics, input.diagnostics ?? []);
return {
...record,
status: input.semanticallySufficient ? 'responded' : record.status,
responseState,
acceptanceUnknown: input.semanticallySufficient ? false : record.acceptanceUnknown,
nextAttemptAt: input.semanticallySufficient ? null : record.nextAttemptAt,
lastObservedAt: input.observedAt,
respondedAt: input.semanticallySufficient
? (record.respondedAt ?? input.observedAt)
: record.respondedAt,
failedAt: input.semanticallySufficient ? null : record.failedAt,
visibleReplyInbox: input.visibleReplyInbox,
visibleReplyMessageId: input.visibleReplyMessageId,
visibleReplyCorrelation: input.visibleReplyCorrelation,
lastReason: input.semanticallySufficient
? null
: selectOpenCodeDestinationProofInsufficientReason(input.diagnostics),
diagnostics,
updatedAt: input.observedAt,
};
});
}
async markAcceptanceUnknown(input: {

View file

@ -165,6 +165,7 @@ function workSyncControlLines(input: OpenCodePromptDeliveryRepairInput): string[
'Open the current task, verify reviewState/status, then start or continue the review only if it is still assigned to you.',
'Do not mark the review complete from this retry text alone.',
`If you cannot pick up the review now, call agent-teams_member_work_sync_status or mcp__agent-teams__member_work_sync_status with ${args}, then report state "blocked" or "still_working" only for the real current state.`,
'Do not stop after member_work_sync_status. A status-only tool call is incomplete; member_work_sync_report is the required proof.',
taskIds ? `Relevant taskIds: ${taskIds}.` : null,
'Do not invent or reuse a raw report token from this retry text.',
].filter((line): line is string => line !== null);
@ -173,6 +174,7 @@ function workSyncControlLines(input: OpenCodePromptDeliveryRepairInput): string[
'This is a member-work-sync control message. A plain acknowledgement is not sufficient proof.',
`Call agent-teams_member_work_sync_status or mcp__agent-teams__member_work_sync_status with ${args}.`,
'Then call agent-teams_member_work_sync_report or mcp__agent-teams__member_work_sync_report using the agendaFingerprint/reportToken returned by status.',
'Do not stop after member_work_sync_status. A status-only tool call is incomplete; member_work_sync_report is the required proof.',
taskIds ? `Include taskIds ${taskIds} when reporting if those tasks are still relevant.` : null,
'Use state "still_working", "blocked", or "caught_up" according to the status result. Do not invent or reuse a raw report token from this retry text.',
].filter((line): line is string => line !== null);

View file

@ -389,6 +389,9 @@ export const TEAM_REPLACE_MEMBERS = 'team:replaceMembers';
/** Soft-delete a team member */
export const TEAM_REMOVE_MEMBER = 'team:removeMember';
/** Restore a soft-deleted team member */
export const TEAM_RESTORE_MEMBER = 'team:restoreMember';
/** Update a team member's role */
export const TEAM_UPDATE_MEMBER_ROLE = 'team:updateMemberRole';

View file

@ -178,6 +178,7 @@ import {
TEAM_REQUEST_REVIEW,
TEAM_RESTART_MEMBER,
TEAM_RESTORE,
TEAM_RESTORE_MEMBER,
TEAM_RESTORE_TASK,
TEAM_RETRY_FAILED_OPENCODE_SECONDARY_LANES,
TEAM_SAVE_TASK_ATTACHMENT,
@ -1145,6 +1146,9 @@ const electronAPI: ElectronAPI = {
removeMember: async (teamName: string, memberName: string) => {
return invokeIpcWithResult<void>(TEAM_REMOVE_MEMBER, teamName, memberName);
},
restoreMember: async (teamName: string, memberName: string) => {
return invokeIpcWithResult<void>(TEAM_RESTORE_MEMBER, teamName, memberName);
},
updateMemberRole: async (teamName: string, memberName: string, role: string | undefined) => {
return invokeIpcWithResult<void>(TEAM_UPDATE_MEMBER_ROLE, teamName, memberName, role);
},

View file

@ -995,6 +995,9 @@ export class HttpAPIClient implements ElectronAPI {
removeMember: async (): Promise<void> => {
throw new Error('Team member management is not available in browser mode');
},
restoreMember: async (): Promise<void> => {
throw new Error('Team member management is not available in browser mode');
},
updateMemberRole: async (): Promise<void> => {
throw new Error('Team member management is not available in browser mode');
},

View file

@ -632,6 +632,49 @@ function shouldShowOpenCodeProviderFreeBadge(provider: CliProviderStatus): boole
return provider.providerId === 'opencode';
}
function getOpenCodeDashboardChips(
provider: CliProviderStatus
): { label: string; title?: string }[] {
if (!shouldShowOpenCodeProviderFreeBadge(provider)) {
return [];
}
const catalogModels = provider.modelCatalog?.models ?? [];
const configuredLocalCount = new Set(
catalogModels
.filter((model) => model.metadata?.opencode?.routeKind === 'configured_local')
.map((model) => model.launchModel)
).size;
const verifiedCount = new Set(
catalogModels
.filter((model) => model.metadata?.opencode?.proofState === 'verified')
.map((model) => model.launchModel)
).size;
return [
{
label: 'Free models',
title: OPENCODE_PROVIDER_FREE_BADGE_TITLE,
},
...(configuredLocalCount > 0
? [
{
label: `${configuredLocalCount} configured local`,
title: 'Local OpenCode routes imported from your OpenCode config.',
},
]
: []),
...(verifiedCount > 0
? [
{
label: `${verifiedCount} verified`,
title: 'OpenCode routes with a successful execution proof.',
},
]
: []),
];
}
const InstalledBanner = ({
cliStatus,
sourceProviderMap,
@ -842,6 +885,7 @@ const InstalledBanner = ({
? getVisibleTeamProviderModels(provider.providerId, provider.models, provider)
.length > 0
: provider.models.length > 0;
const openCodeDashboardChips = getOpenCodeDashboardChips(provider);
const hasDetailContent = Boolean(
(provider.backend?.label && !runtimeSummary) ||
runtimeSummary ||
@ -873,14 +917,15 @@ const InstalledBanner = ({
? getProviderLabel(provider.providerId)
: provider.displayName}
</span>
{shouldShowOpenCodeProviderFreeBadge(provider) ? (
{openCodeDashboardChips.map((chip) => (
<span
key={chip.label}
className="rounded bg-[rgba(34,197,94,0.14)] px-1.5 py-px text-[9px] font-medium uppercase tracking-[0.06em] text-[rgb(74,222,128)]"
title={OPENCODE_PROVIDER_FREE_BADGE_TITLE}
title={chip.title}
>
Free models
{chip.label}
</span>
) : null}
))}
</span>
<span
className="text-xs"

View file

@ -57,7 +57,7 @@ import {
} from './taskFiltersState';
import type { TaskFiltersState } from './taskFiltersState';
import type { GlobalTask } from '@shared/types';
import type { GlobalTask, TeamSummary } from '@shared/types';
const TASK_GROUPING_STORAGE_KEY = 'sidebarTasksGrouping';
@ -177,6 +177,18 @@ function applyProjectFilter(tasks: GlobalTask[], projectPath: string | null): Gl
return tasks.filter((t) => t.projectPath && normalizePath(t.projectPath) === normalized);
}
function buildTaskTeamSummary(task: GlobalTask): TeamSummary {
return {
teamName: task.teamName,
displayName: task.teamDisplayName,
description: '',
memberCount: 0,
taskCount: 0,
lastActivity: task.updatedAt ?? task.createdAt ?? null,
projectPath: task.projectPath,
};
}
export const GlobalTaskList = memo(function GlobalTaskList({
hideHeader = false,
filters: externalFilters,
@ -331,7 +343,17 @@ export const GlobalTaskList = memo(function GlobalTaskList({
const offlineTeamNames = useMemo(() => {
const result = new Set<string>();
if (aliveTeamsInitialized) {
const teamSummariesByName = new Map<string, TeamSummary>();
for (const team of teams) {
teamSummariesByName.set(team.teamName, team);
}
for (const task of globalTasks) {
if (!teamSummariesByName.has(task.teamName)) {
teamSummariesByName.set(task.teamName, buildTaskTeamSummary(task));
}
}
for (const team of teamSummariesByName.values()) {
const status = resolveTeamStatus(
team,
team.teamName,
@ -350,7 +372,14 @@ export const GlobalTaskList = memo(function GlobalTaskList({
}
}
return result;
}, [aliveTeams, aliveTeamsInitialized, leadActivityByTeam, provisioningState, teams]);
}, [
aliveTeams,
aliveTeamsInitialized,
globalTasks,
leadActivityByTeam,
provisioningState,
teams,
]);
const setGroupingMode = (mode: TaskGroupingMode): void => {
setGroupingModeState(mode);

View file

@ -1516,6 +1516,7 @@ export const TeamDetailView = memo(function TeamDetailView({
restartMember,
skipMemberForLaunch,
removeMember,
restoreMember,
updateMemberRole,
launchTeam,
provisioningError,
@ -1572,6 +1573,7 @@ export const TeamDetailView = memo(function TeamDetailView({
restartMember: s.restartMember,
skipMemberForLaunch: s.skipMemberForLaunch,
removeMember: s.removeMember,
restoreMember: s.restoreMember,
updateMemberRole: s.updateMemberRole,
launchTeam: s.launchTeam,
provisioningError: teamName ? (s.provisioningErrorByTeam[teamName] ?? null) : null,
@ -2151,6 +2153,13 @@ export const TeamDetailView = memo(function TeamDetailView({
[skipMemberForLaunch, teamName]
);
const handleRestoreMember = useCallback(
async (memberName: string): Promise<void> => {
await restoreMember(teamName, memberName);
},
[restoreMember, teamName]
);
const handleSelectMember = useCallback((member: ResolvedTeamMember) => {
setSelectedMember(member);
setSelectedMemberView(null);
@ -2898,6 +2907,7 @@ export const TeamDetailView = memo(function TeamDetailView({
onOpenTask={handleOpenTaskById}
onRestartMember={handleRestartMember}
onSkipMemberForLaunch={handleSkipMemberForLaunch}
onRestoreMember={handleRestoreMember}
/>
</div>
</CollapsibleTeamSection>

View file

@ -380,7 +380,7 @@ export const EditTeamDialog = ({
members.map((member) => [
member.id,
restartNames.has(member.name.trim().toLowerCase())
? 'Saving will restart this teammate to apply role, workflow, worktree isolation, provider, model, or effort changes.'
? 'Saving will restart this teammate to apply role, workflow, worktree isolation, provider, model, effort, or MCP access changes.'
: null,
])
);
@ -705,8 +705,8 @@ export const EditTeamDialog = ({
<p className="text-xs text-amber-300">
Saving will restart or relaunch{' '}
{liveRuntimeRefreshMemberNames.length === 1 ? 'this teammate' : 'these teammates'} to
apply role, workflow, worktree isolation, provider, model, or effort changes:{' '}
{liveRuntimeRefreshMemberNames.join(', ')}.
apply role, workflow, worktree isolation, provider, model, effort, or MCP access
changes: {liveRuntimeRefreshMemberNames.join(', ')}.
</p>
) : null}
<div>

View file

@ -77,9 +77,16 @@ interface OpenCodeSourceInfo {
label: string;
}
interface OpenCodeRouteGroupInfo {
id: string;
label: string;
rank: number;
}
interface OpenCodeModelGroup {
sourceId: string;
sourceLabel: string;
groupId: string;
groupLabel: string;
rank: number;
options: TeamRuntimeModelOption[];
}
@ -87,6 +94,8 @@ interface OpenCodeModelOptionMetadata {
option: TeamRuntimeModelOption;
index: number;
sourceInfo: OpenCodeSourceInfo | null;
routeGroup: OpenCodeRouteGroupInfo;
routeMetadata: NonNullable<ProviderModelCatalogItem['metadata']>['opencode'] | null;
recommendation: ReturnType<typeof getTeamModelRecommendation>;
pricingInfo: OpenCodeModelPricingInfo | null;
searchText: string;
@ -151,6 +160,22 @@ function getOpenCodeSourceInfo(model: string): OpenCodeSourceInfo | null {
};
}
function getOpenCodeRouteGroup(
catalogModel: ProviderModelCatalogItem | null | undefined
): OpenCodeRouteGroupInfo {
const routeKind = catalogModel?.metadata?.opencode?.routeKind;
if (routeKind === 'configured_local') {
return { id: 'opencode-config', label: 'OpenCode config', rank: 0 };
}
if (routeKind === 'builtin_free') {
return { id: 'builtin-free', label: 'Free built-in', rank: 1 };
}
if (routeKind === 'connected_provider') {
return { id: 'connected-providers', label: 'Connected providers', rank: 2 };
}
return { id: 'catalog-provider', label: 'Other OpenCode catalog', rank: 3 };
}
function isRecommendedTeamModelRecommendation(
recommendation: ReturnType<typeof getTeamModelRecommendation>
): boolean {
@ -162,11 +187,15 @@ function isRecommendedTeamModelRecommendation(
function buildOpenCodeModelSearchText({
option,
sourceInfo,
routeGroup,
routeMetadata,
recommendation,
pricingInfo,
}: {
option: TeamRuntimeModelOption;
sourceInfo: OpenCodeSourceInfo | null;
routeGroup: OpenCodeRouteGroupInfo;
routeMetadata: NonNullable<ProviderModelCatalogItem['metadata']>['opencode'] | null;
recommendation: ReturnType<typeof getTeamModelRecommendation>;
pricingInfo: OpenCodeModelPricingInfo | null;
}): string {
@ -175,6 +204,9 @@ function buildOpenCodeModelSearchText({
option.label,
option.badgeLabel ?? '',
sourceInfo?.label ?? '',
routeGroup.label,
routeMetadata?.proofState ?? '',
routeMetadata?.accessKind ?? '',
recommendation?.label ?? '',
recommendation?.reason ?? '',
pricingInfo?.free ? 'free' : '',
@ -219,15 +251,15 @@ function buildOpenCodeVirtualRows({
for (const group of groups) {
rows.push({
kind: 'heading',
key: `heading:${group.sourceId}`,
sourceLabel: group.sourceLabel,
key: `heading:${group.groupId}`,
sourceLabel: group.groupLabel,
count: group.options.length,
});
for (let start = 0; start < group.options.length; start += columnCount) {
rows.push({
kind: 'models',
key: `models:${group.sourceId}:${start}`,
key: `models:${group.groupId}:${start}`,
options: group.options.slice(start, start + columnCount),
isLastInGroup: start + columnCount >= group.options.length,
});
@ -726,6 +758,15 @@ export const TeamModelSelector: React.FC<TeamModelSelectorProps> = ({
return `Uses the Claude team default model.\nResolves to ${defaultLongContextModel} with 1M context, or ${defaultLimitedContextModel} with 200K context when Limit context is enabled.`;
}
if (effectiveProviderId === 'opencode') {
const defaultOpenCodeModel =
runtimeProviderStatus?.modelCatalog?.defaultLaunchModel ??
runtimeProviderStatus?.modelCatalog?.defaultModelId ??
null;
return defaultOpenCodeModel
? `Uses the OpenCode default model.\nCurrently resolves to ${defaultOpenCodeModel}.`
: 'Uses the OpenCode runtime default model.';
}
return 'Uses the runtime default for the selected provider.';
}, [effectiveProviderId, runtimeProviderStatus]);
const getProviderOverrideDisabledReason = (candidateProviderId: string): string | null => {
@ -864,17 +905,24 @@ export const TeamModelSelector: React.FC<TeamModelSelectorProps> = ({
return modelOptions.map((option, index) => {
const sourceInfo = getOpenCodeSourceInfo(option.value);
const recommendation = getTeamModelRecommendation(effectiveProviderId, option.value);
const pricingInfo = getOpenCodeModelPricingInfo(openCodeCatalogModelById.get(option.value));
const catalogModel = openCodeCatalogModelById.get(option.value);
const pricingInfo = getOpenCodeModelPricingInfo(catalogModel);
const routeGroup = getOpenCodeRouteGroup(catalogModel);
const routeMetadata = catalogModel?.metadata?.opencode ?? null;
return {
option,
index,
sourceInfo,
routeGroup,
routeMetadata,
recommendation,
pricingInfo,
searchText: buildOpenCodeModelSearchText({
option,
sourceInfo,
routeGroup,
routeMetadata,
recommendation,
pricingInfo,
}),
@ -997,10 +1045,10 @@ export const TeamModelSelector: React.FC<TeamModelSelectorProps> = ({
const openCodeSourceFilterLabel =
selectedOpenCodeSourceLabels.length === 0
? 'All OpenCode providers'
? 'All OpenCode sources'
: selectedOpenCodeSourceLabels.length === 1
? selectedOpenCodeSourceLabels[0]
: `${selectedOpenCodeSourceLabels.length} OpenCode providers`;
: `${selectedOpenCodeSourceLabels.length} OpenCode sources`;
const toggleOpenCodeSourceFilter = (sourceId: string): void => {
setSelectedOpenCodeSourceIds((previous) => {
@ -1102,24 +1150,21 @@ export const TeamModelSelector: React.FC<TeamModelSelectorProps> = ({
continue;
}
const sourceInfo = metadata.sourceInfo;
if (!sourceInfo) {
continue;
}
const existingGroup = groups.get(sourceInfo.id);
const routeGroup = metadata.routeGroup;
const existingGroup = groups.get(routeGroup.id);
if (existingGroup) {
existingGroup.options.push(option);
} else {
groups.set(sourceInfo.id, {
sourceId: sourceInfo.id,
sourceLabel: sourceInfo.label,
groups.set(routeGroup.id, {
groupId: routeGroup.id,
groupLabel: routeGroup.label,
rank: routeGroup.rank,
options: [option],
});
}
}
return Array.from(groups.values());
return Array.from(groups.values()).sort((left, right) => left.rank - right.rank);
}, [effectiveProviderId, visibleOpenCodeModelMetadata]);
const visibleDefaultModelOptions = visibleModelOptions.filter((option) => !option.value.trim());
const visibleConcreteModelOptionCount =
@ -1227,6 +1272,10 @@ export const TeamModelSelector: React.FC<TeamModelSelectorProps> = ({
}
const openCodePricingInfo =
effectiveProviderId === 'opencode' ? (openCodeMetadata?.pricingInfo ?? null) : null;
const openCodeRouteMetadata =
effectiveProviderId === 'opencode' ? (openCodeMetadata?.routeMetadata ?? null) : null;
const openCodeRouteKind = openCodeRouteMetadata?.routeKind ?? null;
const openCodeProofState = openCodeRouteMetadata?.proofState ?? null;
const modelButtonTitle =
modelStatusMessage ?? (opt.value === '' ? defaultModelTooltip : undefined);
@ -1269,6 +1318,11 @@ export const TeamModelSelector: React.FC<TeamModelSelectorProps> = ({
>
{opt.label}
</span>
{openCodeMetadata?.sourceInfo ? (
<span className="inline-flex items-center justify-center rounded-full border border-white/10 bg-white/[0.04] px-1.5 py-0 text-[9px] font-semibold uppercase text-[var(--color-text-secondary)]">
{openCodeMetadata.sourceInfo.label}
</span>
) : null}
{openCodePricingInfo?.summary ? (
<span
data-testid="team-model-selector-model-pricing"
@ -1278,7 +1332,7 @@ export const TeamModelSelector: React.FC<TeamModelSelectorProps> = ({
{openCodePricingInfo.summary}
</span>
) : null}
{openCodePricingInfo?.free ? (
{openCodePricingInfo?.free || openCodeRouteKind === 'builtin_free' ? (
<span
data-testid="team-model-selector-model-free-badge"
className="inline-flex items-center justify-center rounded-full border border-emerald-300/30 bg-emerald-300/10 px-1.5 py-0 text-[9px] font-semibold uppercase text-emerald-200"
@ -1287,6 +1341,36 @@ export const TeamModelSelector: React.FC<TeamModelSelectorProps> = ({
Free
</span>
) : null}
{openCodeRouteKind === 'configured_local' ? (
<span className="inline-flex items-center justify-center rounded-full border border-cyan-300/30 bg-cyan-300/10 px-1.5 py-0 text-[9px] font-semibold uppercase text-cyan-200">
Local
</span>
) : null}
{openCodeRouteKind === 'configured_local' ? (
<span className="inline-flex items-center justify-center rounded-full border border-sky-300/30 bg-sky-300/10 px-1.5 py-0 text-[9px] font-semibold uppercase text-sky-200">
Configured
</span>
) : null}
{openCodeRouteKind === 'connected_provider' ? (
<span className="inline-flex items-center justify-center rounded-full border border-emerald-300/30 bg-emerald-300/10 px-1.5 py-0 text-[9px] font-semibold uppercase text-emerald-100">
Connected
</span>
) : null}
{openCodeProofState === 'verified' ? (
<span className="inline-flex items-center justify-center rounded-full border border-emerald-300/30 bg-emerald-300/10 px-1.5 py-0 text-[9px] font-semibold uppercase text-emerald-100">
Verified
</span>
) : null}
{openCodeProofState === 'needs_probe' ? (
<span className="inline-flex items-center justify-center rounded-full border border-amber-300/30 bg-amber-300/10 px-1.5 py-0 text-[9px] font-semibold uppercase text-amber-200">
Needs test
</span>
) : null}
{openCodeProofState === 'failed' ? (
<span className="inline-flex items-center justify-center rounded-full border border-red-300/30 bg-red-400/10 px-1.5 py-0 text-[9px] font-semibold uppercase text-red-200">
Failed
</span>
) : null}
{modelRecommendation ? (
<span
className={cn(
@ -1560,7 +1644,7 @@ export const TeamModelSelector: React.FC<TeamModelSelectorProps> = ({
selectedOpenCodeSourceIds.size > 0 &&
'border-[var(--color-border-emphasis)] text-[var(--color-text)]'
)}
aria-label="Filter OpenCode providers"
aria-label="Filter OpenCode sources"
>
<Filter className="size-3.5 shrink-0" />
<span className="min-w-0 truncate">{openCodeSourceFilterLabel}</span>
@ -1576,22 +1660,22 @@ export const TeamModelSelector: React.FC<TeamModelSelectorProps> = ({
<CommandPrimitive.Input
value={openCodeSourceQuery}
onValueChange={setOpenCodeSourceQuery}
placeholder="Search providers"
placeholder="Search sources"
className="flex h-8 w-full border-0 bg-transparent px-2 py-1 text-xs text-[var(--color-text)] outline-none placeholder:text-[var(--color-text-muted)]"
/>
</div>
<CommandPrimitive.List className="max-h-72 overflow-y-auto overscroll-contain p-1">
<CommandPrimitive.Empty className="py-4 text-center text-xs text-[var(--color-text-muted)]">
No providers found.
No sources found.
</CommandPrimitive.Empty>
{selectedOpenCodeSourceIds.size > 0 && !openCodeSourceQuery.trim() ? (
<CommandPrimitive.Item
value="__all_opencode_providers__"
value="__all_opencode_sources__"
onSelect={() => setSelectedOpenCodeSourceIds(new Set())}
className="flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-xs text-[var(--color-text-muted)] outline-none data-[selected=true]:bg-[var(--color-surface-raised)] data-[selected=true]:text-[var(--color-text)]"
>
<Check className="size-3.5 shrink-0 opacity-70" />
All OpenCode providers
All OpenCode sources
</CommandPrimitive.Item>
) : null}
{filteredOpenCodeSourceOptions.map((source) => {
@ -1686,13 +1770,10 @@ export const TeamModelSelector: React.FC<TeamModelSelectorProps> = ({
</div>
) : null}
{visibleOpenCodeModelGroups.map((group) => (
<section
key={group.sourceId}
data-testid="team-model-selector-opencode-group"
>
<section key={group.groupId} data-testid="team-model-selector-opencode-group">
<div className="mb-1.5 flex items-center justify-between gap-2">
<h4 className="truncate text-[11px] font-semibold uppercase tracking-[0.08em] text-[var(--color-text-secondary)]">
{group.sourceLabel}
{group.groupLabel}
</h4>
<span className="shrink-0 text-[10px] text-[var(--color-text-muted)]">
{group.options.length}

View file

@ -40,6 +40,7 @@ import {
Plus,
RotateCcw,
Server,
Undo2,
} from 'lucide-react';
import { CurrentTaskIndicator } from './CurrentTaskIndicator';
@ -99,6 +100,7 @@ interface MemberCardProps {
onAssignTask?: () => void;
onRestartMember?: (memberName: string) => Promise<void> | void;
onSkipMemberForLaunch?: (memberName: string) => Promise<void> | void;
onRestoreMember?: (memberName: string) => Promise<void> | void;
}
const MEMBER_ROW_SURFACE_BLEED_CLASS = '-mx-[calc(1rem-5px)] px-[calc(1rem-5px)]';
@ -636,6 +638,7 @@ export const MemberCard = memo(function MemberCard({
onAssignTask,
onRestartMember,
onSkipMemberForLaunch,
onRestoreMember,
}: MemberCardProps): React.JSX.Element {
// NOTE: lead context display disabled — usage formula is inaccurate
// const teamName = useStore((s) => s.selectedTeamName);
@ -647,6 +650,8 @@ export const MemberCard = memo(function MemberCard({
const [retryLaunchError, setRetryLaunchError] = useState<string | null>(null);
const [skippingLaunch, setSkippingLaunch] = useState(false);
const [skipLaunchError, setSkipLaunchError] = useState<string | null>(null);
const [restoringMember, setRestoringMember] = useState(false);
const [restoreMemberError, setRestoreMemberError] = useState<string | null>(null);
const teamMembers = useStore((s) =>
selectedTeamName ? selectResolvedMembersForTeamName(s, selectedTeamName) : []
);
@ -953,6 +958,7 @@ export const MemberCard = memo(function MemberCard({
canRelaunchOpenCode || canRelaunchRuntimeAdvisoryOpenCode
? 'Failed to relaunch OpenCode teammate'
: 'Failed to retry teammate';
const canRestoreMember = isRemoved && !isLeadMember(member) && Boolean(onRestoreMember);
const handleRestartMember = async (event: React.MouseEvent<HTMLButtonElement>): Promise<void> => {
event.preventDefault();
event.stopPropagation();
@ -987,6 +993,22 @@ export const MemberCard = memo(function MemberCard({
setSkippingLaunch(false);
}
};
const handleRestoreMember = async (event: React.MouseEvent<HTMLButtonElement>): Promise<void> => {
event.preventDefault();
event.stopPropagation();
if (!onRestoreMember || restoringMember) {
return;
}
setRestoreMemberError(null);
setRestoringMember(true);
try {
await onRestoreMember(member.name);
} catch (error) {
setRestoreMemberError(error instanceof Error ? error.message : 'Failed to restore teammate');
} finally {
setRestoringMember(false);
}
};
const cardContent = (
<div
@ -1488,6 +1510,28 @@ export const MemberCard = memo(function MemberCard({
</Tooltip>
</div>
)}
{canRestoreMember ? (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
aria-label={restoringMember ? 'Restoring teammate' : 'Restore teammate'}
className="rounded p-1 text-[var(--color-text-muted)] transition-colors hover:bg-[var(--color-surface)] hover:text-[var(--color-text)] disabled:cursor-not-allowed disabled:opacity-60"
disabled={restoringMember}
onClick={handleRestoreMember}
>
{restoringMember ? (
<SyncedLoader2 className="size-3.5" />
) : (
<Undo2 className="size-3.5" />
)}
</button>
</TooltipTrigger>
<TooltipContent side="bottom">
{restoreMemberError ?? (restoringMember ? 'Restoring teammate...' : 'Restore')}
</TooltipContent>
</Tooltip>
) : null}
</div>
</div>
</div>

View file

@ -51,6 +51,7 @@ interface MemberListProps {
onOpenTask?: (taskId: string) => void;
onRestartMember?: (memberName: string) => Promise<void> | void;
onSkipMemberForLaunch?: (memberName: string) => Promise<void> | void;
onRestoreMember?: (memberName: string) => Promise<void> | void;
}
function areResolvedMembersEquivalent(
@ -464,6 +465,7 @@ function areMemberListPropsEqual(
prev.leadActivity === next.leadActivity &&
prev.onRestartMember === next.onRestartMember &&
prev.onSkipMemberForLaunch === next.onSkipMemberForLaunch &&
prev.onRestoreMember === next.onRestoreMember &&
areLaunchParamsEquivalent(prev.launchParams, next.launchParams)
);
}
@ -505,6 +507,7 @@ interface MemberCardRowProps {
onAssignTask?: (member: ResolvedTeamMember) => void;
onRestartMember?: (memberName: string) => Promise<void> | void;
onSkipMemberForLaunch?: (memberName: string) => Promise<void> | void;
onRestoreMember?: (memberName: string) => Promise<void> | void;
}
const MemberCardRow = memo(function MemberCardRow({
@ -540,6 +543,7 @@ const MemberCardRow = memo(function MemberCardRow({
onAssignTask,
onRestartMember,
onSkipMemberForLaunch,
onRestoreMember,
}: MemberCardRowProps): React.JSX.Element {
const currentTaskId = currentTask?.id;
const reviewTaskId = reviewTask?.id;
@ -591,6 +595,7 @@ const MemberCardRow = memo(function MemberCardRow({
onAssignTask={handleAssignTask}
onRestartMember={onRestartMember}
onSkipMemberForLaunch={onSkipMemberForLaunch}
onRestoreMember={onRestoreMember}
/>
);
});
@ -713,6 +718,7 @@ export const MemberList = memo(function MemberList({
onOpenTask,
onRestartMember,
onSkipMemberForLaunch,
onRestoreMember,
}: MemberListProps): React.JSX.Element {
const containerRef = useRef<HTMLDivElement>(null);
const [isWide, setIsWide] = useState(false);
@ -1006,6 +1012,7 @@ export const MemberList = memo(function MemberList({
onAssignTask={onAssignTask}
onRestartMember={onRestartMember}
onSkipMemberForLaunch={onSkipMemberForLaunch}
onRestoreMember={onRestoreMember}
/>
);
})}
@ -1051,6 +1058,7 @@ export const MemberList = memo(function MemberList({
onAssignTask={onAssignTask}
onRestartMember={undefined}
onSkipMemberForLaunch={undefined}
onRestoreMember={onRestoreMember}
/>
))}
</div>

View file

@ -2989,6 +2989,7 @@ export interface TeamSlice {
restartMember: (teamName: string, memberName: string) => Promise<void>;
skipMemberForLaunch: (teamName: string, memberName: string) => Promise<void>;
removeMember: (teamName: string, memberName: string) => Promise<void>;
restoreMember: (teamName: string, memberName: string) => Promise<void>;
updateMemberRole: (
teamName: string,
memberName: string,
@ -5404,6 +5405,15 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
await get().refreshTeamData(teamName);
},
restoreMember: async (teamName: string, memberName: string) => {
await unwrapIpc('team:restoreMember', () => api.teams.restoreMember(teamName, memberName));
await get().refreshTeamData(teamName);
await Promise.allSettled([
get().fetchMemberSpawnStatuses(teamName),
get().fetchTeamAgentRuntime(teamName),
]);
},
updateMemberRole: async (teamName: string, memberName: string, role: string | undefined) => {
await unwrapIpc('team:updateMemberRole', () =>
api.teams.updateMemberRole(teamName, memberName, role)

View file

@ -20,6 +20,11 @@ const ACTIVE_PROVISIONING_STATES = new Set<TeamProvisioningProgress['state']>([
const READY_RUNNING_GRACE_MS = 45_000;
const STOPPED_PROVISIONING_STATES = new Set<TeamProvisioningProgress['state']>([
'cancelled',
'disconnected',
]);
function isRecentReadyProgress(
currentProgress: TeamProvisioningProgress | null,
nowMs: number
@ -49,6 +54,13 @@ export function resolveTeamStatus(
return 'offline';
}
// The renderer keeps an async alive-list cache. After a stop/cancel event,
// that cache can briefly still include the team, but terminal progress is
// the fresher signal and should make in-progress UI static immediately.
if (currentProgress && STOPPED_PROVISIONING_STATES.has(currentProgress.state)) {
return 'offline';
}
if (aliveTeams.includes(teamName)) {
return leadActivity === 'active' ? 'active' : 'idle';
}

View file

@ -67,6 +67,21 @@ export interface TeamProviderModelVerificationCounts {
verifying: boolean;
}
function mergeModelLists(primary: readonly string[], supplemental: readonly string[]): string[] {
const merged = new Map<string, string>();
for (const model of [...primary, ...supplemental]) {
const trimmed = model.trim();
if (!trimmed) {
continue;
}
const key = trimmed.toLowerCase();
if (!merged.has(key)) {
merged.set(key, trimmed);
}
}
return Array.from(merged.values());
}
export function getOpenCodeOpenAiRouteAuthUnavailableReason(
providerId: SupportedProviderId | undefined,
model: string | undefined,
@ -294,7 +309,11 @@ function getRuntimeSelectorModels(
const catalogModels = getRuntimeCatalogModels(providerId, providerStatus);
if (catalogModels) {
return getVisibleTeamProviderModels(providerId, catalogModels, providerStatus);
const sourceModels =
providerId === 'opencode'
? mergeModelLists(catalogModels, providerStatus.models)
: catalogModels;
return getVisibleTeamProviderModels(providerId, sourceModels, providerStatus);
}
return sortTeamProviderModels(providerId, providerStatus.models, providerStatus);

View file

@ -509,6 +509,21 @@ function getRuntimeCatalogLaunchModels(
return models.length > 0 ? models : null;
}
function mergeModelLists(primary: readonly string[], supplemental: readonly string[]): string[] {
const merged = new Map<string, string>();
for (const model of [...primary, ...supplemental]) {
const trimmed = model.trim();
if (!trimmed) {
continue;
}
const key = trimmed.toLowerCase();
if (!merged.has(key)) {
merged.set(key, trimmed);
}
}
return Array.from(merged.values());
}
function getSupplementalVisibleModels(
providerId: SupportedProviderId,
models: readonly string[]
@ -525,10 +540,10 @@ export function getVisibleTeamProviderModels(
models: readonly string[],
providerStatus?: RuntimeAwareProviderStatus | null
): string[] {
const catalogModels =
providerId === 'opencode' ? getRuntimeCatalogLaunchModels(providerId, providerStatus) : null;
const sourceModels =
providerId === 'opencode'
? (getRuntimeCatalogLaunchModels(providerId, providerStatus) ?? models)
: models;
providerId === 'opencode' && catalogModels ? mergeModelLists(catalogModels, models) : models;
return sortTeamProviderModels(
providerId,

View file

@ -578,6 +578,7 @@ export interface TeamsAPI {
addMember: (teamName: string, request: AddMemberRequest) => Promise<void>;
replaceMembers: (teamName: string, request: ReplaceMembersRequest) => Promise<void>;
removeMember: (teamName: string, memberName: string) => Promise<void>;
restoreMember: (teamName: string, memberName: string) => Promise<void>;
updateMemberRole: (
teamName: string,
memberName: string,

View file

@ -132,6 +132,35 @@ export type CliProviderModelCatalogSource =
| 'static-fallback';
export type CliProviderModelCatalogStatus = 'ready' | 'stale' | 'degraded' | 'unavailable';
export type OpenCodeModelAccessKind =
| 'no_model'
| 'unknown_model'
| 'credentialed'
| 'builtin_free'
| 'configured_authless'
| 'verified'
| 'not_authenticated'
| 'execution_failed';
export type OpenCodeModelRouteKind =
| 'connected_provider'
| 'builtin_free'
| 'configured_local'
| 'catalog_provider';
export type OpenCodeModelProofState = 'not_required' | 'needs_probe' | 'verified' | 'failed';
export interface OpenCodeModelRouteMetadata {
providerId: string | null;
modelId: string | null;
sourceLabel: string | null;
accessKind: OpenCodeModelAccessKind;
routeKind: OpenCodeModelRouteKind;
proofState: OpenCodeModelProofState;
requiresExecutionProof: boolean;
reason: string | null;
}
export interface CliProviderModelCatalogItem {
id: string;
launchModel: string;
@ -152,6 +181,7 @@ export interface CliProviderModelCatalogItem {
context?: number | null;
limits?: unknown;
free?: boolean;
opencode?: OpenCodeModelRouteMetadata | null;
} | null;
}

View file

@ -15,7 +15,7 @@ export interface TeamGraphDefaultLayoutSeed {
assignments: Record<string, GraphOwnerSlotAssignment>;
}
const SMALL_TEAM_CARDINAL_SLOT_PRESETS: readonly (readonly GraphOwnerSlotAssignment[])[] = [
const DEFAULT_OWNER_SLOT_PRESETS: readonly (readonly GraphOwnerSlotAssignment[])[] = [
[],
[{ ringIndex: 0, sectorIndex: 0 }],
[
@ -117,6 +117,23 @@ const SMALL_TEAM_CARDINAL_SLOT_PRESETS: readonly (readonly GraphOwnerSlotAssignm
{ ringIndex: 3, sectorIndex: 1 },
{ ringIndex: 3, sectorIndex: 2 },
],
[],
[
{ ringIndex: 0, sectorIndex: 0 },
{ ringIndex: 0, sectorIndex: 1 },
{ ringIndex: 0, sectorIndex: 2 },
{ ringIndex: 1, sectorIndex: 0 },
{ ringIndex: 1, sectorIndex: 1 },
{ ringIndex: 1, sectorIndex: 2 },
{ ringIndex: 2, sectorIndex: 0 },
{ ringIndex: 2, sectorIndex: 1 },
{ ringIndex: 3, sectorIndex: 0 },
{ ringIndex: 3, sectorIndex: 1 },
{ ringIndex: 3, sectorIndex: 2 },
{ ringIndex: 4, sectorIndex: 0 },
{ ringIndex: 4, sectorIndex: 1 },
{ ringIndex: 4, sectorIndex: 2 },
],
];
export function buildOrderedVisibleTeamGraphOwnerIds(
@ -164,7 +181,7 @@ export function buildTeamGraphDefaultLayoutSeed(
): TeamGraphDefaultLayoutSeed {
const orderedVisibleOwnerIds = buildOrderedVisibleTeamGraphOwnerIds(members, configMembers);
const signature = orderedVisibleOwnerIds.length > 0 ? orderedVisibleOwnerIds.join('|') : null;
const preset = SMALL_TEAM_CARDINAL_SLOT_PRESETS[orderedVisibleOwnerIds.length];
const preset = DEFAULT_OWNER_SLOT_PRESETS[orderedVisibleOwnerIds.length];
const assignments: Record<string, GraphOwnerSlotAssignment> = {};
if (preset?.length === orderedVisibleOwnerIds.length) {

View file

@ -43,7 +43,10 @@ declare module 'agent-teams-controller' {
unlinkTask(taskId: string, targetId: string, linkType: string): unknown;
memberBriefing(
memberName: string,
options?: { runtimeProvider?: 'native' | 'opencode'; includeActiveProcesses?: boolean }
options?: {
runtimeProvider?: 'native' | 'opencode' | 'codex';
includeActiveProcesses?: boolean;
}
): Promise<string>;
leadBriefing(): Promise<string>;
taskBriefing(memberName: string): Promise<string>;
@ -51,7 +54,7 @@ declare module 'agent-teams-controller' {
export interface ControllerKanbanApi {
getKanbanState(): unknown;
setKanbanColumn(taskId: string, column: string): unknown;
setKanbanColumn(taskId: string, column: string, options?: Record<string, unknown>): unknown;
clearKanban(taskId: string): unknown;
listReviewers(): string[];
addReviewer(reviewer: string): string[];

View file

@ -1,5 +1,5 @@
{
"generatedAt": "2026-05-20T15:19:19.600Z",
"generatedAt": "2026-05-20T15:44:19.975Z",
"runsPerModel": 1,
"qualification": {
"minimumAverageScore": 80,
@ -25,8 +25,8 @@
"runtimeTransportFailures": 0,
"modelBehaviorFailures": 0,
"harnessFailures": 0,
"p50DurationMs": 201546,
"p95DurationMs": 201546,
"p50DurationMs": 201184,
"p95DurationMs": 201184,
"stagePassRates": {
"launchBootstrap": {
"passed": 1,
@ -217,16 +217,16 @@
"outcome": "passed",
"failureCategory": "none",
"primaryFailure": null,
"durationMs": 201546,
"durationMs": 201184,
"hardFailure": false,
"stageDurationsMs": {
"setup": 171,
"launchBootstrap": 41905,
"materializeTasks": 39,
"directReply": 29109,
"peerRelayAB": 45148,
"peerRelayBC": 39967,
"concurrentReplies": 28807,
"setup": 322,
"launchBootstrap": 44102,
"materializeTasks": 40,
"directReply": 20838,
"peerRelayAB": 41022,
"peerRelayBC": 47832,
"concurrentReplies": 29138,
"hygiene": 1
},
"stageFailures": {},
@ -253,7 +253,7 @@
"latencyStable": true
},
"diagnostics": [
"runId=1d6a50c3-c5cc-4c1e-91a0-d0e34a2229a3"
"runId=85e7ecb6-0767-4606-90d2-c926937b22f5"
]
}
]

View file

@ -1,6 +1,6 @@
# OpenCode Model Gauntlet Results
Generated: 2026-05-20T15:19:19.600Z
Generated: 2026-05-20T15:44:19.975Z
Runs per model: 1
Recommended threshold: average >= 80, successful runs >= 1, consistency >= 85, hard failures = 0
@ -13,7 +13,7 @@ Scoring weights: launchBootstrap=15, directReply=10, peerRelayAB=15, peerRelayBC
| Model | Verdict | Confidence | Readiness | Consistency | Score Spread | Behavior Avg | Overall Avg | Counted | Pass Runs | Weakest Stage | Weakest TaskRef | Dominant Failure | Blockers | Provider Infra | Runtime Transport | Model Fails | Protocol Runs | p50 | p95 |
| --- | --- | --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | --- | --- | --- | --- | ---: | ---: | ---: | ---: | ---: | ---: |
| `opencode/big-pickle` | Recommended | low | 100 | 100 | 0 | 100 | 100 | 1/1 | 1/1 | cleanTranscript 1/1 (100%) | concurrentBob 1/1 (100%) | none | - | 0 | 0 | 0 | 0 | 201546ms | 201546ms |
| `opencode/big-pickle` | Recommended | low | 100 | 100 | 0 | 100 | 100 | 1/1 | 1/1 | cleanTranscript 1/1 (100%) | concurrentBob 1/1 (100%) | none | - | 0 | 0 | 0 | 0 | 201184ms | 201184ms |
## opencode/big-pickle
@ -33,5 +33,5 @@ Protocol totals: badMessages=0, duplicateOrMissingTokens=0, affectedRuns=0.
| Run | Outcome | Category | Score | Counted | Duration | Failed Stages | Slowest Stage | TaskRefs | Protocol | Diagnostics |
| ---: | --- | --- | ---: | --- | ---: | --- | --- | --- | --- | --- |
| 1 | passed | none | 100 | yes | 201546ms | - | peerRelayAB:45148ms | directReply:ok, peerRelayAB:ok, peerRelayBC:ok, concurrentBob:ok, concurrentTom:ok | - | runId=1d6a50c3-c5cc-4c1e-91a0-d0e34a2229a3 |
| 1 | passed | none | 100 | yes | 201184ms | - | peerRelayBC:47832ms | directReply:ok, peerRelayAB:ok, peerRelayBC:ok, concurrentBob:ok, concurrentTom:ok | - | runId=85e7ecb6-0767-4606-90d2-c926937b22f5 |

View file

@ -145,6 +145,19 @@ function getRowWidths(snapshot: StableSlotLayoutSnapshot): number[] {
});
}
function getFramesByRow(
snapshot: StableSlotLayoutSnapshot
): Map<number, StableSlotLayoutSnapshot['memberSlotFrames']> {
const rows = new Map<number, StableSlotLayoutSnapshot['memberSlotFrames']>();
for (const frame of snapshot.memberSlotFrames) {
rows.set(frame.ringIndex, [...(rows.get(frame.ringIndex) ?? []), frame]);
}
for (const row of rows.values()) {
row.sort((left, right) => left.sectorIndex - right.sectorIndex);
}
return rows;
}
describe('stable slot layout', () => {
it('packs six legacy radial owners into two row-orbit rows', () => {
const { nodes, layout } = buildSixOwnerGraph();
@ -239,6 +252,32 @@ describe('stable slot layout', () => {
expect(maxRowWidth).toBeLessThan(maxFrameWidth * 4);
});
it('packs fourteen radial owners into aligned rows around the lead', () => {
const { nodes, layout } = buildRowOrbitGraph(14, [3, 3, 2, 3, 3]);
const snapshot = getSnapshot(nodes, layout);
expect(snapshot.ownerSlotLayoutKind).toBe('row-orbit');
expect(getRowCounts(snapshot)).toEqual([3, 3, 2, 3, 3]);
const rows = getFramesByRow(snapshot);
const middleRow = rows.get(2)!;
expect(middleRow).toHaveLength(2);
expect(middleRow[0]!.ownerY).toBe(0);
expect(middleRow[1]!.ownerY).toBe(0);
for (const rowIndex of [0, 1, 3, 4]) {
const row = rows.get(rowIndex)!;
expect(row).toHaveLength(3);
expect(row[0]!.ownerX).toBeCloseTo(middleRow[0]!.ownerX, 5);
expect(row[1]!.ownerX).toBeCloseTo(0, 5);
expect(row[2]!.ownerX).toBeCloseTo(middleRow[1]!.ownerX, 5);
}
for (const frame of middleRow) {
expect(rectsOverlap(frame.bounds, snapshot.runtimeCentralExclusion)).toBe(false);
}
});
it('swaps with the nearest existing row-orbit slot while dragging', () => {
const { nodes, layout } = buildRowOrbitGraph(8, [3, 2, 3]);
const snapshot = getSnapshot(nodes, layout);

View file

@ -1,9 +1,9 @@
import { describe, expect, it } from 'vitest';
import {
buildMemberWorkSyncNudgePayload,
buildMemberWorkSyncOutboxEnsureInput,
} from '@features/member-work-sync/core/domain';
import { describe, expect, it } from 'vitest';
import type { MemberWorkSyncStatus } from '@features/member-work-sync/contracts';
function makeStatus(
@ -52,6 +52,8 @@ describe('MemberWorkSyncNudge', () => {
it('tells lead to move escalated clarification to user on the board', () => {
const payload = buildMemberWorkSyncNudgePayload(makeStatus());
expect(payload.text).toContain('mcp__agent-teams__member_work_sync_status');
expect(payload.text).toContain('A status-only tool call is incomplete');
expect(payload.text).toContain(
'update the task board first with task_set_clarification value "user"'
);
@ -90,6 +92,46 @@ describe('MemberWorkSyncNudge', () => {
expect(payload.text).not.toContain('task_set_clarification value "user"');
});
it('marks review-pickup status-only tool calls as incomplete', () => {
const payload = buildMemberWorkSyncNudgePayload(
makeStatus({
memberName: 'bob',
agenda: {
teamName: 'sable-ops',
memberName: 'bob',
generatedAt: '2026-05-13T13:02:44.263Z',
fingerprint: 'agenda:v1:review',
diagnostics: [],
items: [
{
taskId: 'task-review',
displayId: '22222222',
subject: 'Review docs',
assignee: 'bob',
kind: 'review',
priority: 'review_requested',
reason: 'current_cycle_review_assigned',
evidence: {
status: 'completed',
owner: 'alice',
reviewer: 'bob',
reviewState: 'review',
reviewCycleId: 'evt-review-request',
reviewRequestEventId: 'evt-review-request',
reviewObligation: 'review_pickup_required',
canBypassPhase2: true,
},
},
],
},
})
);
expect(payload.workSyncIntent).toBe('review_pickup');
expect(payload.text).toContain('A status-only tool call is incomplete');
expect(payload.text).toContain('mcp__agent-teams__member_work_sync_report');
});
it('adds proof-missing recovery context to agenda sync nudges', () => {
const status = makeStatus({
memberName: 'bob',

View file

@ -1,20 +1,20 @@
import { describe, expect, it } from 'vitest';
import {
type MemberWorkSyncAgendaSourceResult,
type MemberWorkSyncAuditEvent,
MemberWorkSyncDiagnosticsReader,
type MemberWorkSyncInboxNudgePort,
MemberWorkSyncNudgeDispatcher,
type MemberWorkSyncOutboxStorePort,
MemberWorkSyncPendingReportIntentReplayer,
MemberWorkSyncReconciler,
MemberWorkSyncReporter,
type MemberWorkSyncAgendaSourceResult,
type MemberWorkSyncAuditEvent,
type MemberWorkSyncInboxNudgePort,
type MemberWorkSyncOutboxStorePort,
type MemberWorkSyncReviewPickupDeliveryPort,
type MemberWorkSyncReviewPickupEscalationPort,
type MemberWorkSyncStatusStorePort,
type MemberWorkSyncUseCaseDeps,
} from '@features/member-work-sync/core/application';
import { describe, expect, it } from 'vitest';
import type {
MemberWorkSyncActionableWorkItem,
MemberWorkSyncOutboxEnsureInput,
@ -745,6 +745,24 @@ describe('MemberWorkSync use cases', () => {
expect(store.writes).toEqual([]);
});
it('plans a nudge from status refresh once readiness is green', async () => {
const outbox = new InMemoryOutboxStore();
const { deps, store } = createDeps({ outboxStore: outbox });
store.phase2ReadinessState = 'shadow_ready';
const status = await new MemberWorkSyncReconciler(deps).execute({
teamName: 'team-a',
memberName: 'bob',
});
expect(outbox.ensures).toHaveLength(1);
expect(outbox.ensures[0]).toMatchObject({
id: `member-work-sync:team-a:bob:${status.agenda.fingerprint}`,
teamName: 'team-a',
memberName: 'bob',
});
});
it('creates one idempotent outbox nudge intent when Phase 2 readiness is green', async () => {
const outbox = new InMemoryOutboxStore();
const { deps, store } = createDeps({ outboxStore: outbox });
@ -778,6 +796,7 @@ describe('MemberWorkSync use cases', () => {
'member_work_sync_status with teamName "team-a" and memberName "bob"'
);
expect(nudgeText).toContain('member_work_sync_report with the same teamName/memberName');
expect(nudgeText).toContain('mcp__agent-teams__member_work_sync_status');
expect(nudgeText).toContain('taskIds: "task-1"');
expect(nudgeText).toContain(
'Do not use provider names, runtime names, or team names as memberName'
@ -817,6 +836,68 @@ describe('MemberWorkSync use cases', () => {
});
});
it('creates a status-only recovery nudge after a delivered nudge turn settles without a report', async () => {
const outbox = new InMemoryOutboxStore();
const inbox = new InMemoryInboxNudge();
let busyChecks = 0;
const { deps, store } = createDeps({
outboxStore: outbox,
inboxNudge: inbox,
busySignal: {
isBusy: async () => {
busyChecks += 1;
return busyChecks > 1
? { busy: true, reason: 'recent_tool_activity' }
: { busy: false };
},
},
});
store.phase2ReadinessState = 'shadow_ready';
const firstStatus = await new MemberWorkSyncReconciler(deps).execute(
{
teamName: 'team-a',
memberName: 'bob',
},
{ reconciledBy: 'queue', triggerReasons: ['task_changed'] }
);
await new MemberWorkSyncNudgeDispatcher(deps).dispatchDue({
teamNames: ['team-a'],
claimedBy: 'test-dispatcher',
});
await new MemberWorkSyncReconciler(deps).execute(
{
teamName: 'team-a',
memberName: 'bob',
},
{ reconciledBy: 'queue', triggerReasons: ['turn_settled'] }
);
const recovery = [...outbox.items.values()].find((item) =>
item.payload.workSyncIntentKey?.startsWith('status-only:')
);
expect(recovery).toMatchObject({
status: 'pending',
agendaFingerprint: firstStatus.agenda.fingerprint,
payload: {
workSyncIntent: 'agenda_sync',
workSyncIntentKey: `status-only:${firstStatus.agenda.fingerprint}`,
},
});
expect(recovery?.payload.text).toContain('previous work-sync turn appears to have stopped');
const summary = await new MemberWorkSyncNudgeDispatcher(deps).dispatchDue({
teamNames: ['team-a'],
claimedBy: 'test-dispatcher',
});
expect(summary).toMatchObject({ claimed: 1, delivered: 1, superseded: 0 });
expect(busyChecks).toBe(2);
expect(inbox.inserted).toHaveLength(2);
expect(inbox.inserted[1]?.messageId).toContain('status-only');
});
it('marks review pickup delivered only after the delivery port confirms prompt acceptance', async () => {
const outbox = new InMemoryOutboxStore();
const inbox = new InMemoryInboxNudge();

View file

@ -1,6 +1,5 @@
import { describe, expect, it } from 'vitest';
import { decideMemberWorkSyncNudgeActivation } from '@features/member-work-sync/core/application';
import { describe, expect, it } from 'vitest';
import type {
MemberWorkSyncStatus,
@ -152,10 +151,21 @@ describe('MemberWorkSyncNudgeActivationPolicy', () => {
).toEqual({ active: true, reason: 'opencode_targeted_shadow_collecting' });
});
it('keeps non-OpenCode providers behind phase2 readiness while collecting', () => {
it('activates native inbox-watch nudges while shadow data is still collecting', () => {
for (const providerId of ['anthropic', 'codex', 'gemini'] as const) {
expect(
decideMemberWorkSyncNudgeActivation({
status: status({ providerId }),
metrics: metrics(),
})
).toEqual({ active: true, reason: 'native_targeted_shadow_collecting' });
}
});
it('keeps unknown-provider teammates behind phase2 readiness while collecting', () => {
expect(
decideMemberWorkSyncNudgeActivation({
status: status({ providerId: 'anthropic' }),
status: status({ providerId: undefined }),
metrics: metrics(),
})
).toEqual({ active: false, reason: 'phase2_not_ready' });

View file

@ -1,6 +1,5 @@
import { describe, expect, it } from 'vitest';
import { decideMemberWorkSyncTargetedRecovery } from '@features/member-work-sync/core/application';
import { describe, expect, it } from 'vitest';
import type { MemberWorkSyncStatus } from '@features/member-work-sync/contracts';
@ -61,8 +60,16 @@ describe('MemberWorkSyncTargetedRecoveryPolicy', () => {
});
});
it('does not allow non-lead native teammates through targeted recovery', () => {
it('allows non-lead native teammates through inbox-watch targeted recovery', () => {
expect(decideMemberWorkSyncTargetedRecovery(status({ providerId: 'codex' }))).toEqual({
active: true,
capability: 'native_inbox_watch',
reason: 'native_targeted_shadow_collecting',
});
});
it('does not allow unknown-provider teammates through targeted recovery', () => {
expect(decideMemberWorkSyncTargetedRecovery(status({ providerId: undefined }))).toEqual({
active: false,
});
});

View file

@ -1,18 +1,17 @@
import fs from 'fs';
import os from 'os';
import path from 'path';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { buildMemberWorkSyncOutboxEnsureInput } from '@features/member-work-sync/core/domain';
import {
buildMemberWorkSyncRuntimeTurnSettledEnvironment,
createMemberWorkSyncFeature,
} from '@features/member-work-sync/main';
import { buildMemberWorkSyncOutboxEnsureInput } from '@features/member-work-sync/core/domain';
import { JsonMemberWorkSyncStore } from '@features/member-work-sync/main/infrastructure/JsonMemberWorkSyncStore';
import { MemberWorkSyncStorePaths } from '@features/member-work-sync/main/infrastructure/MemberWorkSyncStorePaths';
import { NodeHashAdapter } from '@features/member-work-sync/main/infrastructure/NodeHashAdapter';
import { RUNTIME_TURN_SETTLED_SPOOL_ROOT_ENV } from '@features/member-work-sync/main/infrastructure/runtimeTurnSettledEnvironment';
import { getTeamsBasePath, setClaudeBasePathOverride } from '@main/utils/pathDecoder';
import fs from 'fs';
import os from 'os';
import path from 'path';
import { afterEach, describe, expect, it, vi } from 'vitest';
const tempRoots: string[] = [];
@ -587,7 +586,7 @@ describe('createMemberWorkSyncFeature composition', () => {
const store = new JsonMemberWorkSyncStore(new MemberWorkSyncStorePaths(teamsBasePath));
await expect(store.ensurePending(outboxInput!)).resolves.toMatchObject({
ok: true,
outcome: 'created',
outcome: 'existing',
});
await expect(feature.dispatchDueNudges([teamName])).resolves.toEqual({
@ -682,8 +681,8 @@ describe('createMemberWorkSyncFeature composition', () => {
});
await expect(feature.dispatchDueNudges([teamName])).resolves.toEqual({
claimed: 1,
delivered: 0,
claimed: 2,
delivered: 1,
superseded: 1,
retryable: 0,
terminal: 0,
@ -697,9 +696,12 @@ describe('createMemberWorkSyncFeature composition', () => {
taskIds: ['task-1'],
})
);
await expect(readInboxMessages({ teamsBasePath, teamName, memberName })).resolves.toEqual(
[]
const nudges = (await readInboxMessages({ teamsBasePath, teamName, memberName })).filter(
(message) => message.messageKind === 'member_work_sync_nudge'
);
expect(nudges).toHaveLength(1);
expect(nudges[0]?.text).toContain('Required sync action');
expect(nudges[0]?.text).not.toContain('Recover proof');
} finally {
await feature.dispose();
}
@ -756,7 +758,7 @@ describe('createMemberWorkSyncFeature composition', () => {
const store = new JsonMemberWorkSyncStore(new MemberWorkSyncStorePaths(teamsBasePath));
await expect(store.ensurePending(outboxInput!)).resolves.toMatchObject({
ok: true,
outcome: 'created',
outcome: 'existing',
});
await expect(feature.dispatchDueNudges([teamName])).resolves.toEqual({
@ -886,6 +888,7 @@ describe('createMemberWorkSyncFeature composition', () => {
} as never,
isTeamActive: vi.fn(async () => true),
queueQuietWindowMs: 1,
resolveControlUrl: vi.fn(async () => 'http://127.0.0.1:43123'),
});
try {
@ -914,9 +917,8 @@ describe('createMemberWorkSyncFeature composition', () => {
'utf8'
);
await expect(feature.drainRuntimeTurnSettledEvents()).resolves.toMatchObject({
claimed: 1,
enqueued: 1,
const drain = await feature.drainRuntimeTurnSettledEvents();
expect(drain).toMatchObject({
invalid: 0,
unresolved: 0,
});
@ -955,6 +957,150 @@ describe('createMemberWorkSyncFeature composition', () => {
}
});
it('delivers a status-only recovery nudge when a delivered Codex nudge settles without report proof', async () => {
const claudeRoot = makeTempRoot();
setClaudeBasePathOverride(claudeRoot);
const teamsBasePath = getTeamsBasePath();
const teamName = 'team-codex-status-only-recovery';
const memberName = 'bob';
const feature = createMemberWorkSyncFeature({
teamsBasePath,
configReader: {
getConfig: vi.fn(async () => ({
name: teamName,
members: [{ name: memberName, providerId: 'codex' }],
})),
} as never,
taskReader: {
getTasks: vi.fn(async () => [
{
id: 'task-1',
displayId: '11111111',
subject: 'Ship sync after status-only turn',
status: 'pending',
owner: memberName,
},
]),
} as never,
kanbanManager: {
getState: vi.fn(async () => ({
teamName,
reviewers: [],
tasks: {},
})),
} as never,
membersMetaStore: {
getMembers: vi.fn(async () => []),
} as never,
isTeamActive: vi.fn(async () => true),
queueQuietWindowMs: 1,
resolveControlUrl: vi.fn(async () => 'http://127.0.0.1:43123'),
});
try {
await seedShadowReadyMetrics({ teamsBasePath, teamName, memberName });
feature.noteTeamChange({ type: 'task', teamName, taskId: 'task-1' } as never);
await waitForAssertion(async () => {
const nudges = (await readInboxMessages({ teamsBasePath, teamName, memberName })).filter(
(message) => message.messageKind === 'member_work_sync_nudge'
);
expect(nudges).toHaveLength(1);
expect(nudges[0]?.text).toContain('11111111');
expect(nudges[0]?.text).toContain('controlUrl "http://127.0.0.1:43123"');
const outboxItems = Object.values(
await readMemberOutboxItems({ teamsBasePath, teamName, memberName })
);
expect(outboxItems).toEqual([
expect.objectContaining({
status: 'delivered',
}),
]);
const deliveredOutboxItem = outboxItems[0] as {
payload?: { workSyncIntentKey?: string };
};
expect(deliveredOutboxItem.payload?.workSyncIntentKey).toBeUndefined();
});
feature.noteTeamChange({
type: 'tool-activity',
teamName,
detail: JSON.stringify({
action: 'start',
activity: {
memberName,
toolUseId: 'status-tool-1',
toolName: 'member_work_sync_status',
startedAt: '2026-05-05T12:00:00.000Z',
source: 'runtime',
},
}),
} as never);
feature.noteTeamChange({
type: 'tool-activity',
teamName,
detail: JSON.stringify({
action: 'finish',
memberName,
toolUseId: 'status-tool-1',
finishedAt: new Date().toISOString(),
}),
} as never);
const env = await feature.buildRuntimeTurnSettledEnvironment({ provider: 'codex' });
const spoolRoot = env?.[RUNTIME_TURN_SETTLED_SPOOL_ROOT_ENV];
expect(spoolRoot).toBeTruthy();
const eventFileName = '20260505T120001000Z-status-only.codex.json';
await fs.promises.writeFile(
path.join(spoolRoot!, 'incoming', eventFileName),
`${JSON.stringify({
schemaVersion: 1,
provider: 'codex',
source: 'agent-teams-orchestrator-codex-native',
eventName: 'runtime_turn_settled',
hookEventName: 'Stop',
sessionId: 'ses-codex-1',
memberName,
teamName,
cwd: claudeRoot,
outcome: 'success',
recordedAt: '2026-05-05T12:00:01.000Z',
})}\n`,
'utf8'
);
await expect(feature.drainRuntimeTurnSettledEvents()).resolves.toMatchObject({
claimed: 1,
enqueued: 1,
invalid: 0,
unresolved: 0,
});
await waitForAssertion(async () => {
const nudges = (await readInboxMessages({ teamsBasePath, teamName, memberName })).filter(
(message) => message.messageKind === 'member_work_sync_nudge'
);
expect(nudges).toHaveLength(2);
expect(nudges[1]?.messageId).toContain('status-only');
expect(nudges[1]?.text).toContain('previous work-sync turn appears to have stopped');
expect(
Object.values(await readMemberOutboxItems({ teamsBasePath, teamName, memberName }))
).toEqual(
expect.arrayContaining([
expect.objectContaining({
status: 'delivered',
payload: expect.objectContaining({
workSyncIntentKey: expect.stringContaining('status-only:'),
}),
}),
])
);
});
} finally {
await feature.dispose();
}
});
it('delivers targeted OpenCode nudges during shadow collection and schedules a delivery wake', async () => {
const claudeRoot = makeTempRoot();
setClaudeBasePathOverride(claudeRoot);
@ -1054,12 +1200,15 @@ describe('createMemberWorkSyncFeature composition', () => {
}
});
it('does not apply the OpenCode shadow-collection exception to Codex members', async () => {
it('delivers Codex inbox-watch nudges while shadow data is still collecting', async () => {
const claudeRoot = makeTempRoot();
setClaudeBasePathOverride(claudeRoot);
const teamsBasePath = getTeamsBasePath();
const teamName = 'team-codex-shadow-gated';
const memberName = 'bob';
const nudgeDeliveryWake = {
schedule: vi.fn(async () => undefined),
};
const feature = createMemberWorkSyncFeature({
teamsBasePath,
configReader: {
@ -1090,6 +1239,7 @@ describe('createMemberWorkSyncFeature composition', () => {
getMembers: vi.fn(async () => []),
} as never,
isTeamActive: vi.fn(async () => true),
nudgeDeliveryWake,
queueQuietWindowMs: 1,
});
@ -1099,8 +1249,28 @@ describe('createMemberWorkSyncFeature composition', () => {
await waitForAssertion(async () => {
expect(feature.getQueueDiagnostics()).toMatchObject({ reconciled: 1 });
expect(await readInboxMessages({ teamsBasePath, teamName, memberName })).toEqual([]);
expect(await readMemberOutboxItems({ teamsBasePath, teamName, memberName })).toEqual({});
const nudges = (await readInboxMessages({ teamsBasePath, teamName, memberName })).filter(
(message) => message.messageKind === 'member_work_sync_nudge'
);
expect(nudges).toHaveLength(1);
expect(nudges[0]?.text).toContain('11111111');
expect(nudges[0]?.text).toContain('mcp__agent-teams__member_work_sync_report');
expect(nudgeDeliveryWake.schedule).toHaveBeenCalledWith({
teamName,
memberName,
messageId: nudges[0]?.messageId,
providerId: 'codex',
reason: 'member_work_sync_nudge_inserted',
delayMs: 500,
});
expect(
Object.values(await readMemberOutboxItems({ teamsBasePath, teamName, memberName }))
).toEqual([
expect.objectContaining({
status: 'delivered',
deliveredMessageId: nudges[0]?.messageId,
}),
]);
await expect(feature.getMetrics({ teamName })).resolves.toMatchObject({
phase2Readiness: { state: 'collecting_shadow_data' },
});
@ -1122,9 +1292,8 @@ describe('createMemberWorkSyncFeature composition', () => {
),
'utf8'
);
expect(journal).toContain('"event":"nudge_skipped"');
expect(journal).toContain('"reason":"phase2_not_ready"');
expect(journal).not.toContain('"event":"nudge_delivered"');
expect(journal).toContain('"event":"nudge_delivered"');
expect(journal).not.toContain('"reason":"phase2_not_ready"');
} finally {
await feature.dispose();
}
@ -2008,7 +2177,7 @@ describe('createMemberWorkSyncFeature composition', () => {
const store = new JsonMemberWorkSyncStore(new MemberWorkSyncStorePaths(teamsBasePath));
await expect(store.ensurePending(outboxInput!)).resolves.toMatchObject({
ok: true,
outcome: 'created',
outcome: 'existing',
});
const staleOutboxId = `member-work-sync:${teamName}:${memberName}:${staleStatus.agenda.fingerprint}`;
await expect(
@ -2160,7 +2329,7 @@ describe('createMemberWorkSyncFeature composition', () => {
const store = new JsonMemberWorkSyncStore(new MemberWorkSyncStorePaths(teamsBasePath));
await expect(store.ensurePending(outboxInput!)).resolves.toMatchObject({
ok: true,
outcome: 'created',
outcome: 'existing',
});
teamActive = false;

View file

@ -1,22 +1,23 @@
import * as os from 'os';
import { setClaudeBasePathOverride } from '@main/utils/pathDecoder';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { setClaudeBasePathOverride } from '@main/utils/pathDecoder';
import type {
BoardTaskActivityDetailResult,
BoardTaskActivityEntry,
BoardTaskLogStreamResponse,
BoardTaskExactLogDetailResult,
BoardTaskExactLogSummariesResponse,
BoardTaskLogStreamResponse,
InboxMessage,
MessagesPage,
SendMessageResult,
TeamViewSnapshot,
TeamCreateRequest,
TeamLaunchRequest,
TeamProviderId,
TeamProvisioningProgress,
TeamViewSnapshot,
} from '@shared/types/team';
vi.mock('electron', () => ({
@ -94,66 +95,6 @@ vi.mock('@main/services/team/TeamDataWorkerClient', () => ({
getTeamDataWorkerClient: () => mockTeamDataWorkerClient,
}));
import {
TEAM_ALIVE_LIST,
TEAM_STOP,
TEAM_CANCEL_PROVISIONING,
TEAM_CREATE,
TEAM_CREATE_CONFIG,
TEAM_CREATE_TASK,
TEAM_DELETE_TEAM,
TEAM_GET_DATA,
TEAM_GET_MEMBER_ACTIVITY_META,
TEAM_GET_MESSAGES_PAGE,
TEAM_LAUNCH,
TEAM_LIST,
TEAM_PREPARE_PROVISIONING,
TEAM_PROCESS_ALIVE,
TEAM_PROCESS_SEND,
TEAM_PROVISIONING_STATUS,
TEAM_REQUEST_REVIEW,
TEAM_SEND_MESSAGE,
TEAM_SET_CHANGE_PRESENCE_TRACKING,
TEAM_GET_ALL_TASKS,
TEAM_GET_LOGS_FOR_TASK,
TEAM_GET_TASK_ACTIVITY,
TEAM_GET_TASK_ACTIVITY_DETAIL,
TEAM_GET_TASK_LOG_STREAM,
TEAM_GET_TASK_EXACT_LOG_DETAIL,
TEAM_GET_TASK_EXACT_LOG_SUMMARIES,
TEAM_GET_MEMBER_LOGS,
TEAM_GET_MEMBER_STATS,
TEAM_START_TASK,
TEAM_UPDATE_CONFIG,
TEAM_UPDATE_KANBAN,
TEAM_UPDATE_KANBAN_COLUMN_ORDER,
TEAM_UPDATE_TASK_STATUS,
TEAM_ADD_MEMBER,
TEAM_ADD_TASK_COMMENT,
TEAM_GET_ATTACHMENTS,
TEAM_GET_DELETED_TASKS,
TEAM_GET_TASK_CHANGE_PRESENCE,
TEAM_GET_PROJECT_BRANCH,
TEAM_KILL_PROCESS,
TEAM_LEAD_ACTIVITY,
TEAM_PERMANENTLY_DELETE,
TEAM_REMOVE_MEMBER,
TEAM_RESTORE,
TEAM_SET_TASK_CLARIFICATION,
TEAM_SOFT_DELETE_TASK,
TEAM_UPDATE_MEMBER_ROLE,
TEAM_ADD_TASK_RELATIONSHIP,
TEAM_REMOVE_TASK_RELATIONSHIP,
TEAM_REPLACE_MEMBERS,
TEAM_UPDATE_TASK_OWNER,
TEAM_UPDATE_TASK_FIELDS,
TEAM_LEAD_CONTEXT,
TEAM_RESTORE_TASK,
TEAM_SHOW_MESSAGE_NOTIFICATION,
TEAM_SAVE_TASK_ATTACHMENT,
TEAM_GET_TASK_ATTACHMENT,
TEAM_DELETE_TASK_ATTACHMENT,
} from '../../../src/preload/constants/ipcChannels';
import {
initializeTeamHandlers,
registerTeamHandlers,
@ -162,6 +103,67 @@ import {
import { ConfigManager } from '../../../src/main/services/infrastructure/ConfigManager';
import { LaunchIoGovernor } from '../../../src/main/services/team/LaunchIoGovernor';
import { getAppDataPath } from '../../../src/main/utils/pathDecoder';
import {
TEAM_ADD_MEMBER,
TEAM_ADD_TASK_COMMENT,
TEAM_ADD_TASK_RELATIONSHIP,
TEAM_ALIVE_LIST,
TEAM_CANCEL_PROVISIONING,
TEAM_CREATE,
TEAM_CREATE_CONFIG,
TEAM_CREATE_TASK,
TEAM_DELETE_TASK_ATTACHMENT,
TEAM_DELETE_TEAM,
TEAM_GET_ALL_TASKS,
TEAM_GET_ATTACHMENTS,
TEAM_GET_DATA,
TEAM_GET_DELETED_TASKS,
TEAM_GET_LOGS_FOR_TASK,
TEAM_GET_MEMBER_ACTIVITY_META,
TEAM_GET_MEMBER_LOGS,
TEAM_GET_MEMBER_STATS,
TEAM_GET_MESSAGES_PAGE,
TEAM_GET_PROJECT_BRANCH,
TEAM_GET_TASK_ACTIVITY,
TEAM_GET_TASK_ACTIVITY_DETAIL,
TEAM_GET_TASK_ATTACHMENT,
TEAM_GET_TASK_CHANGE_PRESENCE,
TEAM_GET_TASK_EXACT_LOG_DETAIL,
TEAM_GET_TASK_EXACT_LOG_SUMMARIES,
TEAM_GET_TASK_LOG_STREAM,
TEAM_KILL_PROCESS,
TEAM_LAUNCH,
TEAM_LEAD_ACTIVITY,
TEAM_LEAD_CONTEXT,
TEAM_LIST,
TEAM_PERMANENTLY_DELETE,
TEAM_PREPARE_PROVISIONING,
TEAM_PROCESS_ALIVE,
TEAM_PROCESS_SEND,
TEAM_PROVISIONING_STATUS,
TEAM_REMOVE_MEMBER,
TEAM_REMOVE_TASK_RELATIONSHIP,
TEAM_REPLACE_MEMBERS,
TEAM_REQUEST_REVIEW,
TEAM_RESTORE,
TEAM_RESTORE_MEMBER,
TEAM_RESTORE_TASK,
TEAM_SAVE_TASK_ATTACHMENT,
TEAM_SEND_MESSAGE,
TEAM_SET_CHANGE_PRESENCE_TRACKING,
TEAM_SET_TASK_CLARIFICATION,
TEAM_SHOW_MESSAGE_NOTIFICATION,
TEAM_SOFT_DELETE_TASK,
TEAM_START_TASK,
TEAM_STOP,
TEAM_UPDATE_CONFIG,
TEAM_UPDATE_KANBAN,
TEAM_UPDATE_KANBAN_COLUMN_ORDER,
TEAM_UPDATE_MEMBER_ROLE,
TEAM_UPDATE_TASK_FIELDS,
TEAM_UPDATE_TASK_OWNER,
TEAM_UPDATE_TASK_STATUS,
} from '../../../src/preload/constants/ipcChannels';
describe('ipc teams handlers', () => {
const handlers = new Map<string, (...args: unknown[]) => Promise<unknown>>();
@ -246,6 +248,11 @@ describe('ipc teams handlers', () => {
})),
addMember: vi.fn(async () => undefined),
removeMember: vi.fn(async () => undefined),
restoreMember: vi.fn(async () => ({
name: 'alice',
role: 'Developer',
providerId: 'codex' as TeamProviderId,
})),
updateMemberRole: vi.fn(async () => ({ oldRole: undefined, changed: true })),
softDeleteTask: vi.fn(async () => undefined),
getDeletedTasks: vi.fn(async () => []),
@ -279,6 +286,8 @@ describe('ipc teams handlers', () => {
cancelProvisioning: vi.fn(async () => undefined),
launchTeam: vi.fn(async () => ({ runId: 'run-2' })),
sendMessageToTeam: vi.fn(async () => undefined),
prepareLiveMemberMcpLaunchConfig: vi.fn(async () => null),
discardLiveMemberMcpLaunchConfig: vi.fn(async () => undefined),
isTeamAlive: vi.fn(() => true),
getCurrentRunId: vi.fn(() => 'run-2' as string | null),
pushLiveLeadProcessMessage: vi.fn(),
@ -349,11 +358,26 @@ describe('ipc teams handlers', () => {
handlers.clear();
vi.clearAllMocks();
service.listTeams.mockReset();
service.getTeamData.mockReset();
service.getAllTasks.mockReset();
service.restoreMember.mockReset();
service.listTeams.mockResolvedValue([{ teamName: 'my-team', displayName: 'My Team' }]);
service.getTeamData.mockResolvedValue({
teamName: 'my-team',
config: { name: 'My Team' },
tasks: [],
members: [],
kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} },
processes: [],
});
service.getAllTasks.mockResolvedValue([
{ id: 'task-1', teamName: 'my-team', subject: 'Task 1' },
]);
service.restoreMember.mockResolvedValue({
name: 'alice',
role: 'Developer',
providerId: 'codex' as TeamProviderId,
});
mockGetMembersMeta.mockReset();
mockGetMembersMeta.mockResolvedValue([]);
mockGetMembersMetaFile.mockReset();
@ -372,8 +396,14 @@ describe('ipc teams handlers', () => {
mockTeamDataWorkerClient.invalidateTeamConfig.mockReset();
mockTeamDataWorkerClient.invalidateTeamMessageFeed.mockReset();
mockTeamDataWorkerClient.invalidateMemberRuntimeAdvisory.mockReset();
provisioningService.sendMessageToTeam.mockReset();
provisioningService.sendMessageToTeam.mockResolvedValue(undefined);
provisioningService.resolveRuntimeRecipientProviderId.mockReset();
provisioningService.resolveRuntimeRecipientProviderId.mockResolvedValue(undefined);
provisioningService.prepareLiveMemberMcpLaunchConfig.mockReset();
provisioningService.prepareLiveMemberMcpLaunchConfig.mockResolvedValue(null);
provisioningService.discardLiveMemberMcpLaunchConfig.mockReset();
provisioningService.discardLiveMemberMcpLaunchConfig.mockResolvedValue(undefined);
provisioningService.repairStaleTaskActivityIntervalsBeforeSnapshot.mockReset();
provisioningService.repairStaleTaskActivityIntervalsBeforeSnapshot.mockResolvedValue(undefined);
launchIoGovernor = new LaunchIoGovernor({ quietWindowMs: 100 });
@ -439,6 +469,7 @@ describe('ipc teams handlers', () => {
expect(handlers.has(TEAM_ADD_TASK_COMMENT)).toBe(true);
expect(handlers.has(TEAM_ADD_MEMBER)).toBe(true);
expect(handlers.has(TEAM_REMOVE_MEMBER)).toBe(true);
expect(handlers.has(TEAM_RESTORE_MEMBER)).toBe(true);
expect(handlers.has(TEAM_UPDATE_MEMBER_ROLE)).toBe(true);
expect(handlers.has(TEAM_KILL_PROCESS)).toBe(true);
expect(handlers.has(TEAM_LEAD_ACTIVITY)).toBe(true);
@ -2552,10 +2583,13 @@ describe('ipc teams handlers', () => {
role: 'developer',
})) as { success: boolean };
expect(result.success).toBe(true);
expect(service.addMember).toHaveBeenCalledWith('my-team', {
name: 'alice',
role: 'developer',
});
expect(service.addMember).toHaveBeenCalledWith(
'my-team',
expect.objectContaining({
name: 'alice',
role: 'developer',
})
);
});
it('notifies a live lead to use member_briefing bootstrap for the new teammate', async () => {
@ -2591,6 +2625,79 @@ describe('ipc teams handlers', () => {
);
});
it('passes Agent Teams MCP only launch overrides into live add-member Agent prompt', async () => {
const projectPath = path.join(os.tmpdir(), 'codex live add project with spaces');
service.getTeamData.mockResolvedValueOnce({
teamName: 'my-team',
config: { name: 'My Team', projectPath },
tasks: [],
members: [
{
name: 'team-lead',
providerId: 'codex',
role: 'Team Lead',
currentTaskId: null,
taskCount: 0,
},
],
kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} },
processes: [],
});
provisioningService.prepareLiveMemberMcpLaunchConfig.mockResolvedValueOnce({
mcpConfigPath: '/tmp/codex live add/alice-app-only.json',
mcpSettingSources: 'user,project,local',
strictMcpConfig: true,
} as never);
const handler = handlers.get(TEAM_ADD_MEMBER)!;
const result = (await handler({} as never, 'my-team', {
name: 'alice',
role: 'developer',
providerId: 'codex',
mcpPolicy: { mode: 'appOnly' },
})) as { success: boolean };
expect(result.success).toBe(true);
expect(provisioningService.prepareLiveMemberMcpLaunchConfig).toHaveBeenCalledWith({
teamName: 'my-team',
cwd: projectPath,
mcpPolicy: { mode: 'appOnly' },
});
expect(provisioningService.sendMessageToTeam).toHaveBeenCalledWith(
'my-team',
expect.stringContaining(
'mcp_config="/tmp/codex live add/alice-app-only.json", mcp_setting_sources="user,project,local", strict_mcp_config=true'
)
);
});
it('discards live add-member MCP config if lead notification fails after config creation', async () => {
const mcpLaunchConfig = {
mcpConfigPath: '/tmp/codex live add/alice-orphan-risk.json',
mcpSettingSources: 'user,project,local',
strictMcpConfig: true,
};
provisioningService.prepareLiveMemberMcpLaunchConfig.mockResolvedValueOnce(
mcpLaunchConfig as never
);
provisioningService.sendMessageToTeam.mockRejectedValueOnce(new Error('lead offline'));
const handler = handlers.get(TEAM_ADD_MEMBER)!;
const result = (await handler({} as never, 'my-team', {
name: 'alice',
role: 'developer',
providerId: 'codex',
mcpPolicy: { mode: 'appOnly' },
})) as { success: boolean };
expect(result.success).toBe(true);
expect(provisioningService.discardLiveMemberMcpLaunchConfig).toHaveBeenCalledWith({
teamName: 'my-team',
mcpLaunchConfig,
});
vi.mocked(console.warn).mockClear();
});
it('rejects invalid team name', async () => {
const handler = handlers.get(TEAM_ADD_MEMBER)!;
const result = (await handler({} as never, '../bad', {
@ -2712,6 +2819,7 @@ describe('ipc teams handlers', () => {
providerId: 'opencode',
model: 'minimax-m2.5-free',
effort: undefined,
mcpPolicy: undefined,
});
expect(service.replaceMembers).not.toHaveBeenCalled();
expect(mockWriteMembersMeta).toHaveBeenCalledWith(
@ -2814,6 +2922,7 @@ describe('ipc teams handlers', () => {
it('invalidates worker config cache after roster metadata mutations', async () => {
const addHandler = handlers.get(TEAM_ADD_MEMBER)!;
const removeHandler = handlers.get(TEAM_REMOVE_MEMBER)!;
const restoreMemberHandler = handlers.get(TEAM_RESTORE_MEMBER)!;
const replaceHandler = handlers.get(TEAM_REPLACE_MEMBERS)!;
const updateRoleHandler = handlers.get(TEAM_UPDATE_MEMBER_ROLE)!;
@ -2822,10 +2931,13 @@ describe('ipc teams handlers', () => {
role: 'developer',
})) as { success: boolean };
expect(result.success).toBe(true);
expect(service.addMember).toHaveBeenCalledWith('my-team', {
name: 'alice',
role: 'developer',
});
expect(service.addMember).toHaveBeenCalledWith(
'my-team',
expect.objectContaining({
name: 'alice',
role: 'developer',
})
);
expect(mockTeamDataWorkerClient.invalidateTeamConfig).toHaveBeenCalledWith('my-team');
expect(mockTeamDataWorkerClient.invalidateMemberRuntimeAdvisory).toHaveBeenCalledWith(
'my-team'
@ -2845,6 +2957,19 @@ describe('ipc teams handlers', () => {
mockTeamDataWorkerClient.invalidateTeamConfig.mockClear();
mockTeamDataWorkerClient.invalidateMemberRuntimeAdvisory.mockClear();
result = (await restoreMemberHandler({} as never, 'my-team', 'alice')) as {
success: boolean;
};
expect(result.success).toBe(true);
expect(service.restoreMember).toHaveBeenCalledWith('my-team', 'alice');
expect(mockTeamDataWorkerClient.invalidateTeamConfig).toHaveBeenCalledWith('my-team');
expect(mockTeamDataWorkerClient.invalidateMemberRuntimeAdvisory).toHaveBeenCalledWith(
'my-team'
);
mockTeamDataWorkerClient.invalidateTeamConfig.mockClear();
mockTeamDataWorkerClient.invalidateMemberRuntimeAdvisory.mockClear();
result = (await replaceHandler({} as never, 'my-team', {
members: [{ name: 'bob', role: 'developer' }],
})) as { success: boolean };
@ -3030,7 +3155,260 @@ describe('ipc teams handlers', () => {
});
});
describe('restoreMember', () => {
it('calls service on valid input', async () => {
const handler = handlers.get(TEAM_RESTORE_MEMBER)!;
const result = (await handler({} as never, 'my-team', 'alice')) as { success: boolean };
expect(result.success).toBe(true);
expect(service.restoreMember).toHaveBeenCalledWith('my-team', 'alice');
});
it('rejects invalid team name', async () => {
const handler = handlers.get(TEAM_RESTORE_MEMBER)!;
const result = (await handler({} as never, '../bad', 'alice')) as { success: boolean };
expect(result.success).toBe(false);
});
it('rejects invalid member name', async () => {
const handler = handlers.get(TEAM_RESTORE_MEMBER)!;
const result = (await handler({} as never, 'my-team', '../bad')) as { success: boolean };
expect(result.success).toBe(false);
});
it('passes Agent Teams MCP only launch overrides into live restore-member Agent prompt', async () => {
const projectPath = path.join(os.tmpdir(), 'codex live restore project with spaces');
service.getTeamData.mockResolvedValueOnce({
teamName: 'my-team',
config: { name: 'My Team', projectPath },
tasks: [],
members: [
{
name: 'team-lead',
providerId: 'codex',
role: 'Team Lead',
currentTaskId: null,
taskCount: 0,
},
{
name: 'alice',
providerId: 'codex',
role: 'Developer',
removedAt: Date.now(),
currentTaskId: null,
taskCount: 0,
},
],
kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} },
processes: [],
});
service.restoreMember.mockResolvedValueOnce({
name: 'alice',
role: 'Developer',
providerId: 'codex',
mcpPolicy: { mode: 'appOnly' },
} as never);
provisioningService.prepareLiveMemberMcpLaunchConfig.mockResolvedValueOnce({
mcpConfigPath: '/tmp/codex live restore/alice-app-only.json',
mcpSettingSources: 'user,project,local',
strictMcpConfig: true,
} as never);
const handler = handlers.get(TEAM_RESTORE_MEMBER)!;
const result = (await handler({} as never, 'my-team', 'alice')) as { success: boolean };
expect(result.success).toBe(true);
expect(provisioningService.prepareLiveMemberMcpLaunchConfig).toHaveBeenCalledWith({
teamName: 'my-team',
cwd: projectPath,
mcpPolicy: { mode: 'appOnly' },
});
expect(provisioningService.sendMessageToTeam).toHaveBeenCalledWith(
'my-team',
expect.stringContaining(
'mcp_config="/tmp/codex live restore/alice-app-only.json", mcp_setting_sources="user,project,local", strict_mcp_config=true'
)
);
});
it('reattaches a restored OpenCode teammate on a live mixed team', async () => {
const handler = handlers.get(TEAM_RESTORE_MEMBER)!;
service.restoreMember.mockResolvedValueOnce({
name: 'alice',
providerId: 'opencode',
role: 'Developer',
});
service.getTeamData.mockResolvedValueOnce({
teamName: 'my-team',
config: { name: 'My Team' },
tasks: [],
members: [
{
name: 'team-lead',
providerId: 'codex',
role: 'Team Lead',
currentTaskId: null,
taskCount: 0,
},
{
name: 'alice',
providerId: 'opencode',
role: 'Developer',
removedAt: Date.now(),
currentTaskId: null,
taskCount: 0,
},
],
kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} },
processes: [],
});
const result = (await handler({} as never, 'my-team', 'alice')) as { success: boolean };
expect(result.success).toBe(true);
expect(provisioningService.reattachOpenCodeOwnedMemberLane).toHaveBeenCalledWith(
'my-team',
'alice',
{ reason: 'member_added' }
);
expect(provisioningService.sendMessageToTeam).not.toHaveBeenCalled();
});
it('blocks live restoreMember for a running OpenCode-led team before metadata is changed', async () => {
const handler = handlers.get(TEAM_RESTORE_MEMBER)!;
service.getTeamData.mockResolvedValueOnce({
teamName: 'my-team',
config: { name: 'My Team' },
tasks: [],
members: [
{
name: 'team-lead',
providerId: 'opencode',
role: 'Team Lead',
currentTaskId: null,
taskCount: 0,
},
{
name: 'alice',
providerId: 'opencode',
role: 'Developer',
removedAt: Date.now(),
currentTaskId: null,
taskCount: 0,
},
],
kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} },
processes: [],
});
const result = (await handler({} as never, 'my-team', 'alice')) as {
success: boolean;
error?: string;
};
expect(result.success).toBe(false);
expect(result.error).toContain('running OpenCode-led team');
expect(service.restoreMember).not.toHaveBeenCalled();
expect(provisioningService.reattachOpenCodeOwnedMemberLane).not.toHaveBeenCalled();
vi.mocked(console.error).mockClear();
});
});
describe('replaceMembers', () => {
it('passes Agent Teams MCP only launch overrides into live replace-members added teammate prompt', async () => {
const projectPath = path.join(os.tmpdir(), 'codex live replace project with spaces');
service.getTeamData.mockResolvedValueOnce({
teamName: 'my-team',
config: { name: 'My Team', projectPath },
tasks: [],
members: [
{
name: 'team-lead',
providerId: 'codex',
role: 'Team Lead',
currentTaskId: null,
taskCount: 0,
},
],
kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} },
processes: [],
});
provisioningService.prepareLiveMemberMcpLaunchConfig.mockResolvedValueOnce({
mcpConfigPath: '/tmp/codex live replace/alice-app-only.json',
mcpSettingSources: 'user,project,local',
strictMcpConfig: true,
} as never);
const handler = handlers.get(TEAM_REPLACE_MEMBERS)!;
const result = (await handler({} as never, 'my-team', {
members: [
{
name: 'alice',
role: 'Developer',
providerId: 'codex',
mcpPolicy: { mode: 'appOnly' },
},
],
})) as { success: boolean };
expect(result.success).toBe(true);
expect(provisioningService.prepareLiveMemberMcpLaunchConfig).toHaveBeenCalledWith({
teamName: 'my-team',
cwd: projectPath,
mcpPolicy: { mode: 'appOnly' },
});
expect(provisioningService.sendMessageToTeam).toHaveBeenCalledWith(
'my-team',
expect.stringContaining(
'mcp_config="/tmp/codex live replace/alice-app-only.json", mcp_setting_sources="user,project,local", strict_mcp_config=true'
)
);
});
it('reports existing teammate MCP policy changes in live replace-members summary', async () => {
service.getTeamData.mockResolvedValueOnce({
teamName: 'my-team',
config: { name: 'My Team' },
tasks: [],
members: [
{
name: 'team-lead',
providerId: 'codex',
role: 'Team Lead',
currentTaskId: null,
taskCount: 0,
},
{
name: 'alice',
providerId: 'codex',
role: 'Developer',
currentTaskId: null,
taskCount: 0,
},
],
kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} },
processes: [],
});
const handler = handlers.get(TEAM_REPLACE_MEMBERS)!;
const result = (await handler({} as never, 'my-team', {
members: [
{
name: 'alice',
role: 'Developer',
providerId: 'codex',
mcpPolicy: { mode: 'appOnly' },
},
],
})) as { success: boolean };
expect(result.success).toBe(true);
expect(provisioningService.prepareLiveMemberMcpLaunchConfig).not.toHaveBeenCalled();
expect(provisioningService.sendMessageToTeam).toHaveBeenCalledWith(
'my-team',
expect.stringContaining('MCP access policy changed - restart required')
);
});
it('blocks live replaceMembers for a running OpenCode-led team before metadata is changed', async () => {
const handler = handlers.get(TEAM_REPLACE_MEMBERS)!;
service.getTeamData.mockResolvedValueOnce({
@ -3416,6 +3794,7 @@ describe('ipc teams handlers', () => {
expect(handlers.has(TEAM_ADD_TASK_COMMENT)).toBe(false);
expect(handlers.has(TEAM_ADD_MEMBER)).toBe(false);
expect(handlers.has(TEAM_REMOVE_MEMBER)).toBe(false);
expect(handlers.has(TEAM_RESTORE_MEMBER)).toBe(false);
expect(handlers.has(TEAM_UPDATE_MEMBER_ROLE)).toBe(false);
expect(handlers.has(TEAM_GET_PROJECT_BRANCH)).toBe(false);
expect(handlers.has(TEAM_GET_ATTACHMENTS)).toBe(false);

View file

@ -92,6 +92,33 @@ liveDescribe('Member work sync Codex live e2e', () => {
let controlServer: MemberWorkSyncLiveControlServer | null;
let teamName: string | null;
const createLiveNudgeDeliveryWake = (activeService: NonNullable<typeof svc>) => ({
schedule: async (input: { teamName: string; memberName: string; delayMs?: number }) => {
const timer = setTimeout(() => {
void activeService
.relayInboxFileToLiveRecipient(input.teamName, input.memberName)
.catch(() => undefined);
}, Math.max(0, input.delayMs ?? 0));
timer.unref?.();
},
});
const relayInboxIfNotAlreadyConsumed = async (
activeService: NonNullable<typeof svc>,
memberName: string
): Promise<void> => {
const activeTeamName = teamName;
if (!activeTeamName) {
return;
}
const relay = await activeService.relayInboxFileToLiveRecipient(activeTeamName, memberName);
if (relay.relayed === 0) {
console.info(
`[MemberWorkSyncCodex.live] manual inbox relay returned 0 for ${activeTeamName}/${memberName}; waiting for watcher or wake delivery proof`
);
}
};
beforeEach(async () => {
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'member-work-sync-codex-live-'));
tempClaudeRoot = path.join(tempDir, '.claude');
@ -229,6 +256,8 @@ liveDescribe('Member work sync Codex live e2e', () => {
isTeamActive: (name) =>
activeService.isTeamAlive(name) || activeService.hasProvisioningRun(name),
listLifecycleActiveTeamNames: async () => [teamName!],
resolveControlUrl: async () => controlServer?.baseUrl ?? null,
nudgeDeliveryWake: createLiveNudgeDeliveryWake(activeService),
});
activeService.setTeamChangeEmitter((event: TeamChangeEvent) =>
feature!.noteTeamChange(event)
@ -304,8 +333,7 @@ liveDescribe('Member work sync Codex live e2e', () => {
expect(preRelayStatus.agenda.items.some((item) => item.taskId === task.id)).toBe(true);
expect(preRelayStatus.shadow?.wouldNudge).toBe(true);
const relay = await activeService.relayInboxFileToLiveRecipient(teamName, memberName);
expect(relay.relayed).toBeGreaterThan(0);
await relayInboxIfNotAlreadyConsumed(activeService, memberName);
await waitUntil(async () => {
const fatalRuntimeMessage = await readFatalRuntimeMessage(teamName!);
@ -353,10 +381,12 @@ liveDescribe('Member work sync Codex live e2e', () => {
teamName
);
}, 60_000);
await expect(feature.dispatchDueNudges([teamName])).resolves.toMatchObject({
claimed: 0,
delivered: 0,
});
const postReportDispatch = await feature.dispatchDueNudges([teamName]);
expect(postReportDispatch.delivered).toBe(0);
expect(postReportDispatch.retryable).toBe(0);
expect(postReportDispatch.terminal).toBe(0);
expect(postReportDispatch.claimed).toBe(postReportDispatch.superseded);
expect(postReportDispatch.claimed).toBeLessThanOrEqual(1);
},
360_000
);
@ -433,6 +463,8 @@ liveDescribe('Member work sync Codex live e2e', () => {
activeService.isTeamAlive(name) || activeService.hasProvisioningRun(name),
listLifecycleActiveTeamNames: async () => [teamName!],
queueQuietWindowMs: 1,
resolveControlUrl: async () => controlServer?.baseUrl ?? null,
nudgeDeliveryWake: createLiveNudgeDeliveryWake(activeService),
});
activeService.setTeamChangeEmitter((event: TeamChangeEvent) =>
feature!.noteTeamChange(event)
@ -502,17 +534,12 @@ liveDescribe('Member work sync Codex live e2e', () => {
feature.noteTeamChange({ type: 'task', teamName, taskId: task.id });
await waitUntil(async () => {
const status = await feature!.getStatus({ teamName: teamName!, memberName });
const status = await feature!.refreshStatus({ teamName: teamName!, memberName });
if (!status.agenda.items.some((item) => item.taskId === task.id)) {
return false;
}
const inbox = await readInboxMessages(teamName!, memberName);
return inbox.some(
(message) =>
message.messageKind === 'member_work_sync_nudge' &&
typeof message.messageId === 'string' &&
message.text.includes('Work sync check')
);
await feature!.dispatchDueNudges([teamName!]);
return true;
}, 60_000, 500, async () =>
formatMemberWorkSyncDiagnostics({
feature: feature!,
@ -522,11 +549,7 @@ liveDescribe('Member work sync Codex live e2e', () => {
})
);
const inbox = await readInboxMessages(teamName, memberName);
const nudge = inbox.find((message) => message.messageKind === 'member_work_sync_nudge');
expect(nudge?.messageId).toBeTruthy();
const relay = await activeService.relayInboxFileToLiveRecipient(teamName, memberName);
expect(relay.relayed).toBeGreaterThan(0);
await relayInboxIfNotAlreadyConsumed(activeService, memberName);
await waitUntil(async () => {
const fatalRuntimeMessage = await readFatalRuntimeMessage(teamName!);
@ -643,6 +666,8 @@ liveDescribe('Member work sync Codex live e2e', () => {
activeService.isTeamAlive(name) || activeService.hasProvisioningRun(name),
listLifecycleActiveTeamNames: async () => [teamName!],
queueQuietWindowMs: 1,
resolveControlUrl: async () => controlServer?.baseUrl ?? null,
nudgeDeliveryWake: createLiveNudgeDeliveryWake(activeService),
});
activeService.setTeamChangeEmitter((event: TeamChangeEvent) =>
feature!.noteTeamChange(event)
@ -716,8 +741,7 @@ liveDescribe('Member work sync Codex live e2e', () => {
].join('\n'),
});
feature.noteTeamChange({ type: 'task', teamName, taskId: task.id });
const relay = await activeService.relayInboxFileToLiveRecipient(teamName, memberName);
expect(relay.relayed).toBeGreaterThan(0);
await relayInboxIfNotAlreadyConsumed(activeService, memberName);
await waitUntil(async () => {
const fatalRuntimeMessage = await readFatalRuntimeMessage(teamName!);

View file

@ -97,6 +97,9 @@ describe('NativeAppManagedBootstrapContextBuilder', () => {
expect(alice?.contextText).toContain('ANTHROPIC_API_KEY=[REDACTED]');
expect(bob?.contextText).not.toContain('Bearer secret-token');
expect(bob?.contextText).toContain('Bearer [REDACTED]');
expect(bob?.contextText).toContain('Codex Native visible messaging rule');
expect(bob?.contextText).toContain('mcp__agent-teams__task_get');
expect(bob?.contextText).not.toContain('notify your team lead via SendMessage');
expect(alice?.contextHash).toBe(hashNativeBootstrapText(alice?.contextText ?? ''));
});
@ -174,4 +177,34 @@ describe('NativeAppManagedBootstrapContextBuilder', () => {
expect(firstContext).toContain('[truncated native bootstrap context]');
});
it('keeps Codex MCP rules in compact native startup context', async () => {
await new TeamMetaStore().writeMeta('large-codex-native-team', {
cwd: '/tmp/workspace',
providerId: 'codex',
model: 'gpt-5.4-mini',
createdAt: Date.now(),
});
const members = Array.from({ length: 10 }, (_, index) => ({
name: `member-${index}`,
providerId: 'codex' as const,
role: 'Developer',
model: 'gpt-5.4-mini',
}));
await new TeamMembersMetaStore().writeMembers('large-codex-native-team', members);
const result = await buildNativeAppManagedBootstrapSpecsWithDiagnostics({
teamName: 'large-codex-native-team',
cwd: '/tmp/workspace',
members,
});
const firstContext = result.specs.get('member-0')?.contextText ?? '';
expect(result.specs.size).toBe(10);
expect(firstContext).toContain('The app loaded compact startup context');
expect(firstContext).toContain('mcp__agent-teams__task_get');
expect(firstContext).toContain('mcp__agent-teams__member_work_sync_report');
expect(firstContext).toContain('mcp__agent-teams__message_send');
expect(firstContext).toContain('Do not use SendMessage');
});
});

View file

@ -1,11 +1,4 @@
import fs from 'fs';
import os from 'os';
import path from 'path';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { createMemberWorkSyncFeature } from '@features/member-work-sync/main';
import { TeamProvisioningService } from '@main/services/team/TeamProvisioningService';
import {
OPENCODE_PROMPT_DELIVERY_LEDGER_SCHEMA_VERSION,
type OpenCodePromptDeliveryLedgerRecord,
@ -14,7 +7,12 @@ import {
getOpenCodeLaneScopedRuntimeFilePath,
getOpenCodeRuntimeLaneIndexPath,
} from '@main/services/team/opencode/store/OpenCodeRuntimeManifestEvidenceReader';
import { TeamProvisioningService } from '@main/services/team/TeamProvisioningService';
import { getTeamsBasePath, setClaudeBasePathOverride } from '@main/utils/pathDecoder';
import fs from 'fs';
import os from 'os';
import path from 'path';
import { afterEach, describe, expect, it, vi } from 'vitest';
import type { InboxMessage, TaskRef } from '@shared/types/team';
@ -55,7 +53,9 @@ async function seedNonBlockingShadowCollectingMetrics(input: {
teamsBasePath: string;
teamName: string;
memberName: string;
statusEventCount?: number;
}): Promise<void> {
const statusEventCount = input.statusEventCount ?? 18;
const metricsPath = path.join(
input.teamsBasePath,
input.teamName,
@ -78,7 +78,7 @@ async function seedNonBlockingShadowCollectingMetrics(input: {
evaluatedAt: '2026-01-01T00:00:00.000Z',
},
},
recentEvents: Array.from({ length: 18 }, (_, index) => ({
recentEvents: Array.from({ length: statusEventCount }, (_, index) => ({
id: `seed-status-${index}`,
teamName: input.teamName,
memberName: input.memberName,
@ -100,7 +100,9 @@ async function seedTeamConfig(input: {
teamsBasePath: string;
teamName: string;
memberName: string;
providerId?: 'opencode' | 'codex';
}): Promise<void> {
const providerId = input.providerId ?? 'opencode';
const configPath = path.join(input.teamsBasePath, input.teamName, 'config.json');
await fs.promises.mkdir(path.dirname(configPath), { recursive: true });
await fs.promises.writeFile(
@ -114,8 +116,8 @@ async function seedTeamConfig(input: {
{
name: input.memberName,
role: 'developer',
providerId: 'opencode',
model: 'openrouter/test',
providerId,
model: providerId === 'codex' ? 'gpt-5.4-mini' : 'openrouter/test',
},
],
},
@ -295,13 +297,15 @@ function createFeature(input: {
memberName: string;
service: TeamProvisioningService;
nudgeDeliveryWake: { schedule: ReturnType<typeof vi.fn> };
providerId?: 'opencode' | 'codex';
}) {
const providerId = input.providerId ?? 'opencode';
return createMemberWorkSyncFeature({
teamsBasePath: input.teamsBasePath,
configReader: {
getConfig: vi.fn(async () => ({
name: input.teamName,
members: [{ name: input.memberName, providerId: 'opencode' }],
members: [{ name: input.memberName, providerId }],
})),
} as never,
taskReader: {
@ -326,17 +330,81 @@ function createFeature(input: {
getMembers: vi.fn(async () => []),
} as never,
isTeamActive: vi.fn(async () => true),
extraBusySignals: [
{
isBusy: (busyInput) => input.service.getOpenCodeMemberDeliveryBusyStatus(busyInput),
},
],
extraBusySignals:
providerId === 'opencode'
? [
{
isBusy: (busyInput) => input.service.getOpenCodeMemberDeliveryBusyStatus(busyInput),
},
]
: [],
nudgeDeliveryWake: input.nudgeDeliveryWake,
queueQuietWindowMs: 1,
});
}
describe('OpenCode agenda-sync proof-missing recovery safe e2e', () => {
it('delivers a Codex work-sync nudge during shadow collection with prefixed MCP aliases and schedules a Codex wake', async () => {
const claudeRoot = makeTempRoot();
setClaudeBasePathOverride(claudeRoot);
const teamsBasePath = getTeamsBasePath();
const teamName = 'team-codex-agenda-sync-nudge';
const memberName = 'bob';
const service = new TeamProvisioningService();
const nudgeDeliveryWake = { schedule: vi.fn(async () => undefined) };
const feature = createFeature({
teamsBasePath,
teamName,
memberName,
service,
nudgeDeliveryWake,
providerId: 'codex',
});
try {
await seedTeamConfig({ teamsBasePath, teamName, memberName, providerId: 'codex' });
await seedNonBlockingShadowCollectingMetrics({
teamsBasePath,
teamName,
memberName,
});
await feature.refreshStatus({ teamName, memberName });
await feature.dispatchDueNudges([teamName]);
await waitForAssertion(async () => {
const inbox = await readInboxMessages({ teamsBasePath, teamName, memberName });
const nudges = inbox.filter((message) => message.messageKind === 'member_work_sync_nudge');
expect(nudges).toHaveLength(1);
expect(nudges[0]?.text).toContain('Required sync action: call member_work_sync_status');
expect(nudges[0]?.text).toContain('mcp__agent-teams__member_work_sync_status');
expect(nudges[0]?.text).toContain('mcp__agent-teams__member_work_sync_report');
expect(nudges[0]?.text).toContain('Do not search the filesystem');
await expect(feature.getMetrics({ teamName })).resolves.toMatchObject({
phase2Readiness: { state: 'collecting_shadow_data' },
});
expect(nudgeDeliveryWake.schedule).toHaveBeenCalledWith({
teamName,
memberName,
messageId: nudges[0]?.messageId,
providerId: 'codex',
reason: 'member_work_sync_nudge_inserted',
delayMs: 500,
});
expect(
Object.values(await readMemberOutboxItems({ teamsBasePath, teamName, memberName }))
).toEqual([
expect.objectContaining({
status: 'delivered',
deliveredMessageId: nudges[0]?.messageId,
}),
]);
});
} finally {
await feature.dispose();
}
});
it('delivers a work-sync nudge without marking the proof-missing foreground message read', async () => {
const claudeRoot = makeTempRoot();
setClaudeBasePathOverride(claudeRoot);

View file

@ -1,9 +1,3 @@
import { promises as fs } from 'fs';
import * as os from 'os';
import * as path from 'path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import {
buildOpenCodePromptDeliveryAttemptId,
createOpenCodePromptDeliveryLedgerStore,
@ -11,6 +5,10 @@ import {
isOpenCodePromptDeliveryAttemptDue,
isOpenCodeSessionRefreshResponseState,
} from '@main/services/team/opencode/delivery/OpenCodePromptDeliveryLedger';
import { promises as fs } from 'fs';
import * as os from 'os';
import * as path from 'path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
describe('OpenCodePromptDeliveryLedger', () => {
let tempDir = '';
@ -299,6 +297,91 @@ describe('OpenCodePromptDeliveryLedger', () => {
expect(missingTaskRefs.diagnostics).toContain('visible_reply_missing_task_refs_after_merge');
});
it('clears stale terminal failure state after sufficient destination proof', async () => {
const store = createStore();
const record = await store.ensurePending({
teamName: 'team-a',
memberName: 'jack',
laneId: 'secondary:opencode:jack',
inboxMessageId: 'msg-recovered',
inboxTimestamp: '2026-04-25T09:59:00.000Z',
source: 'watcher',
replyRecipient: 'team-lead',
payloadHash: 'sha256:recovered',
now: '2026-04-25T10:00:00.000Z',
});
const acceptanceUnknown = await store.markAcceptanceUnknown({
id: record.id,
reason: 'opencode_prompt_acceptance_unknown_after_bridge_timeout',
nextAttemptAt: '2026-04-25T10:00:30.000Z',
markedAt: '2026-04-25T10:00:01.000Z',
});
const failed = await store.markFailedTerminal({
id: acceptanceUnknown.id,
reason: 'opencode_session_stale_observe_loop_after_accepted_prompt',
diagnostics: ['OpenCode session stayed stale while observing an accepted prompt after 5 attempt(s).'],
failedAt: '2026-04-25T10:00:05.000Z',
});
const recovered = await store.applyDestinationProof({
id: failed.id,
visibleReplyInbox: 'team-lead',
visibleReplyMessageId: 'reply-recovered',
visibleReplyCorrelation: 'relayOfMessageId',
semanticallySufficient: true,
diagnostics: ['opencode_visible_reply_recovered_by_task_refs'],
observedAt: '2026-04-25T10:01:00.000Z',
});
expect(recovered.status).toBe('responded');
expect(recovered.responseState).toBe('responded_visible_message');
expect(recovered.failedAt).toBeNull();
expect(recovered.lastReason).toBeNull();
expect(recovered.nextAttemptAt).toBeNull();
expect(recovered.acceptanceUnknown).toBe(false);
expect(recovered.visibleReplyMessageId).toBe('reply-recovered');
expect(recovered.diagnostics).toContain('opencode_session_stale_observe_loop_after_accepted_prompt');
expect(recovered.diagnostics).toContain('opencode_visible_reply_recovered_by_task_refs');
});
it('keeps terminal failure active when destination proof is not semantically sufficient', async () => {
const store = createStore();
const record = await store.ensurePending({
teamName: 'team-a',
memberName: 'jack',
laneId: 'secondary:opencode:jack',
inboxMessageId: 'msg-insufficient-proof',
inboxTimestamp: '2026-04-25T09:59:00.000Z',
source: 'watcher',
replyRecipient: 'team-lead',
payloadHash: 'sha256:insufficient-proof',
now: '2026-04-25T10:00:00.000Z',
});
const failed = await store.markFailedTerminal({
id: record.id,
reason: 'visible_reply_still_required',
diagnostics: ['OpenCode responded, but did not create a visible message_send reply.'],
failedAt: '2026-04-25T10:00:05.000Z',
});
const stillFailed = await store.applyDestinationProof({
id: failed.id,
visibleReplyInbox: 'team-lead',
visibleReplyMessageId: 'reply-ack-only',
visibleReplyCorrelation: 'relayOfMessageId',
semanticallySufficient: false,
diagnostics: ['visible_reply_ack_only_still_requires_answer'],
observedAt: '2026-04-25T10:01:00.000Z',
});
expect(stillFailed.status).toBe('failed_terminal');
expect(stillFailed.responseState).toBe('responded_visible_message');
expect(stillFailed.failedAt).toBe('2026-04-25T10:00:05.000Z');
expect(stillFailed.lastReason).toBe('visible_reply_ack_only_still_requires_answer');
expect(stillFailed.visibleReplyMessageId).toBe('reply-ack-only');
expect(stillFailed.diagnostics).toContain('visible_reply_ack_only_still_requires_answer');
});
it('records empty assistant delivery results as unanswered and stores plain text previews', async () => {
const store = createStore();
const unanswered = await store.ensurePending({

View file

@ -1,9 +1,8 @@
import { describe, expect, it } from 'vitest';
import {
decideOpenCodePromptDeliveryRepair,
type OpenCodePromptDeliveryRepairInput,
} from '@main/services/team/opencode/delivery/OpenCodePromptDeliveryRepairPolicy';
import { describe, expect, it } from 'vitest';
function base(overrides: Partial<OpenCodePromptDeliveryRepairInput> = {}) {
return {
@ -71,6 +70,7 @@ describe('OpenCodePromptDeliveryRepairPolicy', () => {
expect(decision.kind).toBe('work_sync_report_required');
expect(decision.controlText).toContain('member_work_sync_status');
expect(decision.controlText).toContain('member_work_sync_report');
expect(decision.controlText).toContain('A status-only tool call is incomplete');
expect(decision.controlText).toContain('controlUrl="http://127.0.0.1:43123"');
expect(decision.controlText).toContain('"task-1"');
expect(decision.controlText).not.toContain('reportToken=');
@ -91,6 +91,7 @@ describe('OpenCodePromptDeliveryRepairPolicy', () => {
expect(decision.kind).toBe('work_sync_report_required');
expect(decision.controlText).toContain('review pickup control message');
expect(decision.controlText).toContain('start or continue the review');
expect(decision.controlText).toContain('A status-only tool call is incomplete');
expect(decision.controlText).toContain('"task-1"');
expect(decision.controlText).not.toContain('Then call agent-teams_member_work_sync_report');
});

View file

@ -2,17 +2,16 @@ import * as nodeFs from 'fs';
import * as fs from 'fs/promises';
import * as os from 'os';
import * as path from 'path';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { encodePath, setClaudeBasePathOverride } from '../../../../src/main/utils/pathDecoder';
import { TeamConfigReader } from '../../../../src/main/services/team/TeamConfigReader';
import { gitIdentityResolver } from '../../../../src/main/services/parsing/GitIdentityResolver';
import { buildTaskChangePresenceDescriptor } from '../../../../src/main/services/team/taskChangePresenceUtils';
import { TeamConfigReader } from '../../../../src/main/services/team/TeamConfigReader';
import { TeamDataService } from '../../../../src/main/services/team/TeamDataService';
import { TeamTaskReader } from '../../../../src/main/services/team/TeamTaskReader';
import { gitIdentityResolver } from '../../../../src/main/services/parsing/GitIdentityResolver';
import type { TeamMetaFile } from '../../../../src/main/services/team/TeamMetaStore';
import { encodePath, setClaudeBasePathOverride } from '../../../../src/main/utils/pathDecoder';
import type { TeamMetaFile } from '../../../../src/main/services/team/TeamMetaStore';
import type {
InboxMessage,
KanbanState,
@ -1181,6 +1180,67 @@ describe('TeamDataService', () => {
);
});
it('restores a removed member without reusing the stale runtime agent id', async () => {
const writeMembers = vi.fn(async () => {});
const membersMetaStore = {
getMembers: vi.fn(async () => [
{
name: 'alice',
role: 'Developer',
providerId: 'codex',
model: 'gpt-5.4-mini',
effort: 'medium',
agentType: 'general-purpose',
agentId: 'alice@old-runtime-team',
joinedAt: 1710000000000,
removedAt: 1715000000000,
},
{
name: 'bob',
role: 'Reviewer',
providerId: 'codex',
agentType: 'general-purpose',
joinedAt: 1710000100000,
},
]),
writeMembers,
} as never;
const service = new TeamDataService(
{ getConfig: vi.fn(), listTeams: vi.fn() } as never,
{ getTasks: vi.fn(async () => []) } as never,
{ listInboxNames: vi.fn(async () => []), getMessages: vi.fn(async () => []) } as never,
{} as never,
{} as never,
{ resolveMembers: vi.fn(() => []) } as never,
{
getState: vi.fn(async () => ({ teamName: 'runtime-team', reviewers: [], tasks: {} })),
} as never,
{} as never,
membersMetaStore,
{ readMessages: vi.fn(async () => []) } as never
);
await expect(service.restoreMember('runtime-team', 'alice')).resolves.toMatchObject({
name: 'alice',
role: 'Developer',
agentId: undefined,
removedAt: undefined,
});
expect(writeMembers).toHaveBeenCalledWith(
'runtime-team',
expect.arrayContaining([
expect.objectContaining({
name: 'alice',
role: 'Developer',
agentId: undefined,
removedAt: undefined,
}),
])
);
});
it('keeps getTeamData read-only and skips kanban garbage-collect', async () => {
const order: string[] = [];
const tasks: TeamTask[] = [
@ -2237,6 +2297,25 @@ describe('TeamDataService', () => {
{} as never,
() =>
({
tasks: {
getTask: vi.fn(() => ({
id: 'task-1',
status: 'completed',
reviewState: 'none',
})),
},
kanban: {
getKanbanState: vi.fn(() => ({
teamName: 'my-team',
reviewers: [],
tasks: {
'task-1': {
column: 'review',
movedAt: '2026-05-20T10:00:00.000Z',
},
},
})),
},
review: {
requestReview: requestReviewMock,
approveReview: approveReviewMock,
@ -2269,6 +2348,60 @@ describe('TeamDataService', () => {
});
});
it('uses direct kanban approval for completed tasks that are not in review', async () => {
const approveReviewMock = vi.fn();
const setKanbanColumnMock = vi.fn();
const service = new TeamDataService(
{
listTeams: vi.fn(),
getConfig: vi.fn(async () => ({
name: 'My team',
members: [{ name: 'lead', role: 'team lead' }],
leadSessionId: 'lead-2',
})),
} as never,
{} as never,
{} as never,
{} as never,
{} as never,
{} as never,
{} as never,
{} as never,
{} as never,
{} as never,
() =>
({
tasks: {
getTask: vi.fn(() => ({
id: 'task-1',
status: 'completed',
reviewState: 'none',
historyEvents: [],
})),
},
kanban: {
getKanbanState: vi.fn(() => ({
teamName: 'my-team',
reviewers: [],
tasks: {},
})),
setKanbanColumn: setKanbanColumnMock,
},
review: {
approveReview: approveReviewMock,
},
}) as never
);
await service.updateKanban('my-team', 'task-1', { op: 'set_column', column: 'approved' });
expect(setKanbanColumnMock).toHaveBeenCalledWith('task-1', 'approved', {
transition: 'manual_approve',
});
expect(approveReviewMock).not.toHaveBeenCalled();
});
it('seeds historical eligible task comments without sending when the journal is missing', async () => {
const previous = process.env[TASK_COMMENT_FORWARDING_ENV];
process.env[TASK_COMMENT_FORWARDING_ENV] = 'on';

View file

@ -69,6 +69,7 @@ describe('TeamMcpConfigBuilder', () => {
const createdDirs: string[] = [];
let tempAppData: string;
let originalResourcesPath: string | undefined;
let originalControlUrl: string | undefined;
function setPackagedMode(isPackaged: boolean, version = '9.9.9-test'): void {
hoisted.electronState.isPackaged = isPackaged;
@ -183,6 +184,7 @@ describe('TeamMcpConfigBuilder', () => {
beforeEach(() => {
clearResolvedNodePathForTests();
originalResourcesPath = (process as NodeJS.Process & { resourcesPath?: string }).resourcesPath;
originalControlUrl = process.env.CLAUDE_TEAM_CONTROL_URL;
tempAppData = fs.mkdtempSync(path.join(os.tmpdir(), 'team-mcp-appdata-'));
createdDirs.push(tempAppData);
moduleInternal._load = ((request, parent, isMain) => {
@ -214,6 +216,11 @@ describe('TeamMcpConfigBuilder', () => {
setClaudeBasePathOverride(null);
setPackagedMode(false);
setResourcesPath(originalResourcesPath);
if (originalControlUrl === undefined) {
delete process.env.CLAUDE_TEAM_CONTROL_URL;
} else {
process.env.CLAUDE_TEAM_CONTROL_URL = originalControlUrl;
}
moduleInternal._load = originalModuleLoad;
vi.restoreAllMocks();
for (const filePath of createdPaths.splice(0)) {
@ -430,6 +437,87 @@ describe('TeamMcpConfigBuilder', () => {
expect(parsed.mcpServers.duplicateServer).toBeUndefined();
});
it('writes Agent Teams MCP only member config even when user and project MCP exist', async () => {
const { sourceEntry, tsxCli } = mockSourceWorkspaceEntryAvailable();
const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'team-mcp-home-'));
const projectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'team-mcp-project-'));
const claudeRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'team-mcp-claude-root-'));
createdDirs.push(homeDir, projectDir, claudeRoot);
mockHomeDir = homeDir;
setClaudeBasePathOverride(claudeRoot);
fs.writeFileSync(
path.join(homeDir, '.claude.json'),
JSON.stringify(
{
mcpServers: {
'brave-real-browser': {
command: 'node',
args: ['brave-real-browser.js'],
},
context7: {
type: 'http',
url: 'https://context7.example.com/mcp',
},
'agent-teams': {
command: 'node',
args: ['user-shadow-agent-teams.js'],
enabled: false,
},
},
projects: {
[projectDir]: {
mcpServers: {
'chrome-devtools': {
command: 'node',
args: ['chrome-devtools.js'],
},
},
},
},
},
null,
2
)
);
fs.writeFileSync(
path.join(projectDir, '.mcp.json'),
JSON.stringify(
{
mcpServers: {
tavily: {
command: 'node',
args: ['tavily.js'],
},
},
},
null,
2
)
);
const builder = new TeamMcpConfigBuilder();
const configPath = await builder.writeConfigFile(projectDir, { mode: 'appOnly' });
createdPaths.push(configPath);
const parsed = JSON.parse(fs.readFileSync(configPath, 'utf8')) as {
mcpServers: Record<string, { command?: string; args?: string[]; enabled?: boolean; env?: Record<string, string> }>;
};
expect(Object.keys(parsed.mcpServers)).toEqual(['agent-teams']);
expectNodeTsxSourceEntry(parsed.mcpServers['agent-teams'], tsxCli, sourceEntry);
expect(parsed.mcpServers['agent-teams']).toMatchObject({
enabled: true,
env: {
AGENT_TEAMS_MCP_CLAUDE_DIR: claudeRoot,
},
});
expect(parsed.mcpServers['brave-real-browser']).toBeUndefined();
expect(parsed.mcpServers.context7).toBeUndefined();
expect(parsed.mcpServers['chrome-devtools']).toBeUndefined();
expect(parsed.mcpServers.tavily).toBeUndefined();
});
it('does not inline project MCP config to preserve native Claude precedence', async () => {
const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'team-mcp-home-'));
const projectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'team-mcp-project-'));
@ -685,6 +773,30 @@ describe('TeamMcpConfigBuilder', () => {
});
});
it('passes the published control API URL to the MCP server', async () => {
process.env.CLAUDE_TEAM_CONTROL_URL = 'http://127.0.0.1:43123';
const builder = new TeamMcpConfigBuilder();
const configPath = await builder.writeConfigFile();
createdPaths.push(configPath);
expect(readGeneratedServer(configPath)?.env).toMatchObject({
CLAUDE_TEAM_CONTROL_URL: 'http://127.0.0.1:43123',
});
});
it('allows an explicit control API URL when no MCP policy is provided', async () => {
const builder = new TeamMcpConfigBuilder();
const configPath = await builder.writeConfigFile(undefined, {
controlApiBaseUrl: 'http://127.0.0.1:43124',
});
createdPaths.push(configPath);
expect(readGeneratedServer(configPath)?.env).toMatchObject({
CLAUDE_TEAM_CONTROL_URL: 'http://127.0.0.1:43124',
});
});
it('ignores malformed user MCP file', async () => {
const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'team-mcp-home-'));
const projectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'team-mcp-project-'));

View file

@ -970,6 +970,50 @@ describe('TeamMemberRuntimeAdvisoryService', () => {
expect(advisory).toBeNull();
});
it('does not surface advisory for recovered OpenCode records that still contain old failure metadata', async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-05-19T12:31:00.000Z'));
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'claude-team-advisory-'));
setClaudeBasePathOverride(tmpDir);
const teamName = 'relay-release';
const laneId = 'secondary:opencode:tom';
await writeOpenCodeDeliveryFixture({
baseDir: tmpDir,
teamName,
laneId,
records: [
buildOpenCodeDeliveryRecord({
teamName,
laneId,
status: 'responded',
responseState: 'responded_visible_message',
inboxReadCommittedAt: '2026-05-19T12:29:31.172Z',
visibleReplyMessageId: 'visible-reply-recovered',
visibleReplyInbox: 'team-lead',
visibleReplyCorrelation: 'relayOfMessageId',
respondedAt: '2026-05-19T12:29:31.126Z',
lastObservedAt: '2026-05-19T12:29:31.126Z',
failedAt: '2026-05-19T12:27:25.965Z',
lastReason: 'opencode_session_stale_observe_loop_after_accepted_prompt',
diagnostics: [
'opencode_session_stale_observe_loop_after_accepted_prompt',
'OpenCode session stayed stale while observing an accepted prompt after 5 attempt(s).',
'opencode_visible_reply_recovered_by_task_refs',
],
updatedAt: '2026-05-19T12:29:31.172Z',
}),
],
});
const service = new TeamMemberRuntimeAdvisoryService({
findMemberLogs: vi.fn(async () => []),
});
const advisory = await service.getMemberAdvisory(teamName, 'tom');
expect(advisory).toBeNull();
});
it('suppresses stale OpenCode prompt delivery advisories after a visible runtime reply exists', async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'claude-team-advisory-'));
setClaudeBasePathOverride(tmpDir);

View file

@ -99,6 +99,27 @@ describe('TeamMessageFeedService', () => {
expect(feed.messages.map((message) => message.messageId)).toEqual(['visible-user-message']);
});
it('includes Codex runtimeProvider in synthetic teammate bootstrap prompts', async () => {
const service = new TeamMessageFeedService({
getConfig: vi.fn(async () => ({
name: 'codex-team',
members: [
{ name: 'team-lead', role: 'Lead' },
{ name: 'bob', role: 'Developer', providerId: 'codex' as const, model: 'gpt-5.4-mini' },
],
})),
getInboxMessages: vi.fn(async () => []),
getLeadSessionMessages: vi.fn(async () => []),
getSentMessages: vi.fn(async () => []),
});
const feed = await service.getFeed('codex-team');
expect(feed.messages).toHaveLength(1);
expect(feed.messages[0].text).toContain('runtimeProvider: "codex"');
expect(feed.messages[0].text).toContain('member_briefing');
});
it('does not hide user-authored text just because it resembles an internal prompt', async () => {
const service = new TeamMessageFeedService({
getConfig: vi.fn(async () => config),

View file

@ -0,0 +1,571 @@
import { ClaudeBinaryResolver } from '@main/services/team/ClaudeBinaryResolver';
import { TeamProvisioningService } from '@main/services/team/TeamProvisioningService';
import { spawnCli } from '@main/utils/childProcess';
import { setAppDataBasePath } from '@main/utils/pathDecoder';
import { EventEmitter } from 'events';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
const hoisted = vi.hoisted(() => ({
paths: {
claudeRoot: '',
teamsBase: '',
tasksBase: '',
},
}));
let tempClaudeRoot = '';
let tempTeamsBase = '';
let tempTasksBase = '';
vi.mock('@main/services/team/ClaudeBinaryResolver', () => ({
ClaudeBinaryResolver: { resolve: vi.fn() },
}));
vi.mock('@main/utils/childProcess', () => ({
execCli: vi.fn(async (_binaryPath: string | null, args: string[]) => {
if (args[0] === '-e' && args[1]?.includes('process.execPath')) {
return { stdout: process.execPath, stderr: '' };
}
if (args.includes('model') && args.includes('list')) {
return {
stdout: JSON.stringify({
schemaVersion: 1,
providers: {
codex: {
defaultModel: 'gpt-5.4',
models: [{ id: 'gpt-5.4', label: 'GPT-5.4', description: 'Codex default' }],
},
},
}),
stderr: '',
};
}
if (args.includes('runtime') && args.includes('status')) {
return {
stdout: JSON.stringify({
providers: {
codex: {
runtimeCapabilities: {
modelCatalog: { dynamic: false, source: 'runtime' },
reasoningEffort: {
supported: true,
values: ['low', 'medium', 'high'],
configPassthrough: false,
},
},
},
},
}),
stderr: '',
};
}
return { stdout: '', stderr: '' };
}),
spawnCli: vi.fn(),
killProcessTree: vi.fn(),
}));
vi.mock('@main/utils/shellEnv', async (importOriginal) => {
const actual = await importOriginal<typeof import('@main/utils/shellEnv')>();
return {
...actual,
getCachedShellEnv: () => ({ PATH: process.env.PATH ?? '', HOME: hoisted.paths.claudeRoot }),
getShellPreferredHome: () => hoisted.paths.claudeRoot || actual.getShellPreferredHome(),
resolveInteractiveShellEnv: vi.fn(async () => ({
PATH: process.env.PATH ?? '',
HOME: hoisted.paths.claudeRoot,
})),
};
});
vi.mock('@main/utils/pathDecoder', async (importOriginal) => {
const actual = await importOriginal<typeof import('@main/utils/pathDecoder')>();
return {
...actual,
getAutoDetectedClaudeBasePath: () => hoisted.paths.claudeRoot,
getClaudeBasePath: () => hoisted.paths.claudeRoot,
getTeamsBasePath: () => hoisted.paths.teamsBase,
getTasksBasePath: () => hoisted.paths.tasksBase,
};
});
type BootstrapSpec = {
members?: Array<{
name?: string;
provider?: string;
model?: string;
mcpConfigPath?: string;
mcpSettingSources?: string;
strictMcpConfig?: boolean;
}>;
};
type BootstrapSpecMember = NonNullable<BootstrapSpec['members']>[number];
type ProvisioningServiceOverrides = {
buildProvisioningEnv: () => Promise<{
env: Record<string, string>;
authSource: string;
geminiRuntimeAuth: null;
providerArgs: string[];
}>;
resolveProviderDefaultModel: () => Promise<string>;
normalizeTeamConfigForLaunch: () => Promise<void>;
updateConfigProjectPath: () => Promise<void>;
restorePrelaunchConfig: () => Promise<void>;
assertConfigLeadOnlyForLaunch: () => Promise<void>;
persistLaunchStateSnapshot: () => Promise<void>;
validateAgentTeamsMcpRuntime: () => Promise<void>;
pathExists: () => Promise<boolean>;
startFilesystemMonitor: () => void;
};
function createFakeChild() {
const child = Object.assign(new EventEmitter(), {
pid: 12345,
stdin: Object.assign(new EventEmitter(), {
writable: true,
write: vi.fn((_data: unknown, cb?: (err?: Error | null) => void) => {
if (typeof cb === 'function') cb(null);
return true;
}),
end: vi.fn(),
on: vi.fn(),
unref: vi.fn(),
}),
stdout: Object.assign(new EventEmitter(), {
pipe: vi.fn(),
unref: vi.fn(),
}),
stderr: Object.assign(new EventEmitter(), {
pipe: vi.fn(),
unref: vi.fn(),
}),
kill: vi.fn(),
unref: vi.fn(),
});
return child;
}
function extractBootstrapSpec(): BootstrapSpec {
const args = vi.mocked(spawnCli).mock.calls[0]?.[1] as string[] | undefined;
const specFlagIndex = args?.indexOf('--team-bootstrap-spec') ?? -1;
const specPath = specFlagIndex >= 0 ? args?.[specFlagIndex + 1] : null;
if (!specPath) {
throw new Error('Failed to extract bootstrap spec path from spawn args');
}
return JSON.parse(fs.readFileSync(specPath, 'utf8')) as BootstrapSpec;
}
function extractMcpConfigPathFromArgs(args: string[]): string {
const mcpFlagIndex = args.indexOf('--mcp-config');
if (mcpFlagIndex < 0 || !args[mcpFlagIndex + 1]) {
throw new Error('Failed to extract MCP config path from launch args');
}
return args[mcpFlagIndex + 1];
}
function configureLaunchStubs(svc: TeamProvisioningService): void {
const overrides = svc as unknown as ProvisioningServiceOverrides;
overrides.buildProvisioningEnv = vi.fn(async () => ({
env: { PATH: '/usr/bin', CLAUDE_TEAM_CONTROL_URL: 'http://127.0.0.1:43123' },
authSource: 'codex_runtime',
geminiRuntimeAuth: null,
providerArgs: ['--settings', '{"codex":{"forced_login_method":"chatgpt"}}'],
}));
overrides.resolveProviderDefaultModel = vi.fn(async () => 'gpt-5.4');
overrides.normalizeTeamConfigForLaunch = vi.fn(async () => {});
overrides.updateConfigProjectPath = vi.fn(async () => {});
overrides.restorePrelaunchConfig = vi.fn(async () => {});
overrides.assertConfigLeadOnlyForLaunch = vi.fn(async () => {});
overrides.persistLaunchStateSnapshot = vi.fn(async () => {});
overrides.validateAgentTeamsMcpRuntime = vi.fn(async () => {});
overrides.pathExists = vi.fn(async () => false);
overrides.startFilesystemMonitor = vi.fn();
}
function writeProjectMcpConfig(projectDir: string): void {
fs.writeFileSync(
path.join(projectDir, '.mcp.json'),
JSON.stringify({
mcpServers: {
tavily: { command: 'node', args: ['tavily.js'] },
'brave-real-browser': { command: 'node', args: ['brave.js'] },
},
}),
'utf8'
);
}
function expectAppOnlyMcpConfigPath(mcpConfigPath: string): void {
const memberMcpConfig = JSON.parse(fs.readFileSync(mcpConfigPath, 'utf8')) as {
mcpServers: Record<
string,
{ command?: string; args?: string[]; enabled?: boolean; env?: Record<string, string> }
>;
};
expect(Object.keys(memberMcpConfig.mcpServers)).toEqual(['agent-teams']);
expect(memberMcpConfig.mcpServers['agent-teams']).toMatchObject({
enabled: true,
env: {
AGENT_TEAMS_MCP_CLAUDE_DIR: tempClaudeRoot,
CLAUDE_TEAM_CONTROL_URL: 'http://127.0.0.1:43123',
},
});
expect(memberMcpConfig.mcpServers.tavily).toBeUndefined();
expect(memberMcpConfig.mcpServers['brave-real-browser']).toBeUndefined();
}
function expectAppOnlyMemberMcpConfig(member: BootstrapSpecMember | undefined): void {
expect(member?.mcpConfigPath).toEqual(expect.any(String));
expectAppOnlyMcpConfigPath(member?.mcpConfigPath ?? '');
}
async function expectPathRemovedEventually(targetPath: string): Promise<void> {
for (let attempt = 0; attempt < 20; attempt += 1) {
if (!fs.existsSync(targetPath)) {
return;
}
await new Promise((resolve) => setTimeout(resolve, 25));
}
expect(fs.existsSync(targetPath)).toBe(false);
}
function writeCodexTeamWithAppOnlyMeta(teamName: string): void {
const teamDir = path.join(tempTeamsBase, teamName);
fs.mkdirSync(teamDir, { recursive: true });
fs.writeFileSync(
path.join(teamDir, 'config.json'),
JSON.stringify({
name: teamName,
members: [
{ name: 'team-lead', agentType: 'team-lead', providerId: 'codex' },
{ name: 'alice', agentType: 'teammate', role: 'developer', providerId: 'codex' },
],
}),
'utf8'
);
fs.writeFileSync(
path.join(teamDir, 'members.meta.json'),
JSON.stringify({
version: 1,
providerBackendId: 'codex-native',
members: [
{
name: 'alice',
role: 'developer',
providerId: 'codex',
model: 'gpt-5.4',
mcpPolicy: { mode: 'appOnly' },
},
],
}),
'utf8'
);
}
describe('TeamProvisioningService member MCP config safe e2e', () => {
beforeEach(() => {
vi.clearAllMocks();
tempClaudeRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'claude team member mcp-'));
tempTeamsBase = path.join(tempClaudeRoot, 'teams');
tempTasksBase = path.join(tempClaudeRoot, 'tasks');
hoisted.paths.claudeRoot = tempClaudeRoot;
hoisted.paths.teamsBase = tempTeamsBase;
hoisted.paths.tasksBase = tempTasksBase;
setAppDataBasePath(tempClaudeRoot);
fs.mkdirSync(tempTeamsBase, { recursive: true });
fs.mkdirSync(tempTasksBase, { recursive: true });
});
afterEach(() => {
setAppDataBasePath(null);
fs.rmSync(tempClaudeRoot, { recursive: true, force: true });
});
it('createTeam preserves request Agent Teams MCP only in the real member MCP config', async () => {
const teamName = 'codex-app-only-create';
const projectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codex app-only project-'));
writeProjectMcpConfig(projectDir);
vi.mocked(ClaudeBinaryResolver.resolve).mockResolvedValue('/fake/codex');
vi.mocked(spawnCli).mockReturnValue(createFakeChild() as never);
const svc = new TeamProvisioningService();
configureLaunchStubs(svc);
svc.setControlApiBaseUrlResolver(async () => 'http://127.0.0.1:43123');
let runId: string | undefined;
try {
const created = await svc.createTeam(
{
teamName,
cwd: projectDir,
providerId: 'codex',
providerBackendId: 'codex-native',
model: 'gpt-5.4',
members: [
{
name: 'alice',
role: 'developer',
providerId: 'codex',
model: 'gpt-5.4',
mcpPolicy: { mode: 'appOnly' },
},
],
},
() => {}
);
runId = created.runId;
const member = extractBootstrapSpec().members?.[0];
expect(member).toEqual(
expect.objectContaining({
name: 'alice',
provider: 'codex',
model: 'gpt-5.4',
mcpSettingSources: 'user,project,local',
strictMcpConfig: true,
})
);
expectAppOnlyMemberMcpConfig(member);
const memberMcpConfigPath = member?.mcpConfigPath ?? '';
await svc.cancelProvisioning(runId);
runId = undefined;
await expectPathRemovedEventually(memberMcpConfigPath);
} finally {
if (runId) {
await svc.cancelProvisioning(runId);
}
fs.rmSync(projectDir, { recursive: true, force: true });
}
});
it('launchTeam preserves members.meta Agent Teams MCP only in the real member MCP config', async () => {
const teamName = 'codex-app-only-meta-launch';
const projectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codex-app-only-project-'));
writeCodexTeamWithAppOnlyMeta(teamName);
writeProjectMcpConfig(projectDir);
vi.mocked(ClaudeBinaryResolver.resolve).mockResolvedValue('/fake/codex');
vi.mocked(spawnCli).mockReturnValue(createFakeChild() as never);
const svc = new TeamProvisioningService();
configureLaunchStubs(svc);
svc.setControlApiBaseUrlResolver(async () => 'http://127.0.0.1:43123');
let runId: string | undefined;
try {
const launched = await svc.launchTeam(
{
teamName,
cwd: projectDir,
providerId: 'codex',
providerBackendId: 'codex-native',
model: 'gpt-5.4',
clearContext: true,
},
() => {}
);
runId = launched.runId;
const member = extractBootstrapSpec().members?.[0];
expect(member).toEqual(
expect.objectContaining({
name: 'alice',
provider: 'codex',
model: 'gpt-5.4',
mcpSettingSources: 'user,project,local',
strictMcpConfig: true,
})
);
expectAppOnlyMemberMcpConfig(member);
const memberMcpConfigPath = member?.mcpConfigPath ?? '';
await svc.cancelProvisioning(runId);
runId = undefined;
await expectPathRemovedEventually(memberMcpConfigPath);
} finally {
if (runId) {
await svc.cancelProvisioning(runId);
}
fs.rmSync(projectDir, { recursive: true, force: true });
}
});
it('live add-member MCP prep writes and discards a real Agent Teams MCP only config', async () => {
const teamName = 'codex-app-only-live-add';
const projectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codex live add project-'));
writeProjectMcpConfig(projectDir);
vi.mocked(ClaudeBinaryResolver.resolve).mockResolvedValue('/fake/codex');
vi.mocked(spawnCli).mockReturnValue(createFakeChild() as never);
const svc = new TeamProvisioningService();
configureLaunchStubs(svc);
svc.setControlApiBaseUrlResolver(async () => 'http://127.0.0.1:43123');
let runId: string | undefined;
let liveMcpConfigPath = '';
try {
const created = await svc.createTeam(
{
teamName,
cwd: projectDir,
providerId: 'codex',
providerBackendId: 'codex-native',
model: 'gpt-5.4',
members: [{ name: 'alice', role: 'developer', providerId: 'codex', model: 'gpt-5.4' }],
},
() => {}
);
runId = created.runId;
(svc as unknown as { aliveRunByTeam: Map<string, string> }).aliveRunByTeam.set(
teamName,
runId
);
const liveMcpConfig = await svc.prepareLiveMemberMcpLaunchConfig({
teamName,
cwd: projectDir,
mcpPolicy: { mode: 'appOnly' },
});
expect(liveMcpConfig).toEqual(
expect.objectContaining({
mcpConfigPath: expect.any(String),
mcpSettingSources: 'user,project,local',
strictMcpConfig: true,
})
);
liveMcpConfigPath = liveMcpConfig?.mcpConfigPath ?? '';
expectAppOnlyMcpConfigPath(liveMcpConfigPath);
await svc.discardLiveMemberMcpLaunchConfig({
teamName,
mcpLaunchConfig: liveMcpConfig,
});
await expectPathRemovedEventually(liveMcpConfigPath);
await svc.cancelProvisioning(runId);
runId = undefined;
} finally {
if (runId) {
await svc.cancelProvisioning(runId);
}
fs.rmSync(projectDir, { recursive: true, force: true });
}
});
it('restartMember direct process preserves Agent Teams MCP only in the real restart args', async () => {
const teamName = 'codex-app-only-process-restart';
const projectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codex restart project-'));
writeProjectMcpConfig(projectDir);
vi.mocked(ClaudeBinaryResolver.resolve).mockResolvedValue('/fake/codex');
vi.mocked(spawnCli).mockReturnValue(createFakeChild() as never);
const svc = new TeamProvisioningService();
configureLaunchStubs(svc);
let runId: string | undefined;
let restartMcpConfigPath = '';
try {
const created = await svc.createTeam(
{
teamName,
cwd: projectDir,
providerId: 'codex',
providerBackendId: 'codex-native',
model: 'gpt-5.4',
members: [
{
name: 'alice',
role: 'developer',
providerId: 'codex',
model: 'gpt-5.4',
mcpPolicy: { mode: 'appOnly' },
},
],
},
() => {}
);
runId = created.runId;
(svc as unknown as { aliveRunByTeam: Map<string, string> }).aliveRunByTeam.set(
teamName,
runId
);
(svc as unknown as { readConfigForStrictDecision: () => Promise<unknown> }).readConfigForStrictDecision =
vi.fn(async () => ({
name: teamName,
projectPath: projectDir,
members: [
{ name: 'team-lead', agentType: 'team-lead', providerId: 'codex' },
{
name: 'alice',
role: 'developer',
providerId: 'codex',
model: 'gpt-5.4',
mcpPolicy: { mode: 'appOnly' },
},
],
}));
(svc as unknown as { readPersistedRuntimeMembers: () => unknown[] }).readPersistedRuntimeMembers =
vi.fn(() => [{ name: 'alice', backendType: 'process', cwd: projectDir }]);
(
svc as unknown as { getLiveTeamAgentRuntimeMetadata: () => Promise<Map<string, unknown>> }
).getLiveTeamAgentRuntimeMetadata = vi.fn(async () => new Map());
(
svc as unknown as {
buildTeamRuntimeLaunchArgsPlan: () => Promise<{
fastModeArgs: string[];
runtimeTurnSettledHookArgs: string[];
providerArgs: string[];
settingsArgs: string[];
extraArgs: string[];
}>;
}
).buildTeamRuntimeLaunchArgsPlan = vi.fn(async () => ({
fastModeArgs: [],
runtimeTurnSettledHookArgs: [],
providerArgs: [],
settingsArgs: [],
extraArgs: [],
}));
(
svc as unknown as { updateDirectTmuxRestartMemberConfig: () => Promise<void> }
).updateDirectTmuxRestartMemberConfig = vi.fn(async () => {});
(svc as unknown as { enqueueDirectRestartPrompt: () => void }).enqueueDirectRestartPrompt =
vi.fn();
vi.mocked(spawnCli).mockClear();
await svc.restartMember(teamName, 'alice');
const restartArgs = vi.mocked(spawnCli).mock.calls[0]?.[1] as string[] | undefined;
expect(restartArgs).toEqual(
expect.arrayContaining([
'--teammate-runtime',
'headless',
'--setting-sources',
'user,project,local',
'--strict-mcp-config',
])
);
restartMcpConfigPath = extractMcpConfigPathFromArgs(restartArgs ?? []);
expectAppOnlyMcpConfigPath(restartMcpConfigPath);
await svc.cancelProvisioning(runId);
runId = undefined;
await expectPathRemovedEventually(restartMcpConfigPath);
} finally {
if (runId) {
await svc.cancelProvisioning(runId);
}
fs.rmSync(projectDir, { recursive: true, force: true });
}
});
});

View file

@ -16246,6 +16246,63 @@ describe('TeamProvisioningService', () => {
await svc.cancelProvisioning(runId);
});
it('preserves Agent Teams MCP only from members meta when relaunch config has no mcpPolicy', async () => {
allowConsoleLogs();
const teamName = 'safe-member-mcp-policy-meta-relaunch';
const leadSessionId = 'safe-member-mcp-policy-meta-session';
writeLaunchConfig(teamName, tempClaudeRoot, leadSessionId, ['alice']);
vi.mocked(ClaudeBinaryResolver.resolve).mockResolvedValue('/mock/claude');
vi.mocked(spawnCli).mockReturnValue(createRunningChild() as any);
const { svc, mcpConfigBuilder, membersMetaStore } = createSafeLaunchService();
membersMetaStore.getMembers.mockResolvedValue([
{
name: 'alice',
providerId: 'codex',
model: 'gpt-5.4-mini',
mcpPolicy: { mode: 'appOnly' },
},
] as never);
mcpConfigBuilder.writeConfigFile.mockImplementation(async (_projectPath, policy) => {
const mode =
policy && typeof policy === 'object' && 'mode' in policy
? (policy as { mode?: unknown }).mode
: undefined;
return mode === 'appOnly'
? '/mock/member-mcp-app-only.json'
: '/mock/lead-mcp-config.json';
});
const { runId } = await svc.launchTeam(
{
teamName,
cwd: tempClaudeRoot,
providerId: 'codex',
providerBackendId: 'codex-native',
model: 'gpt-5.4',
},
() => {}
);
const spawnArgs = vi.mocked(spawnCli).mock.calls[0]?.[1] as string[];
const bootstrapSpec = readBootstrapSpecFromSpawnArgs(spawnArgs);
expect(bootstrapSpec.members).toEqual([
expect.objectContaining({
name: 'alice',
provider: 'codex',
model: 'gpt-5.4-mini',
mcpConfigPath: '/mock/member-mcp-app-only.json',
mcpSettingSources: 'user,project,local',
strictMcpConfig: true,
}),
]);
expect(mcpConfigBuilder.writeConfigFile).toHaveBeenCalledWith(tempClaudeRoot, {
mode: 'appOnly',
});
await svc.cancelProvisioning(runId);
});
it('starts an Anthropic team without injecting lead effort into explicit teammate models', async () => {
allowConsoleLogs();
vi.mocked(ClaudeBinaryResolver.resolve).mockResolvedValue('/mock/claude');

View file

@ -620,6 +620,41 @@ describe('TeamProvisioningService prompt content (solo mode discipline)', () =>
expect(normalAddMessage).not.toContain('isolation="worktree"');
});
it('add and restart teammate prompts can carry strict MCP launch overrides for Agent tool spawns', () => {
const mcpLaunchConfig = {
mcpConfigPath: '/tmp/team path/alice-mcp.json',
mcpSettingSources: 'user,project,local',
strictMcpConfig: true,
};
const addMessage = buildAddMemberSpawnMessage(
'forge-labs',
'Forge Labs',
'lead',
{
name: 'alice',
providerId: 'codex',
},
mcpLaunchConfig
);
const restartMessage = buildRestartMemberSpawnMessage(
'forge-labs',
'Forge Labs',
'lead',
{
name: 'alice',
providerId: 'codex',
},
mcpLaunchConfig
);
expect(addMessage).toContain(
'mcp_config="/tmp/team path/alice-mcp.json", mcp_setting_sources="user,project,local", strict_mcp_config=true'
);
expect(restartMessage).toContain(
'mcp_config="/tmp/team path/alice-mcp.json", mcp_setting_sources="user,project,local", strict_mcp_config=true'
);
});
it('createTeam materializes an explicit Codex default model for teammates before bootstrap spawn', async () => {
vi.mocked(ClaudeBinaryResolver.resolve).mockResolvedValue('/fake/claude');
const { child } = createFakeChild();

View file

@ -1,5 +1,5 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { promises as fsPromises } from 'fs';
import { beforeEach, describe, expect, it, vi } from 'vitest';
const hoisted = vi.hoisted(() => {
@ -774,6 +774,7 @@ describe('TeamProvisioningService relayLeadInboxMessages', () => {
messageId: 'm-work-sync-1',
source: 'system_notification',
messageKind: 'member_work_sync_nudge',
workSyncIntent: 'agenda_sync',
taskRefs: [{ teamName, taskId: 'task-1', displayId: '11111111' }],
},
]);
@ -785,7 +786,9 @@ describe('TeamProvisioningService relayLeadInboxMessages', () => {
const payload = String(writeSpy.mock.calls[0]?.[0] ?? '');
expect(payload).toContain('Message kind: member_work_sync_nudge');
expect(payload).toContain('Work-sync intent: agenda_sync');
expect(payload).toContain('it is actionable work-sync control traffic');
expect(payload).toContain('A member_work_sync_status call alone is incomplete');
expect(payload).toContain(
'Call member_work_sync_status with teamName=\\"my-team\\", memberName=\\"team-lead\\", controlUrl=\\"http://127.0.0.1:43123\\"'
);
@ -2891,6 +2894,207 @@ Messages:
expect(rows.map((row: { read?: boolean }) => row.read)).toEqual([false, true]);
});
it('emits advisory refresh when a failed-terminal OpenCode row is recovered by visible reply proof', async () => {
const service = new TeamProvisioningService();
const teamName = 'my-team';
const taskRefs = [{ teamName, taskId: 'task-recovered', displayId: 'task-rec' }];
const ledgerRecord = {
id: 'ledger-terminal-recovered',
teamName,
memberName: 'jack',
laneId: 'secondary:opencode:jack',
runId: 'run-1',
runtimeSessionId: 'ses-1',
inboxMessageId: 'opencode-terminal-recovered',
inboxTimestamp: '2026-02-23T17:00:00.000Z',
source: 'watcher',
messageKind: null,
replyRecipient: 'team-lead',
actionMode: null,
taskRefs,
payloadHash: 'sha256:test',
status: 'failed_terminal',
responseState: 'session_stale',
attempts: 1,
maxAttempts: 3,
acceptanceUnknown: false,
nextAttemptAt: null,
lastAttemptAt: '2026-02-23T17:00:03.000Z',
lastObservedAt: '2026-02-23T17:00:05.000Z',
acceptedAt: '2026-02-23T17:00:03.000Z',
respondedAt: null,
failedAt: '2026-02-23T17:00:08.000Z',
inboxReadCommittedAt: null,
inboxReadCommitError: null,
prePromptCursor: null,
postPromptCursor: null,
deliveredUserMessageId: 'runtime-user-1',
observedAssistantMessageId: null,
observedAssistantPreview: null,
observedToolCallNames: [],
observedVisibleMessageId: null,
visibleReplyMessageId: null,
visibleReplyInbox: null,
visibleReplyCorrelation: null,
lastReason: 'opencode_session_stale_observe_loop_after_accepted_prompt',
diagnostics: ['opencode_session_stale_observe_loop_after_accepted_prompt'],
createdAt: '2026-02-23T17:00:00.000Z',
updatedAt: '2026-02-23T17:00:08.000Z',
};
const visibleReply = {
inboxName: 'team-lead',
message: {
from: 'jack',
to: 'team-lead',
text: 'Recovered visible reply with task results.',
summary: '#task-rec done',
timestamp: '2026-02-23T17:01:00.000Z',
read: true,
source: 'runtime_delivery',
relayOfMessageId: 'opencode-terminal-recovered',
messageId: 'visible-reply-recovered',
taskRefs,
},
};
vi.spyOn(service as any, 'findOpenCodeVisibleReplyByRelayOfMessageId').mockResolvedValue(
visibleReply
);
const applyDestinationProof = vi.fn(async (input: Record<string, unknown>) => ({
...ledgerRecord,
status: 'responded',
responseState: 'responded_visible_message',
failedAt: null,
lastReason: null,
visibleReplyInbox: input.visibleReplyInbox,
visibleReplyMessageId: input.visibleReplyMessageId,
visibleReplyCorrelation: input.visibleReplyCorrelation,
inboxReadCommittedAt: '2026-02-23T17:01:01.000Z',
respondedAt: input.observedAt,
updatedAt: input.observedAt,
}));
const advisoryInvalidator = vi.fn();
const teamChangeEmitter = vi.fn();
service.setMemberRuntimeAdvisoryInvalidator(advisoryInvalidator);
service.setTeamChangeEmitter(teamChangeEmitter);
const result = await (service as any).applyOpenCodeVisibleDestinationProof({
ledger: { applyDestinationProof },
ledgerRecord,
teamName,
replyRecipient: 'team-lead',
memberName: 'jack',
});
expect(result.visibleReply).toBe(visibleReply);
expect(result.ledgerRecord.status).toBe('responded');
expect(applyDestinationProof).toHaveBeenCalledWith(
expect.objectContaining({
id: 'ledger-terminal-recovered',
visibleReplyInbox: 'team-lead',
visibleReplyMessageId: 'visible-reply-recovered',
visibleReplyCorrelation: 'relayOfMessageId',
semanticallySufficient: true,
})
);
expect(advisoryInvalidator).toHaveBeenCalledWith(teamName, 'jack');
expect(teamChangeEmitter).toHaveBeenCalledWith(
expect.objectContaining({
type: 'member-advisory',
teamName,
detail: 'runtime-delivery-reply:jack:opencode-terminal-recovered',
})
);
});
it('does not emit advisory refresh again for already proven OpenCode visible replies', async () => {
const service = new TeamProvisioningService();
const teamName = 'my-team';
const ledgerRecord = {
id: 'ledger-already-proven',
teamName,
memberName: 'jack',
laneId: 'secondary:opencode:jack',
runId: 'run-1',
runtimeSessionId: 'ses-1',
inboxMessageId: 'opencode-already-proven',
inboxTimestamp: '2026-02-23T17:00:00.000Z',
source: 'watcher',
messageKind: null,
replyRecipient: 'team-lead',
actionMode: null,
taskRefs: [],
payloadHash: 'sha256:test',
status: 'responded',
responseState: 'responded_visible_message',
attempts: 1,
maxAttempts: 3,
acceptanceUnknown: false,
nextAttemptAt: null,
lastAttemptAt: '2026-02-23T17:00:03.000Z',
lastObservedAt: '2026-02-23T17:01:00.000Z',
acceptedAt: '2026-02-23T17:00:03.000Z',
respondedAt: '2026-02-23T17:01:00.000Z',
failedAt: null,
inboxReadCommittedAt: '2026-02-23T17:01:01.000Z',
inboxReadCommitError: null,
prePromptCursor: null,
postPromptCursor: null,
deliveredUserMessageId: 'runtime-user-1',
observedAssistantMessageId: null,
observedAssistantPreview: null,
observedToolCallNames: [],
observedVisibleMessageId: null,
visibleReplyMessageId: 'visible-reply-proven',
visibleReplyInbox: 'team-lead',
visibleReplyCorrelation: 'relayOfMessageId',
lastReason: null,
diagnostics: [],
createdAt: '2026-02-23T17:00:00.000Z',
updatedAt: '2026-02-23T17:01:01.000Z',
};
const visibleReply = {
inboxName: 'team-lead',
message: {
from: 'jack',
to: 'team-lead',
text: 'Already proven visible reply.',
summary: '#done',
timestamp: '2026-02-23T17:01:00.000Z',
read: true,
source: 'runtime_delivery',
relayOfMessageId: 'opencode-already-proven',
messageId: 'visible-reply-proven',
},
};
vi.spyOn(service as any, 'findOpenCodeVisibleReplyByRelayOfMessageId').mockResolvedValue(
visibleReply
);
const applyDestinationProof = vi.fn(async () => ledgerRecord);
const advisoryInvalidator = vi.fn();
const teamChangeEmitter = vi.fn();
service.setMemberRuntimeAdvisoryInvalidator(advisoryInvalidator);
service.setTeamChangeEmitter(teamChangeEmitter);
const result = await (service as any).applyOpenCodeVisibleDestinationProof({
ledger: { applyDestinationProof },
ledgerRecord,
teamName,
replyRecipient: 'team-lead',
memberName: 'jack',
});
expect(result.visibleReply).toBe(visibleReply);
expect(applyDestinationProof).toHaveBeenCalledWith(
expect.objectContaining({
id: 'ledger-already-proven',
visibleReplyMessageId: 'visible-reply-proven',
semanticallySufficient: true,
})
);
expect(advisoryInvalidator).not.toHaveBeenCalled();
expect(teamChangeEmitter).not.toHaveBeenCalled();
});
it('retries failed-terminal OpenCode rows caused by stale runtime manifest watermark', async () => {
const service = new TeamProvisioningService();
const teamName = 'my-team';

View file

@ -0,0 +1,37 @@
import {
buildReplaceMembersDiff,
buildReplaceMembersSummaryMessage,
} from '@main/services/team/memberUpdateNotifications';
import { describe, expect, it } from 'vitest';
describe('member update notifications', () => {
it('reports MCP policy changes as restart-required live roster updates', () => {
const diff = buildReplaceMembersDiff(
[
{
name: 'alice',
role: 'Developer',
providerId: 'codex',
},
],
[
{
name: 'alice',
role: 'Developer',
providerId: 'codex',
mcpPolicy: { mode: 'appOnly' },
},
]
);
expect(diff.updated).toEqual([
{
name: 'alice',
changes: ['MCP access policy changed - restart required'],
},
]);
expect(buildReplaceMembersSummaryMessage(diff)).toContain(
'MCP access policy changed - restart required'
);
});
});

View file

@ -751,6 +751,98 @@ describe('CLI status visibility during completed install state', () => {
});
});
it('shows compact OpenCode configured-local and verified counts on the dashboard card', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
storeState.cliInstallerState = 'idle';
storeState.cliStatus = createInstalledCliStatus({
flavor: 'agent_teams_orchestrator',
displayName: 'Multimodel runtime',
supportsSelfUpdate: false,
showVersionDetails: false,
showBinaryPath: false,
authLoggedIn: true,
providers: [
{
providerId: 'opencode',
displayName: 'OpenCode (200+ models)',
supported: true,
authenticated: true,
authMethod: 'opencode_configured_local',
verificationState: 'verified',
statusMessage: null,
models: [],
canLoginFromUi: false,
capabilities: {
teamLaunch: true,
oneShot: false,
},
backend: { kind: 'opencode-cli', label: 'OpenCode CLI' },
modelCatalog: {
schemaVersion: 1,
providerId: 'opencode',
source: 'app-server',
status: 'ready',
fetchedAt: '2026-05-12T00:00:00.000Z',
staleAt: '2026-05-12T00:10:00.000Z',
defaultModelId: 'llama.cpp/qwen-test:0.5b',
defaultLaunchModel: 'llama.cpp/qwen-test:0.5b',
models: [
{
id: 'llama.cpp/qwen-test:0.5b',
launchModel: 'llama.cpp/qwen-test:0.5b',
displayName: 'qwen-test:0.5b',
hidden: false,
supportedReasoningEfforts: [],
defaultReasoningEffort: null,
inputModalities: ['text'],
supportsPersonality: true,
isDefault: true,
upgrade: false,
source: 'app-server',
badgeLabel: null,
metadata: {
opencode: {
providerId: 'llama.cpp',
modelId: 'qwen-test:0.5b',
sourceLabel: 'llama.cpp',
accessKind: 'verified',
routeKind: 'configured_local',
proofState: 'verified',
requiresExecutionProof: false,
reason: null,
},
},
},
],
diagnostics: {
configReadState: 'ready',
appServerState: 'healthy',
},
},
},
],
});
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
await act(async () => {
root.render(React.createElement(CliStatusBanner));
await Promise.resolve();
});
expect(host.textContent).toContain('Free models');
expect(host.textContent).toContain('1 configured local');
expect(host.textContent).toContain('1 verified');
expect(host.textContent).not.toContain('qwen-test:0.5b qwen-test:0.5b');
await act(async () => {
root.unmount();
await Promise.resolve();
});
});
it('shows OpenCode model loading instead of the summary-only big-pickle badge', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
storeState.cliInstallerState = 'idle';

View file

@ -1,5 +1,6 @@
import React, { act } from 'react';
import { createRoot } from 'react-dom/client';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { GlobalTask } from '../../../../src/shared/types';
@ -150,6 +151,14 @@ function flushMicrotasks(): Promise<void> {
return Promise.resolve();
}
function setElectronApiForTest(value: unknown): void {
Object.defineProperty(window, 'electronAPI', {
configurable: true,
writable: true,
value,
});
}
function findButton(host: HTMLElement, label: string): HTMLButtonElement | null {
return (
Array.from(host.querySelectorAll('button')).find(
@ -211,12 +220,14 @@ describe('GlobalTaskList project grouping', () => {
taskLocalState.togglePin.mockClear();
taskLocalState.toggleArchive.mockClear();
taskLocalState.renameTask.mockClear();
setElectronApiForTest(undefined);
localStorage.clear();
localStorage.setItem('sidebarTasksGrouping', 'project');
});
afterEach(() => {
document.body.innerHTML = '';
setElectronApiForTest(undefined);
vi.unstubAllGlobals();
storeListeners.clear();
});
@ -316,6 +327,34 @@ describe('GlobalTaskList project grouping', () => {
});
});
it('marks task cards as offline when alive-list is initialized before teams are loaded', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
const aliveList = vi.fn(() => Promise.resolve([]));
setElectronApiForTest({ teams: { aliveList } });
storeState.globalTasks = [makeTask(1)];
storeState.teams = [];
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
await act(async () => {
root.render(React.createElement(GlobalTaskList));
await flushMicrotasks();
await flushMicrotasks();
});
expect(aliveList).toHaveBeenCalled();
expect(
host.querySelector('[data-testid="sidebar-task-item"]')?.getAttribute('data-team-offline')
).toBe('true');
await act(async () => {
root.unmount();
await flushMicrotasks();
});
});
it('keeps the hard visible limit when new tasks arrive after expansion', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
storeState.globalTasks = Array.from({ length: 10 }, (_, index) => makeTask(index + 1));

View file

@ -377,8 +377,9 @@ describe('TeamModelSelector disabled Codex models', () => {
const groupLabels = Array.from(
host.querySelectorAll('[data-testid="team-model-selector-opencode-group"] h4')
).map((heading) => heading.textContent ?? '');
expect(groupLabels).toContain('OpenCode');
expect(groupLabels).toContain('OpenRouter');
expect(groupLabels).toContain('Other OpenCode catalog');
expect(host.textContent).toContain('OpenCode');
expect(host.textContent).toContain('OpenRouter');
const buttonTexts = Array.from(host.querySelectorAll('button')).map(
(button) => button.textContent ?? ''
@ -2326,6 +2327,280 @@ describe('TeamModelSelector disabled Codex models', () => {
});
});
it('keeps OpenCode runtime models visible when catalog metadata is partial', 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: ['openai/gpt-5.4', 'openrouter/moonshotai/kimi-k2', 'opencode/big-pickle'],
modelCatalog: {
schemaVersion: 1,
providerId: 'opencode',
source: 'app-server',
status: 'ready',
fetchedAt: '2026-05-13T00:00:00.000Z',
staleAt: '2026-05-13T00:10:00.000Z',
defaultModelId: 'opencode/big-pickle',
defaultLaunchModel: 'opencode/big-pickle',
models: [
{
id: 'opencode/big-pickle',
launchModel: 'opencode/big-pickle',
displayName: 'big-pickle',
hidden: false,
supportedReasoningEfforts: [],
defaultReasoningEffort: null,
inputModalities: ['text'],
supportsPersonality: false,
isDefault: true,
upgrade: false,
source: 'app-server',
badgeLabel: 'Free',
metadata: {
free: true,
opencode: {
providerId: 'opencode',
modelId: 'big-pickle',
sourceLabel: 'opencode',
accessKind: 'builtin_free',
routeKind: 'builtin_free',
proofState: 'not_required',
requiresExecutionProof: false,
reason: null,
},
},
},
],
diagnostics: {
configReadState: 'ready',
appServerState: 'healthy',
message: null,
code: null,
},
},
},
],
};
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
await act(async () => {
root.render(
React.createElement(TeamModelSelector, {
providerId: 'opencode',
onProviderChange: () => undefined,
value: '',
onValueChange: () => undefined,
})
);
await Promise.resolve();
});
expect(host.textContent).toContain('big-pickle');
expect(host.textContent).toContain('GPT-5.4');
expect(host.textContent).toContain('moonshotai/kimi-k2');
expect(host.textContent).toContain('OpenAI');
expect(host.textContent).toContain('OpenRouter');
expect(host.textContent).toContain('Free built-in');
expect(host.textContent).toContain('Other OpenCode catalog');
await act(async () => {
root.unmount();
await Promise.resolve();
});
});
it('groups OpenCode catalog routes by configured, free, connected, and other sources', 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: [
'llama.cpp/qwen-test:0.5b',
'opencode/big-pickle',
'openrouter/moonshotai/kimi-k2',
'deepseek/deepseek-chat',
],
modelCatalog: {
schemaVersion: 1,
providerId: 'opencode',
source: 'app-server',
status: 'ready',
fetchedAt: '2026-05-13T00:00:00.000Z',
staleAt: '2026-05-13T00:10:00.000Z',
defaultModelId: 'llama.cpp/qwen-test:0.5b',
defaultLaunchModel: 'llama.cpp/qwen-test:0.5b',
models: [
{
id: 'llama.cpp/qwen-test:0.5b',
launchModel: 'llama.cpp/qwen-test:0.5b',
displayName: 'qwen-test:0.5b',
hidden: false,
supportedReasoningEfforts: [],
defaultReasoningEffort: null,
inputModalities: ['text'],
supportsPersonality: false,
isDefault: true,
upgrade: false,
source: 'app-server',
metadata: {
free: false,
opencode: {
providerId: 'llama.cpp',
modelId: 'qwen-test:0.5b',
sourceLabel: 'llama.cpp',
accessKind: 'configured_authless',
routeKind: 'configured_local',
proofState: 'needs_probe',
requiresExecutionProof: true,
reason: 'Execution proof required',
},
},
},
{
id: 'opencode/big-pickle',
launchModel: 'opencode/big-pickle',
displayName: 'big-pickle',
hidden: false,
supportedReasoningEfforts: [],
defaultReasoningEffort: null,
inputModalities: ['text'],
supportsPersonality: false,
isDefault: false,
upgrade: false,
source: 'app-server',
metadata: {
free: true,
opencode: {
providerId: 'opencode',
modelId: 'big-pickle',
sourceLabel: 'opencode',
accessKind: 'builtin_free',
routeKind: 'builtin_free',
proofState: 'not_required',
requiresExecutionProof: false,
reason: null,
},
},
},
{
id: 'openrouter/moonshotai/kimi-k2',
launchModel: 'openrouter/moonshotai/kimi-k2',
displayName: 'moonshotai/kimi-k2',
hidden: false,
supportedReasoningEfforts: [],
defaultReasoningEffort: null,
inputModalities: ['text'],
supportsPersonality: false,
isDefault: false,
upgrade: false,
source: 'app-server',
metadata: {
free: false,
opencode: {
providerId: 'openrouter',
modelId: 'moonshotai/kimi-k2',
sourceLabel: 'OpenRouter',
accessKind: 'credentialed',
routeKind: 'connected_provider',
proofState: 'not_required',
requiresExecutionProof: false,
reason: null,
},
},
},
{
id: 'deepseek/deepseek-chat',
launchModel: 'deepseek/deepseek-chat',
displayName: 'deepseek-chat',
hidden: false,
supportedReasoningEfforts: [],
defaultReasoningEffort: null,
inputModalities: ['text'],
supportsPersonality: false,
isDefault: false,
upgrade: false,
source: 'app-server',
metadata: {
free: false,
opencode: {
providerId: 'deepseek',
modelId: 'deepseek-chat',
sourceLabel: 'DeepSeek',
accessKind: 'not_authenticated',
routeKind: 'catalog_provider',
proofState: 'not_required',
requiresExecutionProof: false,
reason: 'Provider is not connected',
},
},
},
],
diagnostics: {
configReadState: 'ready',
appServerState: 'healthy',
message: null,
code: null,
},
},
},
],
};
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
await act(async () => {
root.render(
React.createElement(TeamModelSelector, {
providerId: 'opencode',
onProviderChange: () => undefined,
value: '',
onValueChange: () => undefined,
})
);
await Promise.resolve();
});
expect(host.textContent).toContain('OpenCode config');
expect(host.textContent).toContain('Free built-in');
expect(host.textContent).toContain('Connected providers');
expect(host.textContent).toContain('Other OpenCode catalog');
expect(host.textContent).toContain('Local');
expect(host.textContent).toContain('Needs test');
expect(host.textContent).toContain('Connected');
const filterButton = host.querySelector<HTMLElement>(
'[data-testid="team-model-selector-opencode-provider-filter"]'
);
expect(filterButton?.getAttribute('aria-label')).toBe('Filter OpenCode sources');
expect(filterButton?.textContent).toContain('All OpenCode sources');
await act(async () => {
root.unmount();
await Promise.resolve();
});
});
it('filters OpenCode model groups by selected source providers', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
storeState.cliStatus = {

View file

@ -29,6 +29,7 @@ vi.mock('@renderer/components/team/members/MembersEditorSection', () => ({
providerId?: string;
model?: string;
effort?: string;
mcpPolicy?: { mode: 'appOnly' };
}>;
onChange: (
members: Array<{
@ -40,6 +41,7 @@ vi.mock('@renderer/components/team/members/MembersEditorSection', () => ({
providerId?: string;
model?: string;
effort?: string;
mcpPolicy?: { mode: 'appOnly' };
}>
) => void;
fieldError?: string;
@ -115,6 +117,20 @@ vi.mock('@renderer/components/team/members/MembersEditorSection', () => ({
},
'change-member-runtime'
),
React.createElement(
'button',
{
type: 'button',
'data-testid': 'change-member-mcp-policy',
onClick: () =>
onChange(
members.map((member, index) =>
index === 0 ? { ...member, mcpPolicy: { mode: 'appOnly' } } : member
)
),
},
'change-member-mcp-policy'
),
React.createElement(
'button',
{
@ -181,6 +197,7 @@ vi.mock('@renderer/components/team/members/MembersEditorSection', () => ({
providerId?: string;
model?: string;
effort?: string;
mcpPolicy?: { mode: 'appOnly' };
}>
).map((member) => ({
name: member.name,
@ -193,6 +210,7 @@ vi.mock('@renderer/components/team/members/MembersEditorSection', () => ({
providerId: member.providerId,
model: member.model,
effort: member.effort,
mcpPolicy: member.mcpPolicy,
}))
),
createMemberDraftsFromInputs: vi.fn((members) =>
@ -203,6 +221,7 @@ vi.mock('@renderer/components/team/members/MembersEditorSection', () => ({
providerId?: string;
model?: string;
effort?: string;
mcpPolicy?: { mode: 'appOnly' };
}>
).map((member, index) => ({
id: `draft-${index}`,
@ -221,6 +240,7 @@ vi.mock('@renderer/components/team/members/MembersEditorSection', () => ({
providerId: member.providerId,
model: member.model,
effort: member.effort,
mcpPolicy: member.mcpPolicy,
}))
),
createMemberDraft: vi.fn((member) => member),
@ -822,6 +842,69 @@ describe('EditTeamDialog', () => {
});
});
it('restarts an existing live teammate when MCP policy changes', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
vi.mocked(api.teams.updateConfig).mockResolvedValue({} as any);
vi.mocked(api.teams.replaceMembers).mockResolvedValue(undefined);
vi.mocked(api.teams.restartMember).mockResolvedValue(undefined);
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
await act(async () => {
root.render(
React.createElement(EditTeamDialog, {
open: true,
teamName: 'live-team',
currentName: 'Current Team',
currentDescription: 'desc',
currentColor: 'blue',
currentMembers: [{ name: 'alice', role: 'Developer', providerId: 'codex' }] as any,
isTeamAlive: true,
projectPath: '/tmp/project',
onClose: vi.fn(),
onChangeLeadRuntime: vi.fn(),
onSaved: vi.fn(),
})
);
await Promise.resolve();
});
await act(async () => {
host
.querySelector('[data-testid="change-member-mcp-policy"]')
?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
await Promise.resolve();
});
expect(host.textContent).toContain('MCP access changes');
const saveButton = Array.from(host.querySelectorAll('button')).find(
(button) => button.textContent === 'Save'
);
await act(async () => {
saveButton?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
await Promise.resolve();
});
expect(api.teams.replaceMembers).toHaveBeenCalledWith('live-team', {
members: [
expect.objectContaining({
name: 'alice',
mcpPolicy: { mode: 'appOnly' },
}),
],
});
expect(api.teams.restartMember).toHaveBeenCalledWith('live-team', 'alice');
await act(async () => {
root.unmount();
await Promise.resolve();
});
});
it('does not call generic restart for live OpenCode teammate edits handled by replaceMembers', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
vi.mocked(api.teams.updateConfig).mockResolvedValue({} as any);

View file

@ -1,8 +1,14 @@
import React, { act } from 'react';
import { createRoot } from 'react-dom/client';
import { afterEach, describe, expect, it, vi } from 'vitest';
import type { MemberSpawnStatusEntry, ResolvedTeamMember, TeamTaskWithKanban } from '@shared/types';
import type {
MemberSpawnStatusEntry,
ResolvedTeamMember,
TeamAgentRuntimeEntry,
TeamTaskWithKanban,
} from '@shared/types';
const hoisted = vi.hoisted(() => ({
openExternal: vi.fn(),
@ -429,6 +435,45 @@ describe('MemberCard starting-state visuals', () => {
});
});
it('shows a restore action for removed teammates', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
const onRestoreMember = vi.fn();
await act(async () => {
root.render(
React.createElement(MemberCard, {
member: {
...member,
removedAt: Date.now(),
},
memberColor: 'blue',
isRemoved: true,
onRestoreMember,
})
);
await Promise.resolve();
});
expect(host.textContent).toContain('removed');
const restoreButton = host.querySelector('button[aria-label="Restore teammate"]');
expect(restoreButton).not.toBeNull();
await act(async () => {
(restoreButton as HTMLButtonElement).click();
await Promise.resolve();
});
expect(onRestoreMember).toHaveBeenCalledWith('alice');
await act(async () => {
root.unmount();
await Promise.resolve();
});
});
it('keeps runtime-pending launch status visible even when the teammate has an active task', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
const host = document.createElement('div');
@ -859,7 +904,7 @@ describe('MemberCard starting-state visuals', () => {
pid: 222,
resourceHistory: 'not-an-array',
updatedAt: '2026-04-24T12:00:05.000Z',
} as any,
} as unknown as TeamAgentRuntimeEntry,
isTeamAlive: true,
isTeamProvisioning: false,
})
@ -908,7 +953,7 @@ describe('MemberCard starting-state visuals', () => {
},
],
updatedAt: '2026-04-24T12:00:05.000Z',
} as any,
} as unknown as TeamAgentRuntimeEntry,
isTeamAlive: true,
isTeamProvisioning: false,
})

View file

@ -1,5 +1,6 @@
import React, { act } from 'react';
import { createRoot } from 'react-dom/client';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { MemberSpawnStatusEntry, ResolvedTeamMember, TeamTaskWithKanban } from '@shared/types';
@ -14,6 +15,8 @@ vi.mock('@renderer/components/team/members/MemberCard', () => ({
reviewTask,
onRestartMember,
onSkipMemberForLaunch,
onRestoreMember,
isRemoved,
}: {
member: ResolvedTeamMember;
spawnError?: string;
@ -23,6 +26,8 @@ vi.mock('@renderer/components/team/members/MemberCard', () => ({
reviewTask?: TeamTaskWithKanban | null;
onRestartMember?: (memberName: string) => void;
onSkipMemberForLaunch?: (memberName: string) => void;
onRestoreMember?: (memberName: string) => void;
isRemoved?: boolean;
}) =>
React.createElement(
'div',
@ -55,6 +60,17 @@ vi.mock('@renderer/components/team/members/MemberCard', () => ({
},
'skip'
)
: null,
onRestoreMember && isRemoved
? React.createElement(
'button',
{
'data-testid': `restore-${member.name}`,
type: 'button',
onClick: () => onRestoreMember(member.name),
},
'restore'
)
: null
),
}));
@ -379,6 +395,64 @@ describe('MemberList spawn-status memoization', () => {
});
});
it('passes restore callbacks to removed member cards and rerenders when the callback changes', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
const members: ResolvedTeamMember[] = [{ ...member, removedAt: 1715000000000 }];
const firstRestore = vi.fn();
const secondRestore = vi.fn();
await act(async () => {
root.render(
React.createElement(MemberList, {
members,
isTeamAlive: false,
onRestoreMember: firstRestore,
})
);
await Promise.resolve();
});
const firstButton = host.querySelector('[data-testid="restore-bob"]') as HTMLButtonElement;
expect(firstButton).not.toBeNull();
await act(async () => {
firstButton.click();
await Promise.resolve();
});
expect(firstRestore).toHaveBeenCalledWith('bob');
await act(async () => {
root.render(
React.createElement(MemberList, {
members,
isTeamAlive: false,
onRestoreMember: secondRestore,
})
);
await Promise.resolve();
});
const secondButton = host.querySelector('[data-testid="restore-bob"]') as HTMLButtonElement;
expect(secondButton).not.toBeNull();
await act(async () => {
secondButton.click();
await Promise.resolve();
});
expect(secondRestore).toHaveBeenCalledWith('bob');
expect(firstRestore).toHaveBeenCalledTimes(1);
await act(async () => {
root.unmount();
await Promise.resolve();
});
});
it('shows a review task when a stale currentTaskId points at the same non-active task', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
const host = document.createElement('div');

View file

@ -1,8 +1,8 @@
import React, { act } from 'react';
import { createRoot } from 'react-dom/client';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { GraphActivityHud } from '@features/agent-graph/renderer/ui/GraphActivityHud';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { GraphNode } from '@claude-teams/agent-graph';
import type { InboxMessage } from '@shared/types/team';
@ -358,6 +358,91 @@ describe('GraphActivityHud', () => {
expect(shell).not.toBeNull();
expect((shell as HTMLDivElement).style.left).toBe(`${laneRect.left}px`);
expect((shell as HTMLDivElement).style.top).toBe(`${laneRect.top}px`);
expect(host.querySelector('[data-activity-connector="member:demo-team:jack"]')).not.toBeNull();
await act(async () => {
root.unmount();
await Promise.resolve();
});
});
it('hides owner-to-activity connector when static graph edges are hidden', async () => {
const message: InboxMessage = {
from: 'team-lead',
to: 'jack',
text: 'Latest log',
summary: 'Latest log',
timestamp: '2026-04-13T13:36:00.000Z',
read: false,
messageId: 'msg-latest',
};
buildInlineActivityEntries.mockReturnValue(
new Map([
[
'member:demo-team:jack',
[
{
ownerNodeId: 'member:demo-team:jack',
graphItem: {
id: 'item-1',
kind: 'inbox_message',
timestamp: message.timestamp,
title: message.summary ?? '',
},
message,
},
],
],
])
);
const node: GraphNode = {
id: 'member:demo-team:jack',
kind: 'member',
label: 'jack',
state: 'active',
domainRef: { kind: 'member', teamName: 'demo-team', memberName: 'jack' },
activityItems: [
{
id: 'item-1',
kind: 'inbox_message',
timestamp: message.timestamp,
title: 'Latest log',
},
],
activityOverflowCount: 0,
};
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
await act(async () => {
root.render(
React.createElement(GraphActivityHud, {
teamName: 'demo-team',
nodes: [node],
getActivityWorldRect: () => ({
left: 120,
top: 340,
right: 416,
bottom: 632,
width: 296,
height: 292,
}),
getCameraZoom: () => 1,
getNodeWorldPosition: () => ({ x: 320, y: 300 }),
getViewportSize: () => ({ width: 1200, height: 800 }),
worldToScreen: (x: number, y: number) => ({ x, y }),
focusNodeIds: null,
showConnectors: false,
})
);
await Promise.resolve();
});
expect(host.querySelector('.z-10')).not.toBeNull();
expect(host.querySelector('[data-activity-connector="member:demo-team:jack"]')).toBeNull();
await act(async () => {
root.unmount();

View file

@ -1,11 +1,12 @@
import React, { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { GraphEdge, GraphNode } from '@claude-teams/agent-graph';
import { getEdgeMidpoint } from '../../../../packages/agent-graph/src/canvas/hit-detection';
import type { GraphEdge, GraphNode } from '@claude-teams/agent-graph';
const hoisted = vi.hoisted(() => ({
handlePanStart: vi.fn(),
handlePanMove: vi.fn(),
@ -163,6 +164,42 @@ describe('GraphView pan interactions', () => {
vi.unstubAllGlobals();
});
it('starts with static edges hidden by default', async () => {
await act(async () => {
root.render(
React.createElement(GraphView, {
data: {
teamName: 'demo-team',
nodes: [],
edges: [],
particles: [],
},
config: { animationEnabled: false },
})
);
});
expect((hoisted.graphControlsProps?.filters as { showEdges: boolean }).showEdges).toBe(false);
});
it('can opt into showing static edges through config', async () => {
await act(async () => {
root.render(
React.createElement(GraphView, {
data: {
teamName: 'demo-team',
nodes: [],
edges: [],
particles: [],
},
config: { animationEnabled: false, showEdges: true },
})
);
});
expect((hoisted.graphControlsProps?.filters as { showEdges: boolean }).showEdges).toBe(true);
});
it('starts panning when dragging from a hit-tested edge instead of getting stuck on edge selection', async () => {
const source: GraphNode = {
id: 'member:alice',

View file

@ -0,0 +1,66 @@
import { describe, expect, it } from 'vitest';
import { filterVisibleGraphEdges } from '../../../../packages/agent-graph/src/ui/GraphView';
import type { GraphEdge } from '@claude-teams/agent-graph';
const parentEdge: GraphEdge = {
id: 'edge:parent:lead:alice',
source: 'lead:team',
target: 'member:team:alice',
type: 'parent-child',
};
const ownershipEdge: GraphEdge = {
id: 'edge:own:alice:task-1',
source: 'member:team:alice',
target: 'task:team:task-1',
type: 'ownership',
};
const messageEdge: GraphEdge = {
id: 'edge:msg:member:team:alice:member:team:bob',
source: 'member:team:alice',
target: 'member:team:bob',
type: 'message',
};
describe('filterVisibleGraphEdges', () => {
it('keeps only active routes when static edges are hidden', () => {
const visibleNodeIds = new Set([
'lead:team',
'member:team:alice',
'member:team:bob',
'task:team:task-1',
]);
expect(
filterVisibleGraphEdges(
[parentEdge, ownershipEdge, messageEdge],
visibleNodeIds,
false,
new Set([parentEdge.id, messageEdge.id])
).map((edge) => edge.id)
).toEqual([parentEdge.id, messageEdge.id]);
});
it('hides all idle routes when static edges are hidden', () => {
const visibleNodeIds = new Set(['lead:team', 'member:team:alice', 'member:team:bob']);
expect(
filterVisibleGraphEdges([parentEdge, messageEdge], visibleNodeIds, false).map(
(edge) => edge.id
)
).toEqual([]);
});
it('keeps static routes when edges are enabled', () => {
const visibleNodeIds = new Set(['lead:team', 'member:team:alice', 'member:team:bob']);
expect(
filterVisibleGraphEdges([parentEdge, messageEdge], visibleNodeIds, true).map(
(edge) => edge.id
)
).toEqual([parentEdge.id, messageEdge.id]);
});
});

View file

@ -383,6 +383,87 @@ describe('TeamGraphAdapter particles', () => {
});
});
it('creates a message edge and correctly directed particles for teammate-to-teammate messages', () => {
const adapter = TeamGraphAdapter.create();
adapter.adapt(createBaseTeamData(), 'my-team');
const graph = adapter.adapt(
createBaseTeamData({
messages: [
{
from: 'alice',
to: 'bob',
text: 'Can you review this handoff?',
timestamp: '2026-03-28T19:00:01.000Z',
read: false,
messageId: 'msg-alice-bob',
},
{
from: 'bob',
to: 'alice',
text: 'Review done, sending notes back',
timestamp: '2026-03-28T19:00:02.000Z',
read: false,
messageId: 'msg-bob-alice',
},
],
}),
'my-team'
);
const messageEdges = graph.edges.filter((edge) => edge.type === 'message');
expect(messageEdges).toEqual([
expect.objectContaining({
id: 'edge:msg:member:my-team:alice:member:my-team:bob',
source: 'member:my-team:alice',
target: 'member:my-team:bob',
}),
]);
expect(
graph.particles.find((particle) => particle.id.endsWith(':msg-alice-bob'))
).toMatchObject({
edgeId: 'edge:msg:member:my-team:alice:member:my-team:bob',
reverse: false,
});
expect(
graph.particles.find((particle) => particle.id.endsWith(':msg-bob-alice'))
).toMatchObject({
edgeId: 'edge:msg:member:my-team:alice:member:my-team:bob',
reverse: true,
});
});
it('keeps teammate-to-teammate message edges in the initial graph snapshot without replaying old particles', () => {
const adapter = TeamGraphAdapter.create();
const graph = adapter.adapt(
createBaseTeamData({
messages: [
{
from: 'alice',
to: 'bob',
text: 'Earlier handoff that should remain visible',
timestamp: '2026-03-28T18:59:59.000Z',
read: true,
messageId: 'msg-existing-alice-bob',
},
],
}),
'my-team'
);
expect(graph.particles).toEqual([]);
expect(graph.edges).toContainEqual(
expect.objectContaining({
id: 'edge:msg:member:my-team:alice:member:my-team:bob',
source: 'member:my-team:alice',
target: 'member:my-team:bob',
type: 'message',
})
);
});
it('creates a comment particle for the first new task comment with preview text', () => {
const adapter = TeamGraphAdapter.create();
const baseline = createBaseTeamData({
@ -696,7 +777,7 @@ describe('TeamGraphAdapter particles', () => {
expect(graph.particles).toHaveLength(0);
});
it('creates a synthetic message edge for comments from non-owner participants', () => {
it('routes comments from non-owner participants through a participant message edge', () => {
const adapter = TeamGraphAdapter.create();
const baseline = createBaseTeamData({
tasks: [
@ -740,13 +821,54 @@ describe('TeamGraphAdapter particles', () => {
expect(graph.particles).toHaveLength(1);
expect(graph.particles[0]).toMatchObject({
kind: 'task_comment',
edgeId: 'edge:msg:member:my-team:alice:member:my-team:bob',
label: '💬 I found the root cause, handing notes over now',
reverse: false,
});
expect(
graph.edges.some((edge) => edge.id === 'edge:msg:member:my-team:alice:task:my-team:task-2')
graph.edges.some((edge) => edge.id === 'edge:msg:member:my-team:alice:member:my-team:bob')
).toBe(true);
});
it('keeps existing cross-participant comment edges without replaying old comment particles', () => {
const adapter = TeamGraphAdapter.create();
const graph = adapter.adapt(
createBaseTeamData({
tasks: [
{
id: 'task-existing-comment',
displayId: '#12',
subject: 'Existing review',
owner: 'bob',
status: 'in_progress',
comments: [
{
id: 'comment-existing',
author: 'alice',
text: 'Existing comment visible as participant traffic',
createdAt: '2026-03-28T18:59:59.000Z',
type: 'regular',
},
],
reviewState: 'none',
} as TeamTaskWithKanban,
],
}),
'my-team'
);
expect(graph.particles).toEqual([]);
expect(graph.edges).toContainEqual(
expect.objectContaining({
id: 'edge:msg:member:my-team:alice:member:my-team:bob',
source: 'member:my-team:alice',
target: 'member:my-team:bob',
type: 'message',
})
);
});
it('does not collapse two new inbox particles that share a timestamp but differ in content', () => {
const adapter = TeamGraphAdapter.create();
adapter.adapt(createBaseTeamData(), 'my-team');

View file

@ -0,0 +1,92 @@
import { describe, expect, it, vi } from 'vitest';
import { drawEdges } from '../../../../packages/agent-graph/src/canvas/draw-edges';
import type { GraphEdge, GraphNode } from '@claude-teams/agent-graph';
function createMockContext(): CanvasRenderingContext2D {
let fillStyle: string | CanvasGradient | CanvasPattern = '';
let strokeStyle: string | CanvasGradient | CanvasPattern = '';
let globalAlpha = 1;
return {
save: vi.fn(),
restore: vi.fn(),
beginPath: vi.fn(),
closePath: vi.fn(),
moveTo: vi.fn(),
lineTo: vi.fn(),
bezierCurveTo: vi.fn(),
fill: vi.fn(),
stroke: vi.fn(),
setLineDash: vi.fn(),
shadowColor: '',
shadowBlur: 0,
lineWidth: 1,
get fillStyle() {
return fillStyle;
},
set fillStyle(value: string | CanvasGradient | CanvasPattern) {
fillStyle = value;
},
get strokeStyle() {
return strokeStyle;
},
set strokeStyle(value: string | CanvasGradient | CanvasPattern) {
strokeStyle = value;
},
get globalAlpha() {
return globalAlpha;
},
set globalAlpha(value: number) {
globalAlpha = value;
},
} as unknown as CanvasRenderingContext2D;
}
function createNode(id: string, x: number, y: number): GraphNode {
return {
id,
kind: 'member',
label: id,
state: 'active',
x,
y,
domainRef: { kind: 'member', teamName: 'team', memberName: id },
};
}
const messageEdge: GraphEdge = {
id: 'edge:msg:member:team:alice:member:team:bob',
source: 'member:team:alice',
target: 'member:team:bob',
type: 'message',
};
describe('drawEdges', () => {
it('does not draw idle message edges', () => {
const ctx = createMockContext();
const nodeMap = new Map([
[messageEdge.source, createNode(messageEdge.source, 0, 0)],
[messageEdge.target, createNode(messageEdge.target, 100, 0)],
]);
drawEdges(ctx, [messageEdge], nodeMap, 0, new Set());
expect(ctx.beginPath).not.toHaveBeenCalled();
expect(ctx.fill).not.toHaveBeenCalled();
});
it('draws message edges while a particle is active on them', () => {
const ctx = createMockContext();
const nodeMap = new Map([
[messageEdge.source, createNode(messageEdge.source, 0, 0)],
[messageEdge.target, createNode(messageEdge.target, 100, 0)],
]);
drawEdges(ctx, [messageEdge], nodeMap, 0, new Set([messageEdge.id]));
expect(ctx.beginPath).toHaveBeenCalled();
expect(ctx.fill).toHaveBeenCalled();
});
});

View file

@ -1,5 +1,6 @@
import React, { act } from 'react';
import { createRoot } from 'react-dom/client';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { RuntimeProviderManagementPanelView } from '../../../../src/features/runtime-provider-management/renderer/ui/RuntimeProviderManagementPanelView';
@ -155,6 +156,173 @@ describe('RuntimeProviderManagementPanelView', () => {
expect(refreshButton?.disabled).toBe(true);
});
it('renders configured OpenCode model routes with local proof actions', async () => {
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
const actions = createActions();
const configuredModel = {
providerId: 'llama.cpp',
modelId: 'llama.cpp/qwen-test:0.5b',
displayName: 'qwen-test:0.5b',
sourceLabel: 'llama.cpp',
free: false,
default: false,
availability: 'untested' as const,
accessKind: 'configured_authless' as const,
routeKind: 'configured_local' as const,
proofState: 'needs_probe' as const,
requiresExecutionProof: true,
accessReason: 'Execution proof required',
};
await act(async () => {
root.render(
React.createElement(RuntimeProviderManagementPanelView, {
state: createState({
view: {
...createState().view!,
configuredModels: [configuredModel],
},
selectedModelId: 'llama.cpp/qwen-test:0.5b',
}),
actions,
disabled: false,
})
);
await Promise.resolve();
});
const row = host.querySelector<HTMLElement>(
'[data-testid="configured-opencode-model-row-llama.cpp/qwen-test:0.5b"]'
);
expect(host.textContent).toContain('Configured OpenCode models');
expect(row?.textContent).toContain('local');
expect(row?.textContent).toContain('configured');
expect(row?.textContent).toContain('needs test');
const buttons = Array.from(row?.querySelectorAll('button') ?? []);
await act(async () => {
buttons.find((button) => button.textContent?.includes('Test'))?.click();
await Promise.resolve();
});
await act(async () => {
buttons.find((button) => button.textContent?.includes('Use for new teams'))?.click();
await Promise.resolve();
});
await act(async () => {
buttons.find((button) => button.textContent?.includes('Set OpenCode default'))?.click();
await Promise.resolve();
});
expect(actions.testModel).toHaveBeenCalledWith(
'llama.cpp',
'llama.cpp/qwen-test:0.5b'
);
expect(actions.useModelForNewTeams).toHaveBeenCalledWith('llama.cpp/qwen-test:0.5b');
expect(actions.setDefaultModel).toHaveBeenCalledWith(
'llama.cpp',
'llama.cpp/qwen-test:0.5b'
);
});
it('shows unknown OpenCode defaults without enabling launch actions', async () => {
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
const actions = createActions();
const unknownDefaultModel = {
providerId: 'openrouter',
modelId: 'openrouter/moonshotai/kimi-k2',
displayName: 'moonshotai/kimi-k2',
sourceLabel: 'OpenRouter',
free: false,
default: true,
availability: 'untested' as const,
accessKind: 'unknown_model' as const,
routeKind: 'catalog_provider' as const,
proofState: 'not_required' as const,
requiresExecutionProof: false,
accessReason: 'Model was not found in the live catalog',
};
await act(async () => {
root.render(
React.createElement(RuntimeProviderManagementPanelView, {
state: createState({
view: {
...createState().view!,
configuredModels: [unknownDefaultModel],
},
}),
actions,
disabled: false,
})
);
await Promise.resolve();
});
const row = host.querySelector<HTMLElement>(
'[data-testid="configured-opencode-model-row-openrouter/moonshotai/kimi-k2"]'
);
expect(row?.textContent).toContain('unknown');
expect(row?.textContent).toContain('default');
const buttons = Array.from(row?.querySelectorAll('button') ?? []);
expect(buttons.map((button) => button.disabled)).toEqual([true, true, true]);
await act(async () => {
buttons.forEach((button) => button.click());
await Promise.resolve();
});
expect(actions.testModel).not.toHaveBeenCalled();
expect(actions.useModelForNewTeams).not.toHaveBeenCalled();
expect(actions.setDefaultModel).not.toHaveBeenCalled();
});
it('renders duplicate runtime diagnostics without React key warnings', async () => {
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
const actions = createActions();
const baseState = createState();
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined);
await act(async () => {
root.render(
React.createElement(RuntimeProviderManagementPanelView, {
state: createState({
view: {
...baseState.view!,
diagnostics: [
'Unable to connect. Is the computer able to access the url?',
'Unable to connect. Is the computer able to access the url?',
],
},
providers: baseState.view?.providers ?? [],
}),
actions,
disabled: false,
})
);
await Promise.resolve();
});
const duplicateDiagnostics = host.textContent?.match(
/Unable to connect\. Is the computer able to access the url\?/g
);
const duplicateKeyWarnings = consoleError.mock.calls.filter((call) =>
call.some(
(argument) =>
typeof argument === 'string' &&
argument.includes('Encountered two children with the same key')
)
);
consoleError.mockRestore();
expect(duplicateDiagnostics).toHaveLength(2);
expect(duplicateKeyWarnings).toHaveLength(0);
});
it('renders provider actions and opens API-key form state without exposing a raw secret', async () => {
const host = document.createElement('div');
document.body.appendChild(host);
@ -517,6 +685,7 @@ describe('RuntimeProviderManagementPanelView', () => {
hasKnownModels: true,
requiresManualConfig: false,
supportedInlineAuth: false,
configuredAuthless: false,
},
},
{
@ -547,6 +716,7 @@ describe('RuntimeProviderManagementPanelView', () => {
hasKnownModels: true,
requiresManualConfig: false,
supportedInlineAuth: true,
configuredAuthless: false,
},
},
],
@ -685,6 +855,7 @@ describe('RuntimeProviderManagementPanelView', () => {
hasKnownModels: true,
requiresManualConfig: true,
supportedInlineAuth: true,
configuredAuthless: false,
},
},
],
@ -707,6 +878,72 @@ describe('RuntimeProviderManagementPanelView', () => {
expect(actionLabels).toContain('Configure manually');
});
it('opens model list for configured authless local directory providers', async () => {
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
const actions = createActions();
await act(async () => {
root.render(
React.createElement(RuntimeProviderManagementPanelView, {
state: createState({
directoryLoaded: true,
directoryTotalCount: 1,
directoryEntries: [
{
providerId: 'llama.cpp',
displayName: 'llama.cpp',
state: 'available',
setupKind: 'available-readonly',
ownership: [],
recommended: false,
modelCount: 1,
defaultModelId: null,
authMethods: [],
actions: [
{
id: 'test',
label: 'Test',
enabled: true,
disabledReason: null,
requiresSecret: false,
ownershipScope: 'runtime',
},
],
sources: ['config-provider'],
sourceLabel: 'configured',
providerSource: null,
detail: 'Configured local OpenCode model route is available',
metadata: {
hasKnownModels: true,
requiresManualConfig: false,
supportedInlineAuth: false,
configuredAuthless: true,
},
},
],
}),
actions,
disabled: false,
})
);
await Promise.resolve();
});
const row = host.querySelector<HTMLElement>(
'[data-testid="runtime-provider-directory-row-llama.cpp"]'
);
expect(row?.textContent).toContain('Configured local');
await act(async () => {
row?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
await Promise.resolve();
});
expect(actions.selectDirectoryProvider).toHaveBeenCalledWith('llama.cpp');
});
it('uses the unified provider search when compact search has no matches', async () => {
const host = document.createElement('div');
document.body.appendChild(host);
@ -743,6 +980,7 @@ describe('RuntimeProviderManagementPanelView', () => {
hasKnownModels: true,
requiresManualConfig: false,
supportedInlineAuth: false,
configuredAuthless: false,
},
},
],
@ -1131,6 +1369,7 @@ describe('RuntimeProviderManagementPanelView', () => {
hasKnownModels: true,
requiresManualConfig: false,
supportedInlineAuth: true,
configuredAuthless: false,
},
};
const state = createState({

View file

@ -1,24 +1,25 @@
import React, { act } from 'react';
import { createRoot } from 'react-dom/client';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
useRuntimeProviderManagement,
type RuntimeProviderManagementActions,
type RuntimeProviderManagementState,
useRuntimeProviderManagement,
} from '../../../../src/features/runtime-provider-management/renderer/hooks/useRuntimeProviderManagement';
import {
getStoredCreateTeamModel,
getStoredCreateTeamProvider,
} from '../../../../src/renderer/services/createTeamPreferences';
import type { ElectronAPI } from '../../../../src/shared/types/api';
import type {
RuntimeProviderConnectionDto,
RuntimeProviderDirectoryEntryDto,
RuntimeProviderManagementModelTestResponse,
RuntimeProviderManagementViewDto,
} from '../../../../src/features/runtime-provider-management/contracts';
import type { ElectronAPI } from '../../../../src/shared/types/api';
function installRuntimeProviderManagementApi(
response: RuntimeProviderManagementModelTestResponse
@ -79,6 +80,7 @@ function createOpenAiLocalDirectoryEntry(): RuntimeProviderDirectoryEntryDto {
hasKnownModels: true,
requiresManualConfig: false,
supportedInlineAuth: false,
configuredAuthless: false,
},
};
}
@ -613,6 +615,7 @@ describe('useRuntimeProviderManagement', () => {
hasKnownModels: true,
requiresManualConfig: false,
supportedInlineAuth: false,
configuredAuthless: false,
},
},
],
@ -696,6 +699,7 @@ describe('useRuntimeProviderManagement', () => {
hasKnownModels: true,
requiresManualConfig: false,
supportedInlineAuth: true,
configuredAuthless: false,
},
},
],
@ -1229,4 +1233,73 @@ describe('useRuntimeProviderManagement', () => {
expect(state?.modelResults[modelId]?.ok).toBe(true);
expect(state?.modelResults[modelId]?.message).toBe('Model probe passed');
});
it('keeps a successful set-default probe visible as verified model state', async () => {
const modelId = 'llama.cpp/qwen-test:0.5b';
const setDefaultModel = vi.fn(() =>
Promise.resolve({
schemaVersion: 1,
runtimeId: 'opencode',
view: {
...createRuntimeView(),
defaultModel: modelId,
configuredModels: [
{
providerId: 'llama.cpp',
modelId,
displayName: 'qwen-test:0.5b',
sourceLabel: 'llama.cpp',
free: false,
default: true,
availability: 'untested',
accessKind: 'configured_authless',
routeKind: 'configured_local',
proofState: 'needs_probe',
requiresExecutionProof: true,
accessReason: 'Execution proof required',
},
],
},
})
);
Object.defineProperty(window, 'electronAPI', {
configurable: true,
value: {
runtimeProviderManagement: {
setDefaultModel,
},
} as unknown as ElectronAPI,
});
const root = createRoot(host);
await act(async () => {
root.render(React.createElement(Harness));
await Promise.resolve();
});
await act(async () => {
await actions?.setDefaultModel('llama.cpp', modelId);
});
expect(setDefaultModel).toHaveBeenCalledWith({
runtimeId: 'opencode',
providerId: 'llama.cpp',
modelId,
probe: true,
projectPath: null,
});
expect(state?.view?.configuredModels?.[0]).toMatchObject({
modelId,
default: true,
availability: 'available',
accessKind: 'verified',
proofState: 'verified',
requiresExecutionProof: false,
});
expect(state?.modelResults[modelId]).toMatchObject({
ok: true,
availability: 'available',
message: 'Model probe passed',
});
});
});

View file

@ -1,6 +1,5 @@
import { describe, expect, it } from 'vitest';
import { isTeamListStatusRunning, resolveTeamStatus } from '@renderer/utils/teamListStatus';
import { describe, expect, it } from 'vitest';
import type { TeamProvisioningProgress, TeamSummary } from '@shared/types';
@ -87,6 +86,19 @@ describe('team list status', () => {
).toBe('offline');
});
it('does not let stale aliveList data override stopped runtime progress', () => {
expect(
resolveTeamStatus(
team(),
'atlas-hq-10',
['atlas-hq-10'],
progress('disconnected', '2026-04-28T19:59:45.000Z'),
{},
nowMs
)
).toBe('offline');
});
it('expires optimistic ready state if aliveList still does not report the team alive', () => {
expect(
resolveTeamStatus(

View file

@ -1,6 +1,5 @@
import { describe, expect, it } from 'vitest';
import { buildTeamGraphDefaultLayoutSeed } from '@shared/utils/teamGraphDefaultLayout';
import { describe, expect, it } from 'vitest';
describe('team graph default layout', () => {
function members(count: number): Array<{ name: string; agentId: string }> {
@ -56,4 +55,25 @@ describe('team graph default layout', () => {
'agent-11': { ringIndex: 3, sectorIndex: 2 },
});
});
it('seeds fourteen visible owners into aligned row-orbit defaults around the lead', () => {
const teamMembers = members(14);
expect(buildTeamGraphDefaultLayoutSeed(teamMembers, teamMembers).assignments).toEqual({
'agent-0': { ringIndex: 0, sectorIndex: 0 },
'agent-1': { ringIndex: 0, sectorIndex: 1 },
'agent-2': { ringIndex: 0, sectorIndex: 2 },
'agent-3': { ringIndex: 1, sectorIndex: 0 },
'agent-4': { ringIndex: 1, sectorIndex: 1 },
'agent-5': { ringIndex: 1, sectorIndex: 2 },
'agent-6': { ringIndex: 2, sectorIndex: 0 },
'agent-7': { ringIndex: 2, sectorIndex: 1 },
'agent-8': { ringIndex: 3, sectorIndex: 0 },
'agent-9': { ringIndex: 3, sectorIndex: 1 },
'agent-10': { ringIndex: 3, sectorIndex: 2 },
'agent-11': { ringIndex: 4, sectorIndex: 0 },
'agent-12': { ringIndex: 4, sectorIndex: 1 },
'agent-13': { ringIndex: 4, sectorIndex: 2 },
});
});
});