224 lines
7.1 KiB
TypeScript
224 lines
7.1 KiB
TypeScript
import { describe, expect, it, vi } from 'vitest';
|
|
|
|
import {
|
|
APP_MCP_RUNTIME_TOOL_CONTRACTS,
|
|
assertRuntimeDeliverMessageSchema,
|
|
buildOpenCodeCanonicalMcpToolId,
|
|
matchRequiredOpenCodeTools,
|
|
OpenCodeMcpToolAvailabilityProbe,
|
|
REQUIRED_AGENT_TEAMS_RUNTIME_TOOLS,
|
|
sanitizeOpenCodeMcpToolPart,
|
|
verifyAppMcpRuntimeToolContracts,
|
|
type OpenCodeInfrastructureToolClient,
|
|
type OpenCodeToolListItem,
|
|
} from '../../../../src/main/services/team/opencode/mcp/OpenCodeMcpToolAvailability';
|
|
|
|
describe('OpenCode MCP tool availability', () => {
|
|
it('builds source-verified canonical MCP tool ids', () => {
|
|
expect(sanitizeOpenCodeMcpToolPart('agent-teams')).toBe('agent-teams');
|
|
expect(buildOpenCodeCanonicalMcpToolId('agent-teams', 'runtime_deliver_message')).toBe(
|
|
'agent-teams_runtime_deliver_message'
|
|
);
|
|
});
|
|
|
|
it('fails production proof when only alias ids are observed', () => {
|
|
const proof = matchRequiredOpenCodeTools({
|
|
route: '/experimental/tool/ids',
|
|
serverName: 'agent-teams',
|
|
requiredTools: ['runtime_deliver_message'],
|
|
observedTools: ['mcp__agent-teams__runtime_deliver_message'],
|
|
});
|
|
|
|
expect(proof).toMatchObject({
|
|
ok: false,
|
|
missingTools: ['runtime_deliver_message'],
|
|
matchedByRequiredTool: {
|
|
runtime_deliver_message: null,
|
|
},
|
|
aliasMatchedByRequiredTool: {
|
|
runtime_deliver_message: 'mcp__agent-teams__runtime_deliver_message',
|
|
},
|
|
});
|
|
expect(proof.diagnostics).toContain(
|
|
'OpenCode observed alias mcp__agent-teams__runtime_deliver_message but missing canonical app MCP tool id agent-teams_runtime_deliver_message'
|
|
);
|
|
});
|
|
|
|
it('proves required tools through experimental tool ids', async () => {
|
|
const client = fakeToolClient({
|
|
ids: REQUIRED_AGENT_TEAMS_RUNTIME_TOOLS.map((tool) =>
|
|
buildOpenCodeCanonicalMcpToolId('agent-teams', tool)
|
|
),
|
|
tools: [],
|
|
});
|
|
const probe = new OpenCodeMcpToolAvailabilityProbe(client);
|
|
|
|
await expect(
|
|
probe.proveRequiredTools({
|
|
serverName: 'agent-teams',
|
|
requiredTools: [...REQUIRED_AGENT_TEAMS_RUNTIME_TOOLS],
|
|
providerId: 'anthropic',
|
|
modelId: 'claude-sonnet',
|
|
})
|
|
).resolves.toMatchObject({
|
|
ok: true,
|
|
route: '/experimental/tool/ids',
|
|
missingTools: [],
|
|
matchedByRequiredTool: {
|
|
runtime_deliver_message: 'agent-teams_runtime_deliver_message',
|
|
},
|
|
});
|
|
expect(client.listExperimentalTools).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('falls back to provider/model tool definitions when ids route fails', async () => {
|
|
const client = fakeToolClient({
|
|
idsError: new Error('ids unavailable'),
|
|
tools: REQUIRED_AGENT_TEAMS_RUNTIME_TOOLS.map((tool) => ({
|
|
id: buildOpenCodeCanonicalMcpToolId('agent-teams', tool),
|
|
})),
|
|
});
|
|
const probe = new OpenCodeMcpToolAvailabilityProbe(client);
|
|
|
|
await expect(
|
|
probe.proveRequiredTools({
|
|
serverName: 'agent-teams',
|
|
requiredTools: [...REQUIRED_AGENT_TEAMS_RUNTIME_TOOLS],
|
|
providerId: 'anthropic',
|
|
modelId: 'claude-sonnet',
|
|
})
|
|
).resolves.toMatchObject({
|
|
ok: true,
|
|
route: '/experimental/tool',
|
|
diagnostics: [],
|
|
});
|
|
expect(client.listExperimentalTools).toHaveBeenCalledWith({
|
|
providerId: 'anthropic',
|
|
modelId: 'claude-sonnet',
|
|
});
|
|
});
|
|
|
|
it('keeps launch blocked when neither tool endpoint proves canonical tools', async () => {
|
|
const client = fakeToolClient({
|
|
ids: ['agent-teams_runtime_bootstrap_checkin'],
|
|
toolsError: new Error('definitions unavailable'),
|
|
});
|
|
const probe = new OpenCodeMcpToolAvailabilityProbe(client);
|
|
|
|
const proof = await probe.proveRequiredTools({
|
|
serverName: 'agent-teams',
|
|
requiredTools: [...REQUIRED_AGENT_TEAMS_RUNTIME_TOOLS],
|
|
providerId: 'anthropic',
|
|
modelId: 'claude-sonnet',
|
|
});
|
|
|
|
expect(proof.ok).toBe(false);
|
|
expect(proof.missingTools).toEqual(
|
|
expect.arrayContaining(['runtime_deliver_message', 'runtime_task_event', 'runtime_heartbeat'])
|
|
);
|
|
expect(proof.diagnostics).toContain(
|
|
'OpenCode app-owned MCP server is connected but required runtime tools were not proven available'
|
|
);
|
|
});
|
|
|
|
it('verifies direct app MCP runtime tool contracts', () => {
|
|
const result = verifyAppMcpRuntimeToolContracts(
|
|
APP_MCP_RUNTIME_TOOL_CONTRACTS.map((contract) => ({
|
|
name: contract.name,
|
|
inputSchema: schemaWithRequired(contract.requiredInputFields),
|
|
}))
|
|
);
|
|
|
|
expect(result).toEqual({
|
|
ok: true,
|
|
observedToolNames: [
|
|
'runtime_bootstrap_checkin',
|
|
'runtime_deliver_message',
|
|
'runtime_heartbeat',
|
|
'runtime_task_event',
|
|
],
|
|
diagnostics: [],
|
|
});
|
|
});
|
|
|
|
it('fails direct app MCP preflight when delivery schema misses idempotencyKey', () => {
|
|
const tools = APP_MCP_RUNTIME_TOOL_CONTRACTS.map((contract) => ({
|
|
name: contract.name,
|
|
inputSchema:
|
|
contract.name === 'runtime_deliver_message'
|
|
? schemaWithRequired(['runId', 'teamName', 'fromMemberName', 'runtimeSessionId', 'to', 'text'])
|
|
: schemaWithRequired(contract.requiredInputFields),
|
|
}));
|
|
|
|
expect(verifyAppMcpRuntimeToolContracts(tools).diagnostics).toContain(
|
|
'App MCP tool runtime_deliver_message missing required field idempotencyKey'
|
|
);
|
|
});
|
|
|
|
it('validates provider/model runtime_deliver_message tool schema', () => {
|
|
const tools: OpenCodeToolListItem[] = [
|
|
{
|
|
id: 'agent-teams_runtime_deliver_message',
|
|
parameters: schemaWithRequired([
|
|
'idempotencyKey',
|
|
'runId',
|
|
'teamName',
|
|
'fromMemberName',
|
|
'runtimeSessionId',
|
|
'to',
|
|
'text',
|
|
]),
|
|
},
|
|
];
|
|
|
|
expect(assertRuntimeDeliverMessageSchema(tools)).toEqual([]);
|
|
expect(
|
|
assertRuntimeDeliverMessageSchema([
|
|
{
|
|
id: 'agent-teams_runtime_deliver_message',
|
|
parameters: schemaWithRequired(['runId', 'teamName']),
|
|
},
|
|
])
|
|
).toEqual([
|
|
{
|
|
severity: 'error',
|
|
message:
|
|
'runtime_deliver_message schema missing required fields: idempotencyKey, fromMemberName, runtimeSessionId, to, text',
|
|
missingFields: ['idempotencyKey', 'fromMemberName', 'runtimeSessionId', 'to', 'text'],
|
|
},
|
|
]);
|
|
});
|
|
});
|
|
|
|
function fakeToolClient(options: {
|
|
ids?: string[];
|
|
tools?: OpenCodeToolListItem[];
|
|
idsError?: Error;
|
|
toolsError?: Error;
|
|
}): OpenCodeInfrastructureToolClient & {
|
|
listExperimentalToolIds: ReturnType<typeof vi.fn>;
|
|
listExperimentalTools: ReturnType<typeof vi.fn>;
|
|
} {
|
|
return {
|
|
listExperimentalToolIds: vi.fn(async () => {
|
|
if (options.idsError) {
|
|
throw options.idsError;
|
|
}
|
|
return options.ids ?? [];
|
|
}),
|
|
listExperimentalTools: vi.fn(async () => {
|
|
if (options.toolsError) {
|
|
throw options.toolsError;
|
|
}
|
|
return options.tools ?? [];
|
|
}),
|
|
};
|
|
}
|
|
|
|
function schemaWithRequired(required: string[]): Record<string, unknown> {
|
|
return {
|
|
type: 'object',
|
|
required,
|
|
properties: Object.fromEntries(required.map((field) => [field, { type: 'string' }])),
|
|
};
|
|
}
|