Skip to content
Open
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
61 changes: 49 additions & 12 deletions apps/electron/src/main/kernelHost.test.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import { afterEach, beforeEach, describe, expect, it, mock } from 'bun:test';
import { join } from 'node:path';
import type { AgentEvent } from '@finagent/core';
import type { AgentEvent, AutomationRule } from '@finagent/core';

let lastKernelOptions: Record<string, unknown> | null = null;
let lastMarketData: FakeMarketDataService | null = null;
let lastAutomationContext: unknown = null;
let forwardedEvents: unknown[] = [];
const routerFetchers = { getQuote: async () => ({ symbol: 'AAPL.US' }) };

Expand All @@ -21,7 +22,13 @@ class FakeMarketDataService {
}

async getPortfolio() {
return { totalValue: 1000, cash: 100, positions: [] };
return {
totalAssets: 1000,
cash: 100,
accounts: [],
holdings: [{ symbol: 'AAPL.US', name: 'Apple Inc.' }],
fetchedAt: 1_700_000_000_000,
};
}

async getLongBridgeStatus() {
Expand Down Expand Up @@ -242,16 +249,19 @@ mock.module('@finagent/shared', () => ({
summary: '',
quiet: { count: 0, message: '' },
}),
runAutomation: async () => ({
id: 'run',
ruleId: 'rule',
ranAt: 0,
evaluated: 0,
materialChanges: 0,
analyzed: 0,
notified: false,
failures: [],
}),
runAutomation: async (_rule: unknown, context: unknown) => {
lastAutomationContext = context;
return {
id: 'run',
ruleId: 'rule',
ranAt: 0,
evaluated: 0,
materialChanges: 0,
analyzed: 0,
notified: false,
failures: [],
};
},
runDue: () => [],
DEFAULT_BRIEF_HOUR: 16.5,
THESIS_REVIEW_DAY: 0,
Expand Down Expand Up @@ -340,6 +350,7 @@ const originalPiExtension = process.env.FINAGENT_PI_EXTENSION;
beforeEach(() => {
lastKernelOptions = null;
lastMarketData = null;
lastAutomationContext = null;
forwardedEvents = [];
});

Expand Down Expand Up @@ -474,6 +485,32 @@ describe('AgentKernelHost', () => {
host.dispose();
});

it('passes the fetched portfolio scope and timestamp to the automation runner', async () => {
const host = new AgentKernelHost();
const rule: AutomationRule = {
id: 'portfolio-rule',
type: 'portfolio-daily-brief',
enabled: true,
notify: 'material-only',
createdAt: 1_700_000_000_000,
};
const executeAutomation = (
host as unknown as { executeAutomation: (automationRule: AutomationRule) => Promise<unknown> }
).executeAutomation.bind(host);

await executeAutomation(rule);

const context = lastAutomationContext as {
portfolioSnapshot?: () => Promise<{ symbols: string[]; fetchedAt: number } | null>;
} | null;
expect(context?.portfolioSnapshot).toBeFunction();
await expect(context?.portfolioSnapshot?.()).resolves.toEqual({
symbols: ['AAPL.US'],
fetchedAt: 1_700_000_000_000,
});
host.dispose();
});

it('wraps market data errors into IPC results', async () => {
const host = new AgentKernelHost();
const { toIpcResult } = await import('./kernelHost.ts');
Expand Down
9 changes: 6 additions & 3 deletions apps/electron/src/main/kernelHost.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2378,12 +2378,15 @@ export class AgentKernelHost {
locale: await this.effectiveRunLocale(),
researchStart: async (symbol, strategyId) => this.researchService.start(symbol, strategyId, await this.effectiveRunLocale()),
notify: (event) => void this.dispatchNotification(event),
portfolioSymbols: async () => {
portfolioSnapshot: async () => {
try {
const snapshot = await this.marketData.getPortfolio();
return (snapshot.holdings ?? []).map((holding) => holding.symbol);
return {
symbols: (snapshot.holdings ?? []).map((holding) => holding.symbol),
fetchedAt: snapshot.fetchedAt,
};
} catch {
return [];
return null;
}
},
thesisSymbols: async () => {
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
18 changes: 18 additions & 0 deletions packages/core/src/automation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,20 @@ export interface AutomationRule {
}

/** One execution of an automation rule. */
export type AutomationRunOutcome = 'material_update' | 'no_material_update' | 'incomplete';

export type AutomationScopeKind = 'rule' | 'hook' | 'watchlist' | 'portfolio' | 'thesis';

/** Minimal, immutable record of the securities a run evaluated. */
export interface AutomationScopeSnapshot {
kind: AutomationScopeKind;
symbols: string[];
/** When this run captured the scope. */
capturedAt: number;
/** Source snapshot timestamp, when supplied by the portfolio provider. */
sourceFetchedAt?: number;
}

export interface AutomationRun {
id: string;
ruleId: string;
Expand All @@ -44,6 +58,10 @@ export interface AutomationRun {
analyzed: number;
notified: boolean;
failures: string[];
/** Older persisted runs omit this field. */
outcome?: AutomationRunOutcome;
/** The scope is frozen at execution time; older runs omit this field. */
scopeSnapshot?: AutomationScopeSnapshot;
}

/** Material-change signals, first version (spec §25) — deterministic, never LLM-per-minute. */
Expand Down
5 changes: 5 additions & 0 deletions packages/i18n/src/locales/en-US/automation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,11 @@ export const automation = {
running: 'Running…',
noRunsYet: 'No runs yet',
lastRun: 'Last run {{when}} · {{evaluated}} evaluated, {{material}} material',
outcome: {
materialUpdate: 'Material changes found',
noMaterialUpdate: 'No material changes',
incomplete: 'Run incomplete',
},
},
schedule: {
daily: 'Daily',
Expand Down
5 changes: 5 additions & 0 deletions packages/i18n/src/locales/zh-CN/automation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,11 @@ export const automation = {
running: '运行中…',
noRunsYet: '尚无运行记录',
lastRun: '上次运行 {{when}} · 评估 {{evaluated}} 项,其中 {{material}} 项重要变化',
outcome: {
materialUpdate: '发现实质变化',
noMaterialUpdate: '未发现实质变化',
incomplete: '运行未完成',
},
},
schedule: {
daily: '每日',
Expand Down
51 changes: 51 additions & 0 deletions packages/shared/src/automation/brief.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -190,4 +190,55 @@ describe('buildBrief', () => {
)
expect(brief.items).toEqual([])
})

it('counts the frozen scope of today’s no-material run as quiet without adding an attention item', () => {
const now = 1_700_000_000_000
const noMaterialRun: AutomationRun = {
...run('quiet-run', 0, false, 2),
ranAt: now,
outcome: 'no_material_update',
scopeSnapshot: {
kind: 'portfolio',
symbols: ['AAPL.US', 'MSFT.US'],
capturedAt: now,
sourceFetchedAt: now - 1_000,
},
}

const brief = buildBrief(inputs({ runs: [noMaterialRun] }), now)

expect(brief.items).toEqual([])
expect(brief.quiet).toEqual({
count: 2,
message: '2 monitored securities: no material change',
})
})

it('uses only the latest run per rule for today’s quiet scope', () => {
const now = 1_700_000_000_000
const earlierNoMaterialRun: AutomationRun = {
...run('earlier', 0, false, 1),
ranAt: now,
outcome: 'no_material_update',
scopeSnapshot: {
kind: 'portfolio',
symbols: ['AAPL.US'],
capturedAt: now,
},
}
const latestIncompleteRun: AutomationRun = {
...run('latest', 0, false, 1),
ranAt: now + 1,
outcome: 'incomplete',
scopeSnapshot: {
kind: 'portfolio',
symbols: ['MSFT.US'],
capturedAt: now + 1,
},
}

const brief = buildBrief(inputs({ runs: [earlierNoMaterialRun, latestIncompleteRun] }), now + 1)

expect(brief.quiet).toEqual({ count: 0, message: 'No monitored securities.' })
})
})
23 changes: 22 additions & 1 deletion packages/shared/src/automation/brief.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,14 @@ export function buildBrief(inputs: BriefInputs, now: number = Date.now()): Daily
]
items.sort(compareItems)

const monitored = union([...inputs.movers.map((m) => m.symbol), ...inputs.diffs.map((d) => d.symbol)])
const noMaterialRunSymbols = latestRunsForCurrentDay(inputs.runs, now)
.filter((run) => run.outcome === 'no_material_update')
.flatMap((run) => run.scopeSnapshot?.symbols ?? [])
const monitored = union([
...inputs.movers.map((m) => m.symbol),
...inputs.diffs.map((d) => d.symbol),
...noMaterialRunSymbols,
])
const materialSymbols = union([
...inputs.movers
.filter((m) => Math.abs(m.changePercent) >= MATERIAL_PRICE_MOVE_PCT)
Expand Down Expand Up @@ -211,6 +218,20 @@ function automationItems(inputs: BriefInputs): BriefItem[] {
}))
}

/** Use only the latest successful run per rule on the brief's local calendar day. */
function latestRunsForCurrentDay(runs: AutomationRun[], now: number): AutomationRun[] {
const today = new Date(now).toDateString()
const sorted = runs
.filter((run) => new Date(run.ranAt).toDateString() === today)
.slice()
.sort((a, b) => b.ranAt - a.ranAt || a.id.localeCompare(b.id))
const latestByRule = new Map<string, AutomationRun>()
for (const run of sorted) {
if (!latestByRule.has(run.ruleId)) latestByRule.set(run.ruleId, run)
}
return [...latestByRule.values()]
}

function compareItems(a: BriefItem, b: BriefItem): number {
const severityDiff = SEVERITY_RANK[a.severity] - SEVERITY_RANK[b.severity]
if (severityDiff !== 0) return severityDiff
Expand Down
22 changes: 22 additions & 0 deletions packages/shared/src/automation/rules-repository.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,4 +100,26 @@ describe('AutomationRunRepository', () => {
expect((await repo.listByRule('r2')).map((r) => r.id)).toEqual(['run-2'])
expect(await repo.listByRule('r3')).toEqual([])
})

it('persists the no-material outcome and captured portfolio scope across restarts', async () => {
const noMaterialRun: AutomationRun = {
...run('run-quiet', 'portfolio-rule', 1_700_000_000_000),
evaluated: 2,
materialChanges: 0,
analyzed: 0,
notified: false,
outcome: 'no_material_update',
scopeSnapshot: {
kind: 'portfolio',
symbols: ['AAPL.US', 'MSFT.US'],
capturedAt: 1_700_000_000_000,
sourceFetchedAt: 1_699_999_000_000,
},
}
await new AutomationRunRepository(store).record(noMaterialRun)

const fresh = new AutomationRunRepository(new JsonFileStore(dir))

expect(await fresh.listByRule('portfolio-rule')).toEqual([noMaterialRun])
})
})
68 changes: 68 additions & 0 deletions packages/shared/src/automation/runner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,71 @@ describe('runAutomation scope resolution', () => {
).toBe(1)
})

it('records a complete no-material-change run against the captured portfolio scope', async () => {
const { context, researchCalls, notifications } = makeContext({
quotes: { 'AAPL.US': quote(100, 100), 'MSFT.US': quote(50, 50) },
})
context.portfolioSnapshot = async () => ({
symbols: ['aapl.us', 'MSFT.US', 'AAPL.US', ' '],
fetchedAt: 1_699_999_000_000,
})

const run = await runAutomation(rule({ type: 'portfolio-daily-brief' }), context)

expect(run.outcome).toBe('no_material_update')
expect(run.scopeSnapshot).toEqual({
kind: 'portfolio',
symbols: ['AAPL.US', 'MSFT.US'],
capturedAt: 1_700_000_000_000,
sourceFetchedAt: 1_699_999_000_000,
})
expect(run.evaluated).toBe(2)
expect(run.failures).toEqual([])
expect(researchCalls).toEqual([])
expect(notifications).toEqual([])
})

it('marks a run incomplete when the captured portfolio cannot be fully evaluated', async () => {
const { context } = makeContext({ quotes: { 'AAPL.US': quote(100, 100) } })
context.portfolioSnapshot = async () => ({
symbols: ['AAPL.US', 'MSFT.US'],
fetchedAt: 1_699_999_000_000,
})

const run = await runAutomation(rule({ type: 'portfolio-daily-brief' }), context)

expect(run.outcome).toBe('incomplete')
expect(run.evaluated).toBe(1)
expect(run.failures).toEqual(['MSFT.US: quote unavailable'])
expect(run.scopeSnapshot?.symbols).toEqual(['AAPL.US', 'MSFT.US'])
})

it('keeps an unavailable portfolio snapshot out of the no-change path', async () => {
const { context } = makeContext({})
context.portfolioSnapshot = async () => null

const run = await runAutomation(rule({ type: 'portfolio-daily-brief' }), context)

expect(run.outcome).toBe('incomplete')
expect(run.failures).toEqual(['portfolio snapshot unavailable'])
expect(run.scopeSnapshot).toMatchObject({ kind: 'portfolio', symbols: [] })
})

it('does not report no material change without a valid previous close', async () => {
const noBaseline = { ...quote(100, 100), prevClose: 0 }
const { context } = makeContext({ quotes: { 'AAPL.US': noBaseline } })
context.portfolioSnapshot = async () => ({
symbols: ['AAPL.US'],
fetchedAt: 1_699_999_000_000,
})

const run = await runAutomation(rule({ type: 'portfolio-daily-brief' }), context)

expect(run.evaluated).toBe(1)
expect(run.outcome).toBe('incomplete')
expect(run.failures).toEqual(['AAPL.US: previous close unavailable'])
})

it('prefers rule.symbols over the type provider', async () => {
const { context } = makeContext({ quotes: { 'AAPL.US': quote(100, 100) } })
context.watchlistSymbols = async () => ['MSFT.US']
Expand All @@ -204,6 +269,7 @@ describe('runAutomation scope resolution', () => {
const { context } = makeContext({ quotes: { 'AAPL.US': quote(100, 100) } })
const run = await runAutomation(rule({}), context)
expect(run.evaluated).toBe(0)
expect(run.outcome).toBe('incomplete')
expect(run.failures).toEqual(['no symbols in scope for watchlist-daily-review'])
})
})
Expand Down Expand Up @@ -296,6 +362,7 @@ describe('runAutomation material filter', () => {
expect(run.evaluated).toBe(1)
expect(run.materialChanges).toBe(1)
expect(run.failures).toEqual(['MSFT.US: quote unavailable'])
expect(run.outcome).toBe('incomplete')
expect(researchCalls).toEqual(['AAPL.US'])
})

Expand All @@ -318,6 +385,7 @@ describe('runAutomation notify semantics', () => {
const run = await runAutomation(rule({ notify: 'all' }), context)
expect(run.notified).toBe(true)
expect(run.materialChanges).toBe(0)
expect(run.outcome).toBe('no_material_update')
expect(researchCalls).toEqual([])
expect(notifications.map((n) => n.severity)).toEqual(['info', 'info'])
expect(notifications.map((n) => n.symbol)).toEqual(['AAPL.US', 'MSFT.US'])
Expand Down
Loading
Loading