Add companion runtime gateway client foundation

This commit is contained in:
William Valentin
2026-02-16 13:35:28 -08:00
parent 9b76c75e82
commit 542a8cb70f
8 changed files with 744 additions and 2 deletions
+227
View File
@@ -0,0 +1,227 @@
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
import { resolve } from 'path';
import { createServer } from 'net';
import type { GatewayServerConfig } from '../gateway/server.js';
import { GatewayServer } from '../gateway/server.js';
import { CompanionRuntimeClient, GatewayRpcError } from './runtimeClient.js';
async function canListenOnLocalhost(): Promise<boolean> {
return new Promise((resolvePromise) => {
const s = createServer();
s.once('error', () => resolvePromise(false));
s.listen(0, '127.0.0.1', () => {
s.close(() => resolvePromise(true));
});
});
}
const mockSession = {
id: 'test',
addMessage: vi.fn(),
getHistory: vi.fn(() => []),
clear: vi.fn(),
setHistory: vi.fn(),
replaceHistory: vi.fn(),
};
const mockSessionManager = {
getSession: vi.fn(() => mockSession),
listSessions: vi.fn(() => ['ws:test']),
transferSession: vi.fn(),
closeSession: vi.fn(),
};
const mockModelClient = {
chat: vi.fn(async () => ({
content: 'Hello from Flynn!',
stopReason: 'end_turn',
usage: { inputTokens: 10, outputTokens: 5 },
})),
};
const mockToolRegistry = {
register: vi.fn(),
get: vi.fn(),
list: vi.fn(() => []),
filteredList: vi.fn(() => []),
toAnthropicFormat: vi.fn(() => []),
toOpenAIFormat: vi.fn(() => []),
filteredToAnthropicFormat: vi.fn(() => []),
filteredToOpenAIFormat: vi.fn(() => []),
};
const mockToolExecutor = {
execute: vi.fn(async () => ({ success: true, output: 'ok' })),
};
const TEST_PORT = 18911;
const TEST_TOKEN = 'runtime-client-token';
let LISTEN_ALLOWED = true;
let server: GatewayServer;
beforeAll(async () => {
LISTEN_ALLOWED = await canListenOnLocalhost();
if (!LISTEN_ALLOWED) {
return;
}
server = new GatewayServer({
port: TEST_PORT,
sessionManager: mockSessionManager as unknown as GatewayServerConfig['sessionManager'],
modelClient: mockModelClient,
systemPrompt: 'Test prompt',
toolRegistry: mockToolRegistry as unknown as GatewayServerConfig['toolRegistry'],
toolExecutor: mockToolExecutor as unknown as GatewayServerConfig['toolExecutor'],
version: '0.1.0-test',
uiDir: resolve(import.meta.dirname, '../gateway/ui'),
auth: { token: TEST_TOKEN },
nodes: {
enabled: true,
allowedRoles: ['companion'],
featureGates: { 'ui.canvas': true },
locationEnabled: true,
pushEnabled: true,
},
});
await server.start();
});
afterAll(async () => {
if (!LISTEN_ALLOWED) {
return;
}
await server.stop();
});
describe('CompanionRuntimeClient', () => {
it('connects and performs node registration + capability discovery', async () => {
if (!LISTEN_ALLOWED) {
return;
}
const client = new CompanionRuntimeClient({
url: `ws://127.0.0.1:${TEST_PORT}`,
token: TEST_TOKEN,
});
await client.connect();
expect(client.connected).toBe(true);
try {
const register = await client.registerNode({
nodeId: 'macos-companion',
role: 'companion',
capabilities: ['ui.canvas', 'node.location.read'],
});
expect(register.registered).toBe(true);
expect(register.node.id).toBe('macos-companion');
expect(register.protocol.serverVersion).toBe(1);
expect(register.capabilities.enabled).toContain('ui.canvas');
const nodeCaps = await client.getNodeCapabilities();
expect(nodeCaps.node.id).toBe('macos-companion');
expect(nodeCaps.capabilities.enabled).toContain('ui.canvas');
const systemCaps = await client.getSystemCapabilities();
expect(systemCaps.nodes.enabled).toBe(true);
expect(systemCaps.nodes.registered).toBe(true);
expect(systemCaps.nodes.nodeId).toBe('macos-companion');
} finally {
client.disconnect();
}
});
it('updates status/location/push and exposes them through system.nodes', async () => {
if (!LISTEN_ALLOWED) {
return;
}
const client = new CompanionRuntimeClient({
url: `ws://127.0.0.1:${TEST_PORT}`,
token: TEST_TOKEN,
});
await client.connect();
try {
await client.registerNode({
nodeId: 'ios-companion',
role: 'companion',
capabilities: ['node.location.write', 'node.push.register'],
});
const status = await client.setNodeStatus({
platform: 'ios',
appVersion: '1.2.3',
batteryPct: 77,
powerSource: 'battery',
});
expect(status.updated).toBe(true);
expect(status.status.platform).toBe('ios');
const location = await client.setNodeLocation({
latitude: 37.78,
longitude: -122.41,
source: 'gps',
});
expect(location.updated).toBe(true);
expect(location.location.source).toBe('gps');
const locationGet = await client.getNodeLocation();
expect(locationGet.location?.latitude).toBe(37.78);
const push = await client.setNodePushToken({
provider: 'apns',
token: 'abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890',
topic: 'dev.flynn.companion',
environment: 'production',
});
expect(push.updated).toBe(true);
expect(push.push.provider).toBe('apns');
expect(push.push.tokenPreview.startsWith('***')).toBe(true);
const nodes = await client.listSystemNodes({ role: 'companion', platform: 'ios' });
expect(nodes.summary.total).toBeGreaterThanOrEqual(1);
const current = nodes.nodes.find((entry) => entry.nodeId === 'ios-companion');
expect(current).toBeTruthy();
expect(current?.status?.platform).toBe('ios');
expect(current?.push?.provider).toBe('apns');
} finally {
client.disconnect();
}
});
it('surfaces gateway errors as GatewayRpcError', async () => {
if (!LISTEN_ALLOWED) {
return;
}
const client = new CompanionRuntimeClient({
url: `ws://127.0.0.1:${TEST_PORT}`,
token: TEST_TOKEN,
});
await client.connect();
try {
await expect(client.registerNode({
nodeId: 'observer-node',
role: 'observer',
capabilities: ['node.location.read'],
})).rejects.toBeInstanceOf(GatewayRpcError);
await expect(client.registerNode({
nodeId: 'observer-node',
role: 'observer',
capabilities: ['node.location.read'],
})).rejects.toMatchObject({
code: -5,
});
} finally {
client.disconnect();
}
});
});