refactor/channel & ipc (#349)

Co-authored-by: paisley <8197966+su8su@users.noreply.github.com>
Co-authored-by: zuolingxuan <zuolingxuan@bytedance.com>
This commit is contained in:
Lingxuan Zuo
2026-03-09 19:04:00 +08:00
committed by GitHub
Unverified
parent 8b45960662
commit e28eba01e1
47 changed files with 4160 additions and 543 deletions

View File

@@ -8,11 +8,17 @@ import {
registerTransportInvoker,
unregisterTransportInvoker,
clearTransportBackoff,
getApiClientConfig,
applyGatewayTransportPreference,
createGatewayHttpTransportInvoker,
getGatewayWsDiagnosticEnabled,
setGatewayWsDiagnosticEnabled,
} from '@/lib/api-client';
describe('api-client', () => {
beforeEach(() => {
vi.resetAllMocks();
window.localStorage.removeItem('clawx:gateway-ws-diagnostic');
configureApiClient({
enabled: { ws: false, http: false },
rules: [{ matcher: /.*/, order: ['ipc'] }],
@@ -150,4 +156,82 @@ describe('api-client', () => {
expect(wsInvoker).toHaveBeenCalledTimes(2);
expect(invoke).toHaveBeenCalledTimes(2);
});
it('defaults transport preference to ipc-only', () => {
applyGatewayTransportPreference();
const config = getApiClientConfig();
expect(config.enabled.ws).toBe(false);
expect(config.enabled.http).toBe(false);
expect(config.rules[0]).toEqual({ matcher: /^gateway:rpc$/, order: ['ipc'] });
});
it('enables ws->http->ipc order when ws diagnostic is on', () => {
setGatewayWsDiagnosticEnabled(true);
expect(getGatewayWsDiagnosticEnabled()).toBe(true);
const config = getApiClientConfig();
expect(config.enabled.ws).toBe(true);
expect(config.enabled.http).toBe(true);
expect(config.rules[0]).toEqual({ matcher: /^gateway:rpc$/, order: ['ws', 'http', 'ipc'] });
});
it('parses gateway:httpProxy unified envelope response', async () => {
const invoke = vi.mocked(window.electron.ipcRenderer.invoke);
invoke.mockResolvedValueOnce({
ok: true,
data: {
status: 200,
ok: true,
json: { type: 'res', ok: true, payload: { rows: [1, 2] } },
},
});
const invoker = createGatewayHttpTransportInvoker();
const result = await invoker<{ success: boolean; result: { rows: number[] } }>(
'gateway:rpc',
['chat.history', { sessionKey: 's1' }],
);
expect(result.success).toBe(true);
expect(result.result.rows).toEqual([1, 2]);
expect(invoke).toHaveBeenCalledWith(
'gateway:httpProxy',
expect.objectContaining({
path: '/rpc',
method: 'POST',
}),
);
});
it('throws meaningful error when gateway:httpProxy unified envelope fails', async () => {
const invoke = vi.mocked(window.electron.ipcRenderer.invoke);
invoke.mockResolvedValueOnce({
ok: false,
error: { message: 'proxy unavailable' },
});
const invoker = createGatewayHttpTransportInvoker();
await expect(invoker('gateway:rpc', ['chat.history', {}])).rejects.toThrow('proxy unavailable');
});
it('normalizes raw gateway:httpProxy payload into ipc-style envelope', async () => {
const invoke = vi.mocked(window.electron.ipcRenderer.invoke);
invoke.mockResolvedValueOnce({
ok: true,
data: {
status: 200,
ok: true,
json: { channels: [{ id: 'telegram-default' }] },
},
});
const invoker = createGatewayHttpTransportInvoker();
const result = await invoker<{ success: boolean; result: { channels: Array<{ id: string }> } }>(
'gateway:rpc',
['channels.status', { probe: false }],
);
expect(result.success).toBe(true);
expect(result.result.channels[0].id).toBe('telegram-default');
});
});

View File

@@ -0,0 +1,166 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
const clearErrorRecoveryTimer = vi.fn();
const clearHistoryPoll = vi.fn();
const collectToolUpdates = vi.fn(() => []);
const extractImagesAsAttachedFiles = vi.fn(() => []);
const extractMediaRefs = vi.fn(() => []);
const extractRawFilePaths = vi.fn(() => []);
const getMessageText = vi.fn(() => '');
const getToolCallFilePath = vi.fn(() => undefined);
const hasErrorRecoveryTimer = vi.fn(() => false);
const hasNonToolAssistantContent = vi.fn(() => true);
const isToolOnlyMessage = vi.fn(() => false);
const isToolResultRole = vi.fn((role: unknown) => role === 'toolresult');
const makeAttachedFile = vi.fn((ref: { filePath: string; mimeType: string }) => ({
fileName: ref.filePath.split('/').pop() || 'file',
mimeType: ref.mimeType,
fileSize: 0,
preview: null,
filePath: ref.filePath,
}));
const setErrorRecoveryTimer = vi.fn();
const upsertToolStatuses = vi.fn((_current, updates) => updates);
vi.mock('@/stores/chat/helpers', () => ({
clearErrorRecoveryTimer: (...args: unknown[]) => clearErrorRecoveryTimer(...args),
clearHistoryPoll: (...args: unknown[]) => clearHistoryPoll(...args),
collectToolUpdates: (...args: unknown[]) => collectToolUpdates(...args),
extractImagesAsAttachedFiles: (...args: unknown[]) => extractImagesAsAttachedFiles(...args),
extractMediaRefs: (...args: unknown[]) => extractMediaRefs(...args),
extractRawFilePaths: (...args: unknown[]) => extractRawFilePaths(...args),
getMessageText: (...args: unknown[]) => getMessageText(...args),
getToolCallFilePath: (...args: unknown[]) => getToolCallFilePath(...args),
hasErrorRecoveryTimer: (...args: unknown[]) => hasErrorRecoveryTimer(...args),
hasNonToolAssistantContent: (...args: unknown[]) => hasNonToolAssistantContent(...args),
isToolOnlyMessage: (...args: unknown[]) => isToolOnlyMessage(...args),
isToolResultRole: (...args: unknown[]) => isToolResultRole(...args),
makeAttachedFile: (...args: unknown[]) => makeAttachedFile(...args),
setErrorRecoveryTimer: (...args: unknown[]) => setErrorRecoveryTimer(...args),
upsertToolStatuses: (...args: unknown[]) => upsertToolStatuses(...args),
}));
type ChatLikeState = {
sending: boolean;
activeRunId: string | null;
error: string | null;
streamingMessage: unknown | null;
streamingTools: unknown[];
messages: Array<Record<string, unknown>>;
pendingToolImages: unknown[];
pendingFinal: boolean;
lastUserMessageAt: number | null;
streamingText: string;
loadHistory: ReturnType<typeof vi.fn>;
};
function makeHarness(initial?: Partial<ChatLikeState>) {
let state: ChatLikeState = {
sending: false,
activeRunId: null,
error: 'stale error',
streamingMessage: null,
streamingTools: [],
messages: [],
pendingToolImages: [],
pendingFinal: false,
lastUserMessageAt: null,
streamingText: '',
loadHistory: vi.fn(),
...initial,
};
const set = (partial: Partial<ChatLikeState> | ((s: ChatLikeState) => Partial<ChatLikeState>)) => {
const next = typeof partial === 'function' ? partial(state) : partial;
state = { ...state, ...next };
};
const get = () => state;
return { set, get, read: () => state };
}
describe('chat runtime event handlers', () => {
beforeEach(() => {
vi.resetAllMocks();
hasErrorRecoveryTimer.mockReturnValue(false);
collectToolUpdates.mockReturnValue([]);
upsertToolStatuses.mockImplementation((_current, updates) => updates);
});
it('marks sending on started event', async () => {
const { handleRuntimeEventState } = await import('@/stores/chat/runtime-event-handlers');
const h = makeHarness({ sending: false, activeRunId: null, error: 'err' });
handleRuntimeEventState(h.set as never, h.get as never, {}, 'started', 'run-1');
const next = h.read();
expect(next.sending).toBe(true);
expect(next.activeRunId).toBe('run-1');
expect(next.error).toBeNull();
});
it('applies delta event and clears stale error when recovery timer exists', async () => {
hasErrorRecoveryTimer.mockReturnValue(true);
collectToolUpdates.mockReturnValue([{ name: 'tool-a', status: 'running', updatedAt: 1 }]);
const { handleRuntimeEventState } = await import('@/stores/chat/runtime-event-handlers');
const h = makeHarness({
error: 'old',
streamingTools: [],
streamingMessage: { role: 'assistant', content: 'old' },
});
const event = { message: { role: 'assistant', content: 'delta' } };
handleRuntimeEventState(h.set as never, h.get as never, event, 'delta', 'run-2');
const next = h.read();
expect(clearErrorRecoveryTimer).toHaveBeenCalledTimes(1);
expect(next.error).toBeNull();
expect(next.streamingMessage).toEqual(event.message);
expect(next.streamingTools).toEqual([{ name: 'tool-a', status: 'running', updatedAt: 1 }]);
});
it('loads history when final event has no message', async () => {
const { handleRuntimeEventState } = await import('@/stores/chat/runtime-event-handlers');
const h = makeHarness();
handleRuntimeEventState(h.set as never, h.get as never, {}, 'final', 'run-3');
const next = h.read();
expect(next.pendingFinal).toBe(true);
expect(next.streamingMessage).toBeNull();
expect(h.read().loadHistory).toHaveBeenCalledTimes(1);
});
it('handles error event and finalizes immediately when not sending', async () => {
const { handleRuntimeEventState } = await import('@/stores/chat/runtime-event-handlers');
const h = makeHarness({ sending: false, activeRunId: 'r1', lastUserMessageAt: 123 });
handleRuntimeEventState(h.set as never, h.get as never, { errorMessage: 'boom' }, 'error', 'r1');
const next = h.read();
expect(clearHistoryPoll).toHaveBeenCalledTimes(1);
expect(next.error).toBe('boom');
expect(next.sending).toBe(false);
expect(next.activeRunId).toBeNull();
expect(next.lastUserMessageAt).toBeNull();
expect(next.streamingTools).toEqual([]);
});
it('clears runtime state on aborted event', async () => {
const { handleRuntimeEventState } = await import('@/stores/chat/runtime-event-handlers');
const h = makeHarness({
sending: true,
activeRunId: 'r2',
streamingText: 'abc',
pendingFinal: true,
lastUserMessageAt: 5,
pendingToolImages: [{ fileName: 'x' }],
});
handleRuntimeEventState(h.set as never, h.get as never, {}, 'aborted', 'r2');
const next = h.read();
expect(next.sending).toBe(false);
expect(next.activeRunId).toBeNull();
expect(next.streamingText).toBe('');
expect(next.pendingFinal).toBe(false);
expect(next.lastUserMessageAt).toBeNull();
expect(next.pendingToolImages).toEqual([]);
});
});

View File

@@ -0,0 +1,123 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
const invokeIpcMock = vi.fn();
vi.mock('@/lib/api-client', () => ({
invokeIpc: (...args: unknown[]) => invokeIpcMock(...args),
}));
type ChatLikeState = {
currentSessionKey: string;
sessions: Array<{ key: string; displayName?: string }>;
messages: Array<{ role: string; timestamp?: number; content?: unknown }>;
sessionLabels: Record<string, string>;
sessionLastActivity: Record<string, number>;
streamingText: string;
streamingMessage: unknown | null;
streamingTools: unknown[];
activeRunId: string | null;
error: string | null;
pendingFinal: boolean;
lastUserMessageAt: number | null;
pendingToolImages: unknown[];
loadHistory: ReturnType<typeof vi.fn>;
};
function makeHarness(initial?: Partial<ChatLikeState>) {
let state: ChatLikeState = {
currentSessionKey: 'agent:main:main',
sessions: [{ key: 'agent:main:main' }],
messages: [],
sessionLabels: {},
sessionLastActivity: {},
streamingText: '',
streamingMessage: null,
streamingTools: [],
activeRunId: null,
error: null,
pendingFinal: false,
lastUserMessageAt: null,
pendingToolImages: [],
loadHistory: vi.fn(),
...initial,
};
const set = (partial: Partial<ChatLikeState> | ((s: ChatLikeState) => Partial<ChatLikeState>)) => {
const patch = typeof partial === 'function' ? partial(state) : partial;
state = { ...state, ...patch };
};
const get = () => state;
return { set, get, read: () => state };
}
describe('chat session actions', () => {
beforeEach(() => {
vi.resetAllMocks();
invokeIpcMock.mockResolvedValue({ success: true });
});
it('switchSession removes empty non-main leaving session and loads history', async () => {
const { createSessionActions } = await import('@/stores/chat/session-actions');
const h = makeHarness({
currentSessionKey: 'agent:foo:session-a',
sessions: [{ key: 'agent:foo:session-a' }, { key: 'agent:foo:main' }],
messages: [],
sessionLabels: { 'agent:foo:session-a': 'A' },
sessionLastActivity: { 'agent:foo:session-a': 1 },
});
const actions = createSessionActions(h.set as never, h.get as never);
actions.switchSession('agent:foo:main');
const next = h.read();
expect(next.currentSessionKey).toBe('agent:foo:main');
expect(next.sessions.find((s) => s.key === 'agent:foo:session-a')).toBeUndefined();
expect(next.sessionLabels['agent:foo:session-a']).toBeUndefined();
expect(next.sessionLastActivity['agent:foo:session-a']).toBeUndefined();
expect(h.read().loadHistory).toHaveBeenCalledTimes(1);
});
it('deleteSession updates current session and keeps sidebar consistent', async () => {
const { createSessionActions } = await import('@/stores/chat/session-actions');
const h = makeHarness({
currentSessionKey: 'agent:foo:session-a',
sessions: [{ key: 'agent:foo:session-a' }, { key: 'agent:foo:main' }],
sessionLabels: { 'agent:foo:session-a': 'A' },
sessionLastActivity: { 'agent:foo:session-a': 1 },
messages: [{ role: 'user' }],
});
const actions = createSessionActions(h.set as never, h.get as never);
await actions.deleteSession('agent:foo:session-a');
const next = h.read();
expect(invokeIpcMock).toHaveBeenCalledWith('session:delete', 'agent:foo:session-a');
expect(next.currentSessionKey).toBe('agent:foo:main');
expect(next.sessions.map((s) => s.key)).toEqual(['agent:foo:main']);
expect(next.sessionLabels['agent:foo:session-a']).toBeUndefined();
expect(next.sessionLastActivity['agent:foo:session-a']).toBeUndefined();
expect(h.read().loadHistory).toHaveBeenCalledTimes(1);
});
it('newSession creates a canonical session key and clears transient state', async () => {
const nowSpy = vi.spyOn(Date, 'now').mockReturnValue(1711111111111);
const { createSessionActions } = await import('@/stores/chat/session-actions');
const h = makeHarness({
currentSessionKey: 'agent:foo:main',
sessions: [{ key: 'agent:foo:main' }],
messages: [{ role: 'assistant' }],
streamingText: 'streaming',
activeRunId: 'r1',
pendingFinal: true,
});
const actions = createSessionActions(h.set as never, h.get as never);
actions.newSession();
const next = h.read();
expect(next.currentSessionKey).toBe('agent:foo:session-1711111111111');
expect(next.sessions.some((s) => s.key === 'agent:foo:session-1711111111111')).toBe(true);
expect(next.messages).toEqual([]);
expect(next.streamingText).toBe('');
expect(next.activeRunId).toBeNull();
expect(next.pendingFinal).toBe(false);
nowSpy.mockRestore();
});
});

View File

@@ -0,0 +1,41 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
const hostApiFetchMock = vi.fn();
const subscribeHostEventMock = vi.fn();
vi.mock('@/lib/host-api', () => ({
hostApiFetch: (...args: unknown[]) => hostApiFetchMock(...args),
}));
vi.mock('@/lib/host-events', () => ({
subscribeHostEvent: (...args: unknown[]) => subscribeHostEventMock(...args),
}));
describe('gateway store event wiring', () => {
beforeEach(() => {
vi.resetModules();
vi.clearAllMocks();
});
it('subscribes to host events through subscribeHostEvent on init', async () => {
hostApiFetchMock.mockResolvedValueOnce({ state: 'running', port: 18789 });
const handlers = new Map<string, (payload: unknown) => void>();
subscribeHostEventMock.mockImplementation((eventName: string, handler: (payload: unknown) => void) => {
handlers.set(eventName, handler);
return () => {};
});
const { useGatewayStore } = await import('@/stores/gateway');
await useGatewayStore.getState().init();
expect(subscribeHostEventMock).toHaveBeenCalledWith('gateway:status', expect.any(Function));
expect(subscribeHostEventMock).toHaveBeenCalledWith('gateway:error', expect.any(Function));
expect(subscribeHostEventMock).toHaveBeenCalledWith('gateway:notification', expect.any(Function));
expect(subscribeHostEventMock).toHaveBeenCalledWith('gateway:chat-message', expect.any(Function));
expect(subscribeHostEventMock).toHaveBeenCalledWith('gateway:channel-status', expect.any(Function));
handlers.get('gateway:status')?.({ state: 'stopped', port: 18789 });
expect(useGatewayStore.getState().status.state).toBe('stopped');
});
});

View File

@@ -0,0 +1,88 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
const invokeIpcMock = vi.fn();
vi.mock('@/lib/api-client', () => ({
invokeIpc: (...args: unknown[]) => invokeIpcMock(...args),
}));
describe('host-api', () => {
beforeEach(() => {
vi.resetAllMocks();
});
it('uses IPC proxy and returns unified envelope json', async () => {
invokeIpcMock.mockResolvedValueOnce({
ok: true,
data: {
status: 200,
ok: true,
json: { success: true },
},
});
const { hostApiFetch } = await import('@/lib/host-api');
const result = await hostApiFetch<{ success: boolean }>('/api/settings');
expect(result.success).toBe(true);
expect(invokeIpcMock).toHaveBeenCalledWith(
'hostapi:fetch',
expect.objectContaining({ path: '/api/settings', method: 'GET' }),
);
});
it('supports legacy proxy envelope response', async () => {
invokeIpcMock.mockResolvedValueOnce({
success: true,
status: 200,
ok: true,
json: { ok: 1 },
});
const { hostApiFetch } = await import('@/lib/host-api');
const result = await hostApiFetch<{ ok: number }>('/api/settings');
expect(result.ok).toBe(1);
});
it('throws proxy error from unified envelope', async () => {
invokeIpcMock.mockResolvedValueOnce({
ok: false,
error: { message: 'No handler registered for hostapi:fetch' },
});
const { hostApiFetch } = await import('@/lib/host-api');
await expect(hostApiFetch('/api/test')).rejects.toThrow('No handler registered for hostapi:fetch');
});
it('throws message from legacy non-ok envelope', async () => {
invokeIpcMock.mockResolvedValueOnce({
success: true,
ok: false,
status: 401,
json: { error: 'Invalid Authentication' },
});
const { hostApiFetch } = await import('@/lib/host-api');
await expect(hostApiFetch('/api/test')).rejects.toThrow('Invalid Authentication');
});
it('falls back to browser fetch only when IPC channel is unavailable', async () => {
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
status: 200,
json: async () => ({ fallback: true }),
});
vi.stubGlobal('fetch', fetchMock);
invokeIpcMock.mockRejectedValueOnce(new Error('Invalid IPC channel: hostapi:fetch'));
const { hostApiFetch } = await import('@/lib/host-api');
const result = await hostApiFetch<{ fallback: boolean }>('/api/test');
expect(result.fallback).toBe(true);
expect(fetchMock).toHaveBeenCalledWith(
'http://127.0.0.1:3210/api/test',
expect.objectContaining({ headers: expect.any(Object) }),
);
});
});

View File

@@ -0,0 +1,74 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
const addEventListenerMock = vi.fn();
const removeEventListenerMock = vi.fn();
const eventSourceMock = {
addEventListener: addEventListenerMock,
removeEventListener: removeEventListenerMock,
} as unknown as EventSource;
const createHostEventSourceMock = vi.fn(() => eventSourceMock);
vi.mock('@/lib/host-api', () => ({
createHostEventSource: () => createHostEventSourceMock(),
}));
describe('host-events', () => {
beforeEach(() => {
vi.resetAllMocks();
window.localStorage.clear();
});
it('subscribes through IPC for mapped host events', async () => {
const onMock = vi.mocked(window.electron.ipcRenderer.on);
const offMock = vi.mocked(window.electron.ipcRenderer.off);
const captured: Array<(...args: unknown[]) => void> = [];
onMock.mockImplementation((_, cb: (...args: unknown[]) => void) => {
captured.push(cb);
return () => {};
});
const { subscribeHostEvent } = await import('@/lib/host-events');
const handler = vi.fn();
const unsubscribe = subscribeHostEvent('gateway:status', handler);
expect(onMock).toHaveBeenCalledWith('gateway:status-changed', expect.any(Function));
expect(createHostEventSourceMock).not.toHaveBeenCalled();
captured[0]({ state: 'running' });
expect(handler).toHaveBeenCalledWith({ state: 'running' });
unsubscribe();
expect(offMock).toHaveBeenCalledWith('gateway:status-changed', expect.any(Function));
});
it('does not use SSE fallback by default for unknown events', async () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
const { subscribeHostEvent } = await import('@/lib/host-events');
const unsubscribe = subscribeHostEvent('unknown:event', vi.fn());
expect(createHostEventSourceMock).not.toHaveBeenCalled();
expect(warnSpy).toHaveBeenCalledWith(
'[host-events] no IPC mapping for event "unknown:event", SSE fallback disabled',
);
unsubscribe();
warnSpy.mockRestore();
});
it('uses SSE fallback only when explicitly enabled', async () => {
window.localStorage.setItem('clawx:allow-sse-fallback', '1');
const { subscribeHostEvent } = await import('@/lib/host-events');
const handler = vi.fn();
const unsubscribe = subscribeHostEvent('unknown:event', handler);
expect(createHostEventSourceMock).toHaveBeenCalledTimes(1);
expect(addEventListenerMock).toHaveBeenCalledWith('unknown:event', expect.any(Function));
const listener = addEventListenerMock.mock.calls[0][1] as (event: Event) => void;
listener({ data: JSON.stringify({ x: 1 }) } as unknown as Event);
expect(handler).toHaveBeenCalledWith({ x: 1 });
unsubscribe();
expect(removeEventListenerMock).toHaveBeenCalledWith('unknown:event', expect.any(Function));
});
});

View File

@@ -0,0 +1,89 @@
import { describe, expect, it } from 'vitest';
import { buildNonOAuthAgentProviderUpdate, getModelIdFromRef } from '@electron/main/provider-model-sync';
import type { ProviderConfig } from '@electron/utils/secure-storage';
function providerConfig(overrides: Partial<ProviderConfig>): ProviderConfig {
return {
id: 'provider-id',
name: 'Provider',
type: 'moonshot',
enabled: true,
createdAt: '2026-03-09T00:00:00.000Z',
updatedAt: '2026-03-09T00:00:00.000Z',
...overrides,
};
}
describe('provider-model-sync', () => {
it('extracts model ID from provider/model refs', () => {
expect(getModelIdFromRef('moonshot/kimi-k2.5', 'moonshot')).toBe('kimi-k2.5');
expect(getModelIdFromRef('kimi-k2.5', 'moonshot')).toBe('kimi-k2.5');
expect(getModelIdFromRef(undefined, 'moonshot')).toBeUndefined();
});
it('builds models.json update payload for moonshot default switch', () => {
const payload = buildNonOAuthAgentProviderUpdate(
providerConfig({ type: 'moonshot', id: 'moonshot' }),
'moonshot',
'moonshot/kimi-k2.5',
);
expect(payload).toEqual({
providerKey: 'moonshot',
entry: {
baseUrl: 'https://api.moonshot.cn/v1',
api: 'openai-completions',
apiKey: 'MOONSHOT_API_KEY',
models: [{ id: 'kimi-k2.5', name: 'kimi-k2.5' }],
},
});
});
it('prefers provider custom baseUrl and omits models when modelRef is missing', () => {
const payload = buildNonOAuthAgentProviderUpdate(
providerConfig({
type: 'ark',
id: 'ark',
baseUrl: 'https://custom-ark.example.com/v3',
}),
'ark',
undefined,
);
expect(payload).toEqual({
providerKey: 'ark',
entry: {
baseUrl: 'https://custom-ark.example.com/v3',
api: 'openai-completions',
apiKey: 'ARK_API_KEY',
models: [],
},
});
});
it('returns null for oauth and multi-instance providers', () => {
expect(
buildNonOAuthAgentProviderUpdate(
providerConfig({ type: 'qwen-portal', id: 'qwen-portal' }),
'qwen-portal',
'qwen-portal/coder-model',
),
).toBeNull();
expect(
buildNonOAuthAgentProviderUpdate(
providerConfig({ type: 'custom', id: 'custom-123' }),
'custom-123',
'custom-123/model',
),
).toBeNull();
expect(
buildNonOAuthAgentProviderUpdate(
providerConfig({ type: 'ollama', id: 'ollama' }),
'ollama',
'ollama/qwen3:latest',
),
).toBeNull();
});
});