From 2af26b9324e04e418ac9bd4bfe7728da9229d59b Mon Sep 17 00:00:00 2001 From: CC1227871 <2812624878@qq.com> Date: Sun, 20 Sep 2026 11:54:35 +0800 Subject: [PATCH 1/2] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=EF=BC=9A=E8=A1=A5?= =?UTF-8?q?=E9=BD=90=E7=BB=84=E5=90=88=E6=83=85=E6=8A=A5=E6=97=A0=E5=AE=9E?= =?UTF-8?q?=E8=B4=A8=E5=8F=98=E5=8C=96=E8=BF=90=E8=A1=8C=E8=B7=AF=E5=BE=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/electron/src/main/kernelHost.test.ts | 61 ++++++++-- apps/electron/src/main/kernelHost.ts | 9 +- packages/core/src/automation.ts | 18 +++ packages/i18n/src/locales/en-US/automation.ts | 5 + packages/i18n/src/locales/zh-CN/automation.ts | 5 + packages/shared/src/automation/brief.test.ts | 51 ++++++++ packages/shared/src/automation/brief.ts | 23 +++- .../src/automation/rules-repository.test.ts | 22 ++++ packages/shared/src/automation/runner.test.ts | 68 +++++++++++ packages/shared/src/automation/runner.ts | 111 +++++++++++++++--- .../automation/AutomationRulesView.test.tsx | 45 ++++++- .../ui/src/components/automation/RuleCard.tsx | 20 +++- 12 files changed, 400 insertions(+), 38 deletions(-) diff --git a/apps/electron/src/main/kernelHost.test.ts b/apps/electron/src/main/kernelHost.test.ts index d002a7e9..0a5684e3 100644 --- a/apps/electron/src/main/kernelHost.test.ts +++ b/apps/electron/src/main/kernelHost.test.ts @@ -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 | null = null; let lastMarketData: FakeMarketDataService | null = null; +let lastAutomationContext: unknown = null; let forwardedEvents: unknown[] = []; const routerFetchers = { getQuote: async () => ({ symbol: 'AAPL.US' }) }; @@ -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() { @@ -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, @@ -340,6 +350,7 @@ const originalPiExtension = process.env.FINAGENT_PI_EXTENSION; beforeEach(() => { lastKernelOptions = null; lastMarketData = null; + lastAutomationContext = null; forwardedEvents = []; }); @@ -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 } + ).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'); diff --git a/apps/electron/src/main/kernelHost.ts b/apps/electron/src/main/kernelHost.ts index 20290cfb..63bea455 100644 --- a/apps/electron/src/main/kernelHost.ts +++ b/apps/electron/src/main/kernelHost.ts @@ -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 () => { diff --git a/packages/core/src/automation.ts b/packages/core/src/automation.ts index 8879ed25..79c9de7f 100644 --- a/packages/core/src/automation.ts +++ b/packages/core/src/automation.ts @@ -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; @@ -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. */ diff --git a/packages/i18n/src/locales/en-US/automation.ts b/packages/i18n/src/locales/en-US/automation.ts index 952b1c4c..65da89a8 100644 --- a/packages/i18n/src/locales/en-US/automation.ts +++ b/packages/i18n/src/locales/en-US/automation.ts @@ -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', diff --git a/packages/i18n/src/locales/zh-CN/automation.ts b/packages/i18n/src/locales/zh-CN/automation.ts index 307641a2..a9f71d76 100644 --- a/packages/i18n/src/locales/zh-CN/automation.ts +++ b/packages/i18n/src/locales/zh-CN/automation.ts @@ -23,6 +23,11 @@ export const automation = { running: '运行中…', noRunsYet: '尚无运行记录', lastRun: '上次运行 {{when}} · 评估 {{evaluated}} 项,其中 {{material}} 项重要变化', + outcome: { + materialUpdate: '发现实质变化', + noMaterialUpdate: '未发现实质变化', + incomplete: '运行未完成', + }, }, schedule: { daily: '每日', diff --git a/packages/shared/src/automation/brief.test.ts b/packages/shared/src/automation/brief.test.ts index 19623825..5932bc66 100644 --- a/packages/shared/src/automation/brief.test.ts +++ b/packages/shared/src/automation/brief.test.ts @@ -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.' }) + }) }) diff --git a/packages/shared/src/automation/brief.ts b/packages/shared/src/automation/brief.ts index 01375bee..5809ff9c 100644 --- a/packages/shared/src/automation/brief.ts +++ b/packages/shared/src/automation/brief.ts @@ -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) @@ -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() + 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 diff --git a/packages/shared/src/automation/rules-repository.test.ts b/packages/shared/src/automation/rules-repository.test.ts index 80543b07..24eff3b1 100644 --- a/packages/shared/src/automation/rules-repository.test.ts +++ b/packages/shared/src/automation/rules-repository.test.ts @@ -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]) + }) }) diff --git a/packages/shared/src/automation/runner.test.ts b/packages/shared/src/automation/runner.test.ts index 20c92f6e..7ef3d872 100644 --- a/packages/shared/src/automation/runner.test.ts +++ b/packages/shared/src/automation/runner.test.ts @@ -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'] @@ -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']) }) }) @@ -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']) }) @@ -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']) diff --git a/packages/shared/src/automation/runner.ts b/packages/shared/src/automation/runner.ts index 571f3825..d71a0d4a 100644 --- a/packages/shared/src/automation/runner.ts +++ b/packages/shared/src/automation/runner.ts @@ -2,6 +2,8 @@ import { randomUUID } from 'node:crypto' import type { AutomationRule, AutomationRun, + AutomationScopeKind, + AutomationScopeSnapshot, CalendarEvent, CapabilityRegistry, NotificationEvent, @@ -59,6 +61,8 @@ export interface AutomationRunContext { /** Scope providers — the kernel host wires these (UI atoms / stored scope). */ watchlistSymbols?: () => string[] | Promise portfolioSymbols?: () => string[] | Promise + /** A portfolio scope plus the timestamp of the source snapshot it came from. */ + portfolioSnapshot?: () => Promise<{ symbols: string[]; fetchedAt: number } | null> thesisSymbols?: () => string[] | Promise /** Earnings-event hook scope for pre/post-earnings rules. */ symbols?: string[] @@ -77,11 +81,13 @@ export async function runAutomation( ): Promise { const ranAt = ctx.now?.() ?? Date.now() const id = ctx.idGen?.() ?? randomUUID() - const symbols = await resolveScope(rule, ctx) + const resolvedScope = await resolveScope(rule, ctx, ranAt) + const symbols = resolvedScope.snapshot.symbols const failures: string[] = [] + if (resolvedScope.failure !== undefined) failures.push(resolvedScope.failure) if (symbols.length === 0) { - failures.push(`no symbols in scope for ${rule.type}`) + if (resolvedScope.failure === undefined) failures.push(`no symbols in scope for ${rule.type}`) } let evaluated = 0 @@ -91,24 +97,40 @@ export async function runAutomation( for (const raw of symbols) { const symbol = raw.trim().toUpperCase() - const outcome = await evaluateSymbol(symbol, ctx) - if (outcome === null) { + let evaluation: { signals: MaterialSignals; quote: Quote } | null + try { + evaluation = await evaluateSymbol(symbol, ctx) + } catch { + failures.push(`${symbol}: evaluation failed`) + continue + } + if (evaluation === null) { failures.push(`${symbol}: quote unavailable`) continue } evaluated += 1 - const material = signalsAreMaterial(outcome.signals) + if (evaluation.signals.priceMovePct === undefined) { + failures.push(`${symbol}: previous close unavailable`) + } + const material = signalsAreMaterial(evaluation.signals) if (material) { materialChanges += 1 analyzed += 1 await ctx.researchStart(symbol, rule.strategyId) } if (rule.notify === 'all' || material) { - await ctx.notify?.(notificationFor(rule, symbol, material, outcome.signals, ranAt, ctx.locale)) + await ctx.notify?.(notificationFor(rule, symbol, material, evaluation.signals, ranAt, ctx.locale)) notified = true } } + const complete = symbols.length > 0 && evaluated === symbols.length && failures.length === 0 + const outcome: AutomationRun['outcome'] = complete + ? materialChanges > 0 + ? 'material_update' + : 'no_material_update' + : 'incomplete' + return { id, ruleId: rule.id, @@ -118,22 +140,83 @@ export async function runAutomation( analyzed, notified, failures, + outcome, + scopeSnapshot: resolvedScope.snapshot, } } /** Symbols the rule monitors: rule override → hook scope → type providers. */ -async function resolveScope(rule: AutomationRule, ctx: AutomationRunContext): Promise { - if (rule.symbols !== undefined && rule.symbols.length > 0) return rule.symbols - if (ctx.symbols !== undefined && ctx.symbols.length > 0) return ctx.symbols +async function resolveScope( + rule: AutomationRule, + ctx: AutomationRunContext, + capturedAt: number +): Promise<{ snapshot: AutomationScopeSnapshot; failure?: string }> { + if (rule.symbols !== undefined && rule.symbols.length > 0) { + return { snapshot: makeScopeSnapshot('rule', rule.symbols, capturedAt) } + } + if (ctx.symbols !== undefined && ctx.symbols.length > 0) { + return { snapshot: makeScopeSnapshot('hook', ctx.symbols, capturedAt) } + } switch (rule.type) { case 'watchlist-daily-review': - return (await ctx.watchlistSymbols?.()) ?? [] - case 'portfolio-daily-brief': - return (await ctx.portfolioSymbols?.()) ?? [] + return resolveProviderScope('watchlist', ctx.watchlistSymbols, capturedAt) + case 'portfolio-daily-brief': { + if (ctx.portfolioSnapshot !== undefined) { + try { + const portfolio = await ctx.portfolioSnapshot() + if (portfolio === null || !Number.isFinite(portfolio.fetchedAt)) { + return { + snapshot: makeScopeSnapshot('portfolio', [], capturedAt), + failure: 'portfolio snapshot unavailable', + } + } + return { + snapshot: makeScopeSnapshot('portfolio', portfolio.symbols, capturedAt, portfolio.fetchedAt), + } + } catch { + return { + snapshot: makeScopeSnapshot('portfolio', [], capturedAt), + failure: 'portfolio snapshot unavailable', + } + } + } + return resolveProviderScope('portfolio', ctx.portfolioSymbols, capturedAt) + } case 'weekly-thesis-review': - return (await ctx.thesisSymbols?.()) ?? [] + return resolveProviderScope('thesis', ctx.thesisSymbols, capturedAt) default: - return [] + return { snapshot: makeScopeSnapshot('hook', [], capturedAt) } + } +} + +function resolveProviderScope( + kind: AutomationScopeKind, + provider: (() => string[] | Promise) | undefined, + capturedAt: number +): Promise<{ snapshot: AutomationScopeSnapshot; failure?: string }> { + return Promise.resolve() + .then(() => provider?.() ?? []) + .then((symbols) => ({ snapshot: makeScopeSnapshot(kind, symbols, capturedAt) })) + .catch(() => ({ + snapshot: makeScopeSnapshot(kind, [], capturedAt), + failure: `${kind} scope unavailable`, + })) +} + +function makeScopeSnapshot( + kind: AutomationScopeKind, + symbols: string[], + capturedAt: number, + sourceFetchedAt?: number +): AutomationScopeSnapshot { + const normalizedSymbols = [ + ...new Set(symbols.map((symbol) => symbol.trim().toUpperCase()).filter(Boolean)), + ].sort() + return { + kind, + symbols: normalizedSymbols, + capturedAt, + ...(sourceFetchedAt !== undefined ? { sourceFetchedAt } : {}), } } diff --git a/packages/ui/src/components/automation/AutomationRulesView.test.tsx b/packages/ui/src/components/automation/AutomationRulesView.test.tsx index 8fa32f10..98716994 100644 --- a/packages/ui/src/components/automation/AutomationRulesView.test.tsx +++ b/packages/ui/src/components/automation/AutomationRulesView.test.tsx @@ -41,13 +41,17 @@ interface SaveRecord { calls: number } -function clientWithRules(options: { save?: (rule: AutomationRule) => void } = {}): { +function clientWithRules(options: { + save?: (rule: AutomationRule) => void + run?: AutomationRun +} = {}): { client: FinagentClient saveCalls: SaveRecord[] runRuleCalls: string[] } { const saveCalls: SaveRecord[] = [] const runRuleCalls: string[] = [] + const currentRun = options.run ?? RUN const client: FinagentClient = { ...fallbackClient, automation: { @@ -60,9 +64,9 @@ function clientWithRules(options: { save?: (rule: AutomationRule) => void } = {} removeRule: async () => ({ ok: true, data: undefined }), runRule: async (input: { ruleId: string }) => { runRuleCalls.push(input.ruleId) - return { ok: true, data: RUN } + return { ok: true, data: currentRun } }, - listRuns: async () => ({ ok: true, data: [RUN] }), + listRuns: async () => ({ ok: true, data: [currentRun] }), buildBrief: async () => ({ ok: false, error: { code: 'NONE', message: 'missing' } }), }, } as unknown as FinagentClient @@ -115,6 +119,41 @@ describe('AutomationRulesView', () => { container.remove() }) + it('shows an explicit no-material-change outcome for a completed run', async () => { + const quietRun: AutomationRun = { + ...RUN, + materialChanges: 0, + analyzed: 0, + notified: false, + outcome: 'no_material_update', + scopeSnapshot: { + kind: 'portfolio', + symbols: ['AAPL.US', 'MSFT.US'], + capturedAt: RUN.ranAt, + }, + } + const { client } = clientWithRules({ run: quietRun }) + const { container, root } = render(client) + + await act(async () => { + root.render( + withI18n( + + + + ) + ) + }) + await flushAsync() + + expect(container.textContent ?? '').toContain('No material changes') + + await act(async () => { + root.unmount() + }) + container.remove() + }) + it('toggle calls saveRule with the flipped rule', async () => { const { client, saveCalls } = clientWithRules() const { container, root } = render(client) diff --git a/packages/ui/src/components/automation/RuleCard.tsx b/packages/ui/src/components/automation/RuleCard.tsx index 025e1693..1165d894 100644 --- a/packages/ui/src/components/automation/RuleCard.tsx +++ b/packages/ui/src/components/automation/RuleCard.tsx @@ -78,6 +78,13 @@ export const RuleCard: React.FC = ({ }) => { const { t } = useTranslation() const label = t(AUTOMATION_TYPE_KEYS[rule.type]) + const outcomeKey = lastRun?.outcome === 'material_update' + ? 'automation.run.outcome.materialUpdate' + : lastRun?.outcome === 'no_material_update' + ? 'automation.run.outcome.noMaterialUpdate' + : lastRun?.outcome === 'incomplete' + ? 'automation.run.outcome.incomplete' + : null return (
@@ -124,11 +131,14 @@ export const RuleCard: React.FC = ({
{lastRun !== undefined - ? t('automation.run.lastRun', { - when: formatWhen(lastRun.ranAt, t), - evaluated: lastRun.evaluated, - material: lastRun.materialChanges, - }) + ? <> + {t('automation.run.lastRun', { + when: formatWhen(lastRun.ranAt, t), + evaluated: lastRun.evaluated, + material: lastRun.materialChanges, + })} + {outcomeKey !== null && · {t(outcomeKey)}} + : t('automation.run.noRunsYet')}