Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,15 @@ FINAGENT_AGENT_PROVIDER=local bun run dev
FINAGENT_AGENT_PROVIDER=local FINAGENT_DEMO_DATA=1 bun run dev
```

Windows CMD 打开已构建的桌面工作台:

```cmd
bun.cmd run build:electron
start-trail.cmd
```

`start-trail.cmd` 会优先使用 npm 安装的原生 `bunx.exe`,避免 Windows 的 `bunx.cmd` 包装脚本无法被 Pi 子进程启动;若长桥安装在标准用户目录,也会补齐当前进程的 PATH。Pi CLI、模型凭据及长桥 CLI / 账户授权仍需单独配置。

### 命令

| 命令 | 说明 |
Expand Down
9 changes: 7 additions & 2 deletions apps/electron/src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,13 @@ loadFinagentEnv({
if (!app.requestSingleInstanceLock()) app.exit(0);
app.on('second-instance', () => {
if (process.env.FINAGENT_E2E_HIDDEN === '1') return;
if (mainWindow?.isMinimized()) mainWindow.restore();
mainWindow?.focus();
void app.whenReady().then(() => {
if (!mainWindow || mainWindow.isDestroyed()) createWindow();
if (mainWindow?.isMinimized()) mainWindow.restore();
// focus() alone does not reveal a window hidden by an earlier launch.
mainWindow?.show();
mainWindow?.focus();
});
});
const agentKernelHost = new AgentKernelHost();
registerAboutIpc();
Expand Down
86 changes: 83 additions & 3 deletions apps/electron/src/main/kernelHost.test.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,21 @@
import { afterEach, beforeEach, describe, expect, it, mock } from 'bun:test';
import { join } from 'node:path';
import type { AgentEvent, AutomationRule } from '@finagent/core';
import type { AnyProvider, ConnectionState } from '@finagent/shared';
import type { LoginOutcome, StartLoginOptions } from '../../../../packages/shared/src/providers/longbridge/auth.ts';

let lastKernelOptions: Record<string, unknown> | null = null;
let lastMarketData: FakeMarketDataService | null = null;
let lastAutomationContext: unknown = null;
let forwardedEvents: unknown[] = [];
let loginHandler: (options: StartLoginOptions) => Promise<LoginOutcome> = async () => ({ status: 'cancelled' });
let connectionUpdates: Array<Record<string, unknown>> = [];
let listedProviders: AnyProvider[] = [];
let storedConnection: ConnectionState | undefined;
const routerFetchers = { getQuote: async () => ({ symbol: 'AAPL.US' }) };

class FakeMarketDataService {
clear() {}
quoteSymbols: string[] = [];
constructor(readonly options?: { fetchers?: unknown }) {}

Expand Down Expand Up @@ -177,14 +184,16 @@ mock.module('@finagent/shared', () => ({
register = () => undefined;
setRouting = () => undefined;
get = () => undefined;
list = () => [];
list = () => listedProviders;
coverage = () => [];
lastResultFor = () => undefined;
},
ConnectionStore: class {
list = async () => [];
get = async () => undefined;
get = async (providerId: string) => storedConnection?.providerId === providerId ? storedConnection : undefined;
getConfig = async () => undefined;
update = async () => undefined;
getRouting = async (fallback: unknown) => fallback;
update = async (state: Record<string, unknown>) => { connectionUpdates.push(state); };
setConfig = async () => undefined;
subscribe = () => () => undefined;
},
Expand Down Expand Up @@ -341,6 +350,11 @@ mock.module('@finagent/skill-hub', () => ({
skillCapabilityMap: {},
}));

const longbridgeProviders = await import('@finagent/shared/providers/longbridge');
mock.module('@finagent/shared/providers/longbridge', () => ({
...longbridgeProviders,
startLogin: (options: StartLoginOptions) => loginHandler(options),
}));
const { AgentKernelHost } = await import('./kernelHost.ts');

// The constructor sets FINAGENT_PI_EXTENSION as a process-wide side effect;
Expand All @@ -352,6 +366,10 @@ beforeEach(() => {
lastMarketData = null;
lastAutomationContext = null;
forwardedEvents = [];
connectionUpdates = [];
listedProviders = [];
storedConnection = undefined;
loginHandler = async () => ({ status: 'cancelled' });
});

afterEach(() => {
Expand All @@ -363,6 +381,68 @@ afterEach(() => {
});

describe('AgentKernelHost', () => {
it('shows Connect after installation despite a persisted not-installed result', async () => {
const host = new AgentKernelHost();
storedConnection = {
providerId: 'longbridge', status: 'not-installed', lastCheck: 1,
error: { code: 'LONGBRIDGE_NOT_INSTALLED', message: 'Old missing CLI result' },
};
listedProviders = [new longbridgeProviders.LongbridgeFinancialDataProvider({
exec: async (args) => args[0] === '--version' ? 'longbridge 0.30.0'
: JSON.stringify({ token: { status: 'not_found' } }),
})];
const entries = await host.listConnections();
expect(entries[0]).toMatchObject({ status: 'not-connected', error: null });
host.dispose();
});

it('ends a failed login startup and persists not-installed instead of connecting', async () => {
const host = new AgentKernelHost();
loginHandler = async () => ({ status: 'error', error: {
code: 'LONGBRIDGE_NOT_INSTALLED', message: 'CLI not found',
} });
await expect(host.connectProvider({ providerId: 'longbridge' })).rejects.toMatchObject({
code: 'LONGBRIDGE_NOT_INSTALLED',
});
expect(connectionUpdates.at(-1)).toMatchObject({ status: 'not-installed' });
// The failed attempt must release the in-memory login lock too.
loginHandler = async () => ({ status: 'connected' });
await expect(host.connectProvider({ providerId: 'longbridge' })).resolves.toEqual({ status: 'connected' });
host.dispose();
});

it('cancels authorization while waiting for the first URL', async () => {
const host = new AgentKernelHost();
let started!: () => void;
const ready = new Promise<void>((resolve) => { started = resolve; });
loginHandler = async ({ signal }) => {
started();
return new Promise((resolve) => signal?.addEventListener('abort', () => resolve({ status: 'cancelled' }), { once: true }));
};
const pending = host.connectProvider({ providerId: 'longbridge' });
await ready;
await host.cancelConnectProvider({ providerId: 'longbridge' });
await expect(pending).rejects.toMatchObject({ code: 'LOGIN_CANCELLED' });
expect(connectionUpdates.at(-1)).toMatchObject({ status: 'not-connected' });
host.dispose();
});

it('returns the manual URL while the device flow continues, then cancels it', async () => {
const host = new AgentKernelHost();
let finished!: (outcome: LoginOutcome) => void;
loginHandler = async ({ onVerificationUri }) => {
onVerificationUri?.('https://example.test/authorize');
return new Promise((resolve) => { finished = resolve; });
};
await expect(host.connectProvider({ providerId: 'longbridge' })).resolves.toEqual({
status: 'connecting', verificationUrl: 'https://example.test/authorize',
});
finished({ status: 'cancelled' });
await new Promise((resolve) => setTimeout(resolve, 0));
expect(connectionUpdates.at(-1)).toMatchObject({ status: 'not-connected' });
host.dispose();
});

it('builds the kernel on the electron userData store', () => {
const host = new AgentKernelHost();

Expand Down
96 changes: 65 additions & 31 deletions apps/electron/src/main/kernelHost.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1011,7 +1011,7 @@ export class AgentKernelHost {
});
}
for (const info of credentialInfos) {
if (info.custom || byProvider.has(info.provider)) continue;
if (info.custom || info.provider.startsWith('custom:') || info.provider === 'runtime' || byProvider.has(info.provider)) continue;
statuses.push({
provider: info.provider,
displayName: info.provider,
Expand Down Expand Up @@ -1738,6 +1738,10 @@ export class AgentKernelHost {
this.providerRouter.coverage().find((c) => c.providerId === provider.id) ?? null;
const config = await this.connectionStore.getConfig(provider.id);
const routing = await this.connectionStore.getRouting(DEFAULT_PROVIDER_ROUTING);
// Installation and interrupted-login state can change outside this process.
// A saved not-installed result must not hide a newly installed CLI.
const useLiveHealth = (state?.status === 'connecting' && !this.activeLogin)
|| (state?.status === 'not-installed' && health !== null && health.status !== 'not-installed');
let hasAccount = false;
let accountLabel: string | null = null;
if (provider.kind === 'broker-account') {
Expand All @@ -1755,7 +1759,9 @@ export class AgentKernelHost {
providerId: provider.id,
kind: provider.kind,
name: provider.name,
status: state?.status ?? health?.status ?? 'not-connected',
status: useLiveHealth
? health?.status ?? 'not-connected'
: state?.status ?? health?.status ?? 'not-connected',
health,
coverage,
configurable: provider.id === 'massive',
Expand All @@ -1765,7 +1771,7 @@ export class AgentKernelHost {
: health?.status === 'connected',
hasAccount,
accountLabel,
error: state?.error ?? null,
error: useLiveHealth ? null : state?.error ?? null,
enabled: config?.enabled !== false,
endpoint: config?.endpoint ?? null,
region: config?.region ?? health?.region ?? null,
Expand All @@ -1792,14 +1798,19 @@ export class AgentKernelHost {
if (this.activeLogin) {
throw createCodeError('LOGIN_IN_PROGRESS', 'A Longbridge login is already in progress.');
}
await this.connectionStore.update({ providerId, status: 'connecting', lastCheck: Date.now() });

const controller = new AbortController();
const login = { cancel: () => controller.abort() };
this.activeLogin = login;
await this.connectionStore.update({ providerId, status: 'connecting', lastCheck: Date.now() });
let resolveUrl: (uri: string | undefined) => void;
const urlPromise = new Promise<string | undefined>((resolve) => {
resolveUrl = resolve;
});
const urlTimer = setTimeout(() => resolveUrl(undefined), 30_000);
let startupTimedOut = false;
const urlTimer = setTimeout(() => {
startupTimedOut = true;
controller.abort();
}, 30_000);

const outcomePromise = startLogin({
exec: executeLongBridgeCli,
Expand All @@ -1811,21 +1822,17 @@ export class AgentKernelHost {
},
signal: controller.signal,
});
this.activeLogin = {
cancel: () => controller.abort(),
};

const verificationUrl = await urlPromise;
if (!verificationUrl) {
controller.abort();
this.activeLogin = null;
throw createCodeError('LOGIN_START_FAILED', 'Could not start the Longbridge authorization flow.');
}

// The device flow continues in the background; completion updates the
// connection store and pushes `connections:changed` to the renderer.
void outcomePromise
const completion = outcomePromise
.then(async (outcome) => {
this.marketData.clear();
if (startupTimedOut) {
outcome = { status: 'error', error: {
code: 'LOGIN_START_FAILED',
message: 'Longbridge did not provide an authorization URL. Check the CLI and network, then retry.',
} };
}
if (outcome.status === 'connected') {
await this.connectionStore.update({
providerId,
Expand All @@ -1838,25 +1845,42 @@ export class AgentKernelHost {
} else {
await this.connectionStore.update({
providerId,
status: 'error',
status: outcome.status === 'error' && outcome.error.code === 'LONGBRIDGE_NOT_INSTALLED'
? 'not-installed' : 'error',
lastCheck: Date.now(),
error: { code: 'LOGIN_TIMEOUT', message: 'Authorization timed out. Try again.' },
error: outcome.status === 'error' ? outcome.error
: { code: 'LOGIN_TIMEOUT', message: 'Authorization timed out. Try again.' },
});
}
this.activeLogin = null;
void this.pushConnections();
return outcome;
})
.catch(() => {
this.activeLogin = null;
.finally(() => {
clearTimeout(urlTimer);
resolveUrl(undefined);
if (this.activeLogin === login) this.activeLogin = null;
void this.pushConnections();
});

// Subscribe to completion immediately: missing binaries / CLI failures
// must resolve the request instead of leaving a persisted Connecting card.
void completion.catch(() => undefined);
const verificationUrl = await urlPromise;
if (!verificationUrl) {
const outcome = await completion;
if (outcome.status === 'connected') return { status: 'connected' };
const error = outcome.status === 'error' ? outcome.error
: { code: 'LOGIN_CANCELLED', message: 'Longbridge authorization was cancelled.' };
throw createCodeError(error.code, error.message);
}
return { status: 'connecting', verificationUrl };
}

async cancelConnectProvider(input: unknown): Promise<void> {
requireObject(input);
this.activeLogin?.cancel();
const request = requireObject(input);
const providerId = requireString(request.providerId, 'providerId');
if (providerId !== 'longbridge') throw createCodeError('CONFIG_UNSUPPORTED', 'Unsupported login provider.');
if (this.activeLogin) this.activeLogin.cancel();
else await this.connectionStore.update({ providerId, status: 'not-connected', lastCheck: Date.now() });
}

async disconnectProvider(input: unknown): Promise<ConnectionEntry | null> {
Expand Down Expand Up @@ -2531,11 +2555,17 @@ export class AgentKernelHost {
baseUrl: provider.baseUrl,
apiKey: provider.apiKey,
api: provider.api,
models: provider.models,
// Most third-party OpenAI-compatible gateways still reject the
// newer `developer` role. Use `system` unless a provider explicitly
// opts into developer messages.
models: provider.models.map((model) => ({
...model,
compat: { supportsDeveloperRole: false, ...(model.compat ?? {}) },
})),
});
}
for (const info of await this.credentials.listCredentials()) {
if (info.custom) continue;
if (info.custom || info.provider.startsWith('custom:') || info.provider === 'runtime') continue;
const apiKey = await this.credentials.getCredential(info.provider);
if (!apiKey) continue;
overrides.push({ provider: info.provider, apiKey });
Expand Down Expand Up @@ -2649,10 +2679,14 @@ const executeLongBridgeCli: LongbridgeExec = (args, options) =>
executeLongBridge(args, { timeout: options?.timeout });

/** Spawn the Longbridge CLI; stdout chunks stream to the login flow. */
const spawnLongbridge: SpawnFn = (args, onStdout) => {
const spawnLongbridge: SpawnFn = (args, onOutput, onError) => {
const child = spawn('longbridge', args, { stdio: ['ignore', 'pipe', 'pipe'] });
child.stdout.on('data', (chunk: Buffer) => onStdout(chunk.toString()));
child.stderr.resume();
child.stdout.on('data', (chunk: Buffer) => onOutput(chunk.toString()));
child.stderr.on('data', (chunk: Buffer) => onOutput(chunk.toString()));
child.on('error', onError);
child.on('close', (code) => {
if (code !== 0) onError(new Error('Longbridge login exited before authorization completed.'));
});
return { kill: () => child.kill() };
};

Expand Down
Loading
Loading