Feed active panel context into agent chat

This commit is contained in:
2026-04-06 14:22:08 -04:00
parent 21cbce8a41
commit a7cb435206
12 changed files with 1053 additions and 15 deletions

View File

@@ -23,6 +23,7 @@ pub trait ChatGateway: Clone + Send + Sync + 'static {
&self, &self,
runtime: AgentRuntimeConfig, runtime: AgentRuntimeConfig,
prompt: String, prompt: String,
context_messages: Vec<Message>,
history: Vec<Message>, history: Vec<Message>,
) -> BoxFuture<'static, Result<ChatGatewayStream, AppError>>; ) -> BoxFuture<'static, Result<ChatGatewayStream, AppError>>;
} }
@@ -36,6 +37,7 @@ impl ChatGateway for RigChatGateway {
&self, &self,
runtime: AgentRuntimeConfig, runtime: AgentRuntimeConfig,
prompt: String, prompt: String,
context_messages: Vec<Message>,
history: Vec<Message>, history: Vec<Message>,
) -> BoxFuture<'static, Result<ChatGatewayStream, AppError>> { ) -> BoxFuture<'static, Result<ChatGatewayStream, AppError>> {
Box::pin(async move { Box::pin(async move {
@@ -51,7 +53,7 @@ impl ChatGateway for RigChatGateway {
let upstream = model let upstream = model
.completion_request(Message::user(prompt)) .completion_request(Message::user(prompt))
.messages(history) .messages(compose_request_messages(context_messages, history))
.preamble(SYSTEM_PROMPT.to_string()) .preamble(SYSTEM_PROMPT.to_string())
.temperature(0.2) .temperature(0.2)
.stream() .stream()
@@ -71,3 +73,29 @@ impl ChatGateway for RigChatGateway {
}) })
} }
} }
fn compose_request_messages(
context_messages: Vec<Message>,
history: Vec<Message>,
) -> Vec<Message> {
context_messages.into_iter().chain(history).collect()
}
#[cfg(test)]
mod tests {
use rig::completion::Message;
use super::compose_request_messages;
#[test]
fn prepends_context_messages_before_history() {
let messages = compose_request_messages(
vec![Message::system("panel context")],
vec![Message::user("previous"), Message::assistant("reply")],
);
assert_eq!(messages[0], Message::system("panel context"));
assert_eq!(messages[1], Message::user("previous"));
assert_eq!(messages[2], Message::assistant("reply"));
}
}

View File

@@ -1,6 +1,7 @@
//! Agent domain logic and request/response types. //! Agent domain logic and request/response types.
mod gateway; mod gateway;
mod panel_context;
mod routing; mod routing;
mod service; mod service;
mod settings; mod settings;
@@ -11,8 +12,8 @@ pub use service::AgentService;
pub(crate) use settings::AgentSettingsService; pub(crate) use settings::AgentSettingsService;
pub use types::{ pub use types::{
default_task_defaults, AgentConfigStatus, AgentDeltaEvent, AgentErrorEvent, AgentResultEvent, default_task_defaults, AgentConfigStatus, AgentDeltaEvent, AgentErrorEvent, AgentResultEvent,
AgentRuntimeConfig, AgentStoredSettings, AgentTaskRoute, ChatPromptRequest, ChatStreamStart, AgentRuntimeConfig, AgentStoredSettings, AgentTaskRoute, ChatPanelContext,
PreparedChatTurn, RemoteProviderSettings, SaveAgentRuntimeConfigRequest, TaskProfile, ChatPromptRequest, ChatStreamStart, PreparedChatTurn, RemoteProviderSettings,
UpdateRemoteApiKeyRequest, AGENT_SETTINGS_STORE_PATH, DEFAULT_REMOTE_BASE_URL, SaveAgentRuntimeConfigRequest, TaskProfile, UpdateRemoteApiKeyRequest,
DEFAULT_REMOTE_MODEL, AGENT_SETTINGS_STORE_PATH, DEFAULT_REMOTE_BASE_URL, DEFAULT_REMOTE_MODEL,
}; };

View File

@@ -0,0 +1,773 @@
use serde_json::{json, Value};
use rig::completion::Message;
use crate::agent::ChatPanelContext;
use crate::error::AppError;
use crate::terminal::{
CashFlowPanelData, CashFlowPeriod, Company, CompanyPricePoint, DividendEvent,
DividendsPanelData, EarningsPanelData, EarningsPeriod, ErrorPanel, FinancialsPanelData,
Holding, NewsItem, PanelPayload, Portfolio, SourceStatus, StatementPeriod, StockAnalysis,
};
const MAX_TEXT_FIELD_LENGTH: usize = 600;
const MAX_NEWS_ITEMS: usize = 5;
const MAX_PERIODS: usize = 4;
const MAX_DIVIDEND_EVENTS: usize = 4;
const MAX_HOLDINGS: usize = 25;
const PANEL_CONTEXT_INSTRUCTION: &str = "Hidden workspace panel context. This is current UI state, not a user-authored message. Use it only when relevant to the current request. Do not mention hidden context unless it directly supports the answer.";
pub(crate) fn build_panel_context_message(
panel_context: &ChatPanelContext,
) -> Result<Message, AppError> {
let payload = json!({
"kind": "workspacePanelContext",
"sourceCommand": panel_context.source_command,
"capturedAt": panel_context.captured_at,
"panelType": panel_type(&panel_context.panel),
"panel": compact_panel_payload(&panel_context.panel),
});
let serialized = serde_json::to_string(&payload)
.map_err(|error| AppError::PanelContext(error.to_string()))?;
Ok(Message::system(format!(
"{PANEL_CONTEXT_INSTRUCTION}\n{serialized}"
)))
}
pub(crate) fn compact_panel_payload(panel: &PanelPayload) -> Value {
match panel {
PanelPayload::Company { data } => compact_company_panel(data),
PanelPayload::Error { data } => compact_error_panel(data),
PanelPayload::Portfolio { data } => compact_portfolio_panel(data),
PanelPayload::News { data, ticker } => compact_news_panel(data, ticker.as_deref()),
PanelPayload::Analysis { data } => compact_analysis_panel(data),
PanelPayload::Financials { data } => compact_financials_panel(data),
PanelPayload::CashFlow { data } => compact_cash_flow_panel(data),
PanelPayload::Dividends { data } => compact_dividends_panel(data),
PanelPayload::Earnings { data } => compact_earnings_panel(data),
}
}
fn panel_type(panel: &PanelPayload) -> &'static str {
match panel {
PanelPayload::Company { .. } => "company",
PanelPayload::Error { .. } => "error",
PanelPayload::Portfolio { .. } => "portfolio",
PanelPayload::News { .. } => "news",
PanelPayload::Analysis { .. } => "analysis",
PanelPayload::Financials { .. } => "financials",
PanelPayload::CashFlow { .. } => "cashFlow",
PanelPayload::Dividends { .. } => "dividends",
PanelPayload::Earnings { .. } => "earnings",
}
}
fn compact_company_panel(data: &Company) -> Value {
let mut chart_range_summaries = data
.price_chart_ranges
.as_ref()
.map(|ranges| {
let mut entries = ranges.iter().collect::<Vec<_>>();
entries.sort_by(|(left, _), (right, _)| left.cmp(right));
entries
.into_iter()
.map(|(range, points)| {
json!({
"range": range,
"summary": summarize_chart_points(points),
})
})
.collect::<Vec<_>>()
})
.unwrap_or_default();
if let Some(points) = data.price_chart.as_ref() {
chart_range_summaries.push(json!({
"range": "default",
"summary": summarize_chart_points(points),
}));
}
json!({
"symbol": data.symbol,
"name": data.name,
"price": data.price,
"change": data.change,
"changePercent": data.change_percent,
"marketCap": data.market_cap,
"volume": data.volume,
"volumeLabel": data.volume_label,
"pe": data.pe,
"eps": data.eps,
"high52Week": data.high52_week,
"low52Week": data.low52_week,
"profile": data.profile.as_ref().map(|profile| json!({
"description": profile.description.as_deref().map(truncate_text),
"wikiUrl": profile.wiki_url,
"ceo": profile.ceo.as_deref().map(truncate_text),
"headquarters": profile.headquarters.as_deref().map(truncate_text),
"employees": profile.employees,
"founded": profile.founded,
"sector": profile.sector.as_deref().map(truncate_text),
"website": profile.website,
})),
"chartRangeSummaries": chart_range_summaries,
})
}
fn compact_error_panel(data: &ErrorPanel) -> Value {
json!({
"title": truncate_text(&data.title),
"message": truncate_text(&data.message),
"detail": data.detail.as_deref().map(truncate_text),
"provider": data.provider,
"query": data.query.as_deref().map(truncate_text),
"symbol": data.symbol,
})
}
fn compact_portfolio_panel(data: &Portfolio) -> Value {
json!({
"summary": {
"totalValue": data.total_value,
"dayChange": data.day_change,
"dayChangePercent": data.day_change_percent,
"totalGain": data.total_gain,
"totalGainPercent": data.total_gain_percent,
"cashBalance": data.cash_balance,
"investedCostBasis": data.invested_cost_basis,
"realizedGain": data.realized_gain,
"unrealizedGain": data.unrealized_gain,
"holdingsCount": data.holdings_count,
"stalePricingSymbols": data.stale_pricing_symbols,
},
"holdings": data
.holdings
.iter()
.take(MAX_HOLDINGS)
.map(compact_holding)
.collect::<Vec<_>>(),
})
}
fn compact_news_panel(data: &[NewsItem], ticker: Option<&str>) -> Value {
json!({
"ticker": ticker,
"items": data
.iter()
.take(MAX_NEWS_ITEMS)
.map(|item| json!({
"source": truncate_text(&item.source),
"headline": truncate_text(&item.headline),
"timestamp": item.timestamp,
"snippet": truncate_text(&item.snippet),
"url": item.url,
"relatedTickers": item.related_tickers,
}))
.collect::<Vec<_>>(),
})
}
fn compact_analysis_panel(data: &StockAnalysis) -> Value {
json!({
"symbol": data.symbol,
"summary": truncate_text(&data.summary),
"sentiment": data.sentiment,
"keyPoints": truncate_text_items(&data.key_points),
"risks": truncate_text_items(&data.risks),
"opportunities": truncate_text_items(&data.opportunities),
"recommendation": data.recommendation,
"targetPrice": data.target_price,
})
}
fn compact_financials_panel(data: &FinancialsPanelData) -> Value {
json!({
"symbol": data.symbol,
"companyName": data.company_name,
"cik": data.cik,
"frequency": data.frequency,
"latestFiling": data.latest_filing,
"sourceStatus": compact_source_status(&data.source_status),
"periods": data
.periods
.iter()
.take(MAX_PERIODS)
.map(compact_statement_period)
.collect::<Vec<_>>(),
})
}
fn compact_cash_flow_panel(data: &CashFlowPanelData) -> Value {
json!({
"symbol": data.symbol,
"companyName": data.company_name,
"cik": data.cik,
"frequency": data.frequency,
"latestFiling": data.latest_filing,
"sourceStatus": compact_source_status(&data.source_status),
"periods": data
.periods
.iter()
.take(MAX_PERIODS)
.map(compact_cash_flow_period)
.collect::<Vec<_>>(),
})
}
fn compact_dividends_panel(data: &DividendsPanelData) -> Value {
json!({
"symbol": data.symbol,
"companyName": data.company_name,
"cik": data.cik,
"ttmDividendsPerShare": data.ttm_dividends_per_share,
"ttmCommonDividendsPaid": data.ttm_common_dividends_paid,
"latestFiling": data.latest_filing,
"sourceStatus": compact_source_status(&data.source_status),
"latestEvent": data.latest_event.as_ref().map(compact_dividend_event),
"events": data
.events
.iter()
.take(MAX_DIVIDEND_EVENTS)
.map(compact_dividend_event)
.collect::<Vec<_>>(),
})
}
fn compact_earnings_panel(data: &EarningsPanelData) -> Value {
json!({
"symbol": data.symbol,
"companyName": data.company_name,
"cik": data.cik,
"frequency": data.frequency,
"latestFiling": data.latest_filing,
"sourceStatus": compact_source_status(&data.source_status),
"periods": data
.periods
.iter()
.take(MAX_PERIODS)
.map(compact_earnings_period)
.collect::<Vec<_>>(),
})
}
fn compact_holding(holding: &Holding) -> Value {
json!({
"symbol": holding.symbol,
"name": truncate_text(&holding.name),
"quantity": holding.quantity,
"avgCost": holding.avg_cost,
"currentPrice": holding.current_price,
"currentValue": holding.current_value,
"gainLoss": holding.gain_loss,
"gainLossPercent": holding.gain_loss_percent,
"costBasis": holding.cost_basis,
"unrealizedGain": holding.unrealized_gain,
"latestTradeAt": holding.latest_trade_at,
})
}
fn compact_statement_period(period: &StatementPeriod) -> Value {
json!({
"label": truncate_text(&period.label),
"fiscalYear": period.fiscal_year,
"fiscalPeriod": period.fiscal_period,
"periodStart": period.period_start,
"periodEnd": period.period_end,
"filedDate": period.filed_date,
"form": period.form,
"revenue": period.revenue,
"grossProfit": period.gross_profit,
"operatingIncome": period.operating_income,
"netIncome": period.net_income,
"dilutedEps": period.diluted_eps,
"cashAndEquivalents": period.cash_and_equivalents,
"totalAssets": period.total_assets,
"totalLiabilities": period.total_liabilities,
"totalEquity": period.total_equity,
"sharesOutstanding": period.shares_outstanding,
})
}
fn compact_cash_flow_period(period: &CashFlowPeriod) -> Value {
json!({
"label": truncate_text(&period.label),
"fiscalYear": period.fiscal_year,
"fiscalPeriod": period.fiscal_period,
"periodStart": period.period_start,
"periodEnd": period.period_end,
"filedDate": period.filed_date,
"form": period.form,
"operatingCashFlow": period.operating_cash_flow,
"investingCashFlow": period.investing_cash_flow,
"financingCashFlow": period.financing_cash_flow,
"capex": period.capex,
"freeCashFlow": period.free_cash_flow,
"endingCash": period.ending_cash,
})
}
fn compact_dividend_event(event: &DividendEvent) -> Value {
json!({
"endDate": event.end_date,
"filedDate": event.filed_date,
"form": event.form,
"frequencyGuess": truncate_text(&event.frequency_guess),
"dividendPerShare": event.dividend_per_share,
"totalCashDividends": event.total_cash_dividends,
})
}
fn compact_earnings_period(period: &EarningsPeriod) -> Value {
json!({
"label": truncate_text(&period.label),
"fiscalYear": period.fiscal_year,
"fiscalPeriod": period.fiscal_period,
"periodStart": period.period_start,
"periodEnd": period.period_end,
"filedDate": period.filed_date,
"form": period.form,
"revenue": period.revenue,
"netIncome": period.net_income,
"basicEps": period.basic_eps,
"dilutedEps": period.diluted_eps,
"dilutedWeightedAverageShares": period.diluted_weighted_average_shares,
"revenueYoyChangePercent": period.revenue_yoy_change_percent,
"dilutedEpsYoyChangePercent": period.diluted_eps_yoy_change_percent,
})
}
fn compact_source_status(source_status: &SourceStatus) -> Value {
json!({
"companyfactsUsed": source_status.companyfacts_used,
"latestXbrlParsed": source_status.latest_xbrl_parsed,
"degradedReason": source_status.degraded_reason.as_deref().map(truncate_text),
})
}
fn summarize_chart_points(points: &[CompanyPricePoint]) -> Value {
let prices = points.iter().map(|point| point.price).collect::<Vec<_>>();
let min_price = prices.iter().copied().reduce(f64::min);
let max_price = prices.iter().copied().reduce(f64::max);
let first_point = points.first();
let last_point = points.last();
json!({
"points": points.len(),
"startLabel": first_point.map(|point| truncate_text(&point.label)),
"endLabel": last_point.map(|point| truncate_text(&point.label)),
"startTimestamp": first_point.and_then(|point| point.timestamp.clone()),
"endTimestamp": last_point.and_then(|point| point.timestamp.clone()),
"startPrice": first_point.map(|point| point.price),
"endPrice": last_point.map(|point| point.price),
"minPrice": min_price,
"maxPrice": max_price,
})
}
fn truncate_text_items(items: &[String]) -> Vec<String> {
items.iter().map(|item| truncate_text(item)).collect()
}
fn truncate_text(text: &str) -> String {
if text.chars().count() <= MAX_TEXT_FIELD_LENGTH {
return text.to_string();
}
let mut truncated = text
.chars()
.take(MAX_TEXT_FIELD_LENGTH.saturating_sub(3))
.collect::<String>();
if MAX_TEXT_FIELD_LENGTH >= 3 {
truncated.push_str("...");
}
truncated
}
#[cfg(test)]
mod tests {
use serde_json::Value;
use super::{build_panel_context_message, compact_panel_payload, truncate_text};
use crate::agent::ChatPanelContext;
use crate::terminal::{
CashFlowPanelData, CashFlowPeriod, Company, CompanyProfile, DividendEvent,
DividendsPanelData, EarningsPanelData, EarningsPeriod, ErrorPanel, FilingRef,
FinancialsPanelData, Frequency, Holding, NewsItem, PanelPayload, Portfolio, SourceStatus,
StatementPeriod, StockAnalysis,
};
#[test]
fn company_context_summarizes_chart_data_without_raw_points() {
let value = compact_panel_payload(&PanelPayload::Company {
data: sample_company(),
});
assert_eq!(value["symbol"], "AAPL");
assert!(value["chartRangeSummaries"].is_array());
assert!(value.get("priceChart").is_none());
assert!(value.to_string().contains("\"range\":\"1D\""));
assert!(!value.to_string().contains("\"priceChartRanges\""));
}
#[test]
fn news_context_caps_items_and_truncates_text() {
let value = compact_panel_payload(&PanelPayload::News {
data: (0..6).map(sample_news_item).collect(),
ticker: Some("AAPL".to_string()),
});
let items = value["items"].as_array().unwrap();
assert_eq!(items.len(), 5);
assert!(items[0]["snippet"].as_str().unwrap().len() <= 603);
}
#[test]
fn analysis_context_keeps_key_fields() {
let value = compact_panel_payload(&PanelPayload::Analysis {
data: sample_analysis(),
});
assert_eq!(value["symbol"], "AAPL");
assert_eq!(value["recommendation"], "buy");
assert_eq!(value["keyPoints"].as_array().unwrap().len(), 2);
}
#[test]
fn financial_panels_keep_only_recent_periods() {
let financials = compact_panel_payload(&PanelPayload::Financials {
data: sample_financials(),
});
let cash_flow = compact_panel_payload(&PanelPayload::CashFlow {
data: sample_cash_flow(),
});
let earnings = compact_panel_payload(&PanelPayload::Earnings {
data: sample_earnings(),
});
assert_eq!(financials["periods"].as_array().unwrap().len(), 4);
assert_eq!(cash_flow["periods"].as_array().unwrap().len(), 4);
assert_eq!(earnings["periods"].as_array().unwrap().len(), 4);
}
#[test]
fn dividends_context_keeps_latest_event_and_recent_history() {
let value = compact_panel_payload(&PanelPayload::Dividends {
data: sample_dividends(),
});
assert!(value["latestEvent"].is_object());
assert_eq!(value["events"].as_array().unwrap().len(), 4);
}
#[test]
fn portfolio_context_caps_holdings() {
let value = compact_panel_payload(&PanelPayload::Portfolio {
data: sample_portfolio(),
});
assert_eq!(value["holdings"].as_array().unwrap().len(), 25);
assert_eq!(value["summary"]["totalValue"], 1000.0);
}
#[test]
fn error_context_keeps_expected_fields() {
let value = compact_panel_payload(&PanelPayload::Error {
data: sample_error(),
});
assert_eq!(value["title"], "Lookup failed");
assert_eq!(value["symbol"], "AAPL");
}
#[test]
fn builds_hidden_system_message_with_metadata() {
let message = build_panel_context_message(&ChatPanelContext {
source_command: Some("/search AAPL".to_string()),
captured_at: Some("2026-04-06T10:00:00Z".to_string()),
panel: PanelPayload::Company {
data: sample_company(),
},
})
.unwrap();
let Value::String(content) = serde_json::to_value(&message)
.unwrap()["content"]
.clone()
else {
panic!("expected string content");
};
assert!(content.contains("Hidden workspace panel context"));
assert!(content.contains("\"panelType\":\"company\""));
assert!(content.contains("\"sourceCommand\":\"/search AAPL\""));
}
#[test]
fn truncate_text_appends_ellipsis_when_needed() {
let long_text = "x".repeat(650);
let truncated = truncate_text(&long_text);
assert_eq!(truncated.len(), 600);
assert!(truncated.ends_with("..."));
}
fn sample_company() -> Company {
Company {
symbol: "AAPL".to_string(),
name: "Apple Inc.".to_string(),
price: 200.0,
change: 2.5,
change_percent: 1.2,
market_cap: 3_000_000_000_000.0,
volume: Some(100_000),
volume_label: Some("Volume".to_string()),
pe: Some(30.0),
eps: Some(6.5),
high52_week: Some(210.0),
low52_week: Some(150.0),
profile: Some(CompanyProfile {
description: Some("A".repeat(650)),
wiki_url: Some("https://example.com".to_string()),
ceo: Some("Tim Cook".to_string()),
headquarters: Some("Cupertino".to_string()),
employees: Some(10),
founded: Some(1976),
sector: Some("Technology".to_string()),
website: Some("https://apple.com".to_string()),
}),
price_chart: Some(vec![
sample_chart_point("Open", 195.0, Some("2026-04-06T09:30:00Z")),
sample_chart_point("Close", 200.0, Some("2026-04-06T16:00:00Z")),
]),
price_chart_ranges: Some(
[(
"1D".to_string(),
vec![
sample_chart_point("09:30", 195.0, Some("2026-04-06T09:30:00Z")),
sample_chart_point("16:00", 200.0, Some("2026-04-06T16:00:00Z")),
],
)]
.into_iter()
.collect(),
),
}
}
fn sample_news_item(index: usize) -> NewsItem {
NewsItem {
id: format!("news-{index}"),
source: "Source".to_string(),
headline: format!("Headline {index}"),
timestamp: "2026-04-06T10:00:00Z".to_string(),
snippet: "S".repeat(650),
url: Some("https://example.com/story".to_string()),
related_tickers: vec!["AAPL".to_string()],
}
}
fn sample_analysis() -> StockAnalysis {
StockAnalysis {
symbol: "AAPL".to_string(),
summary: "Strong execution".to_string(),
sentiment: "bullish".to_string(),
key_points: vec!["Services growth".to_string(), "Margin expansion".to_string()],
risks: vec!["China demand".to_string()],
opportunities: vec!["AI features".to_string()],
recommendation: "buy".to_string(),
target_price: Some(225.0),
}
}
fn sample_financials() -> FinancialsPanelData {
FinancialsPanelData {
symbol: "AAPL".to_string(),
company_name: "Apple Inc.".to_string(),
cik: "0000320193".to_string(),
frequency: Frequency::Annual,
periods: (0..5).map(sample_statement_period).collect(),
latest_filing: Some(sample_filing_ref()),
source_status: sample_source_status(),
}
}
fn sample_cash_flow() -> CashFlowPanelData {
CashFlowPanelData {
symbol: "AAPL".to_string(),
company_name: "Apple Inc.".to_string(),
cik: "0000320193".to_string(),
frequency: Frequency::Annual,
periods: (0..5).map(sample_cash_flow_period).collect(),
latest_filing: Some(sample_filing_ref()),
source_status: sample_source_status(),
}
}
fn sample_dividends() -> DividendsPanelData {
DividendsPanelData {
symbol: "AAPL".to_string(),
company_name: "Apple Inc.".to_string(),
cik: "0000320193".to_string(),
ttm_dividends_per_share: Some(1.0),
ttm_common_dividends_paid: Some(10.0),
latest_event: Some(sample_dividend_event(0)),
events: (0..5).map(sample_dividend_event).collect(),
latest_filing: Some(sample_filing_ref()),
source_status: sample_source_status(),
}
}
fn sample_earnings() -> EarningsPanelData {
EarningsPanelData {
symbol: "AAPL".to_string(),
company_name: "Apple Inc.".to_string(),
cik: "0000320193".to_string(),
frequency: Frequency::Quarterly,
periods: (0..5).map(sample_earnings_period).collect(),
latest_filing: Some(sample_filing_ref()),
source_status: sample_source_status(),
}
}
fn sample_portfolio() -> Portfolio {
Portfolio {
holdings: (0..30)
.map(|index| Holding {
symbol: format!("SYM{index}"),
name: format!("Holding {index}"),
quantity: 1.0,
avg_cost: 10.0,
current_price: 20.0,
current_value: 20.0,
gain_loss: 10.0,
gain_loss_percent: 100.0,
cost_basis: Some(10.0),
unrealized_gain: Some(10.0),
latest_trade_at: Some("2026-04-06T10:00:00Z".to_string()),
})
.collect(),
total_value: 1000.0,
day_change: 10.0,
day_change_percent: 1.0,
total_gain: 100.0,
total_gain_percent: 10.0,
cash_balance: Some(100.0),
invested_cost_basis: Some(900.0),
realized_gain: Some(50.0),
unrealized_gain: Some(50.0),
holdings_count: Some(30),
stale_pricing_symbols: Some(vec!["SYM1".to_string()]),
}
}
fn sample_error() -> ErrorPanel {
ErrorPanel {
title: "Lookup failed".to_string(),
message: "The lookup failed".to_string(),
detail: Some("detail".to_string()),
provider: Some("provider".to_string()),
query: Some("apple".to_string()),
symbol: Some("AAPL".to_string()),
}
}
fn sample_source_status() -> SourceStatus {
SourceStatus {
companyfacts_used: true,
latest_xbrl_parsed: true,
degraded_reason: None,
}
}
fn sample_filing_ref() -> FilingRef {
FilingRef {
accession_number: "0000000000".to_string(),
filing_date: "2026-04-01".to_string(),
report_date: Some("2026-03-31".to_string()),
form: "10-K".to_string(),
primary_document: Some("doc.htm".to_string()),
}
}
fn sample_statement_period(index: usize) -> StatementPeriod {
StatementPeriod {
label: format!("FY{index}"),
fiscal_year: Some(format!("20{index:02}")),
fiscal_period: Some("FY".to_string()),
period_start: Some("2025-01-01".to_string()),
period_end: "2025-12-31".to_string(),
filed_date: "2026-01-31".to_string(),
form: "10-K".to_string(),
revenue: Some(100.0),
gross_profit: Some(50.0),
operating_income: Some(40.0),
net_income: Some(30.0),
diluted_eps: Some(2.0),
cash_and_equivalents: Some(20.0),
total_assets: Some(10.0),
total_liabilities: Some(5.0),
total_equity: Some(5.0),
shares_outstanding: Some(1.0),
}
}
fn sample_cash_flow_period(index: usize) -> CashFlowPeriod {
CashFlowPeriod {
label: format!("FY{index}"),
fiscal_year: Some(format!("20{index:02}")),
fiscal_period: Some("FY".to_string()),
period_start: Some("2025-01-01".to_string()),
period_end: "2025-12-31".to_string(),
filed_date: "2026-01-31".to_string(),
form: "10-K".to_string(),
operating_cash_flow: Some(100.0),
investing_cash_flow: Some(-50.0),
financing_cash_flow: Some(-25.0),
capex: Some(-10.0),
free_cash_flow: Some(90.0),
ending_cash: Some(20.0),
}
}
fn sample_dividend_event(index: usize) -> DividendEvent {
DividendEvent {
end_date: format!("2026-03-0{}", index + 1),
filed_date: "2026-03-15".to_string(),
form: "10-Q".to_string(),
frequency_guess: "Quarterly".to_string(),
dividend_per_share: Some(0.25),
total_cash_dividends: Some(100.0),
}
}
fn sample_earnings_period(index: usize) -> EarningsPeriod {
EarningsPeriod {
label: format!("Q{index}"),
fiscal_year: Some("2026".to_string()),
fiscal_period: Some("Q1".to_string()),
period_start: Some("2026-01-01".to_string()),
period_end: "2026-03-31".to_string(),
filed_date: "2026-04-30".to_string(),
form: "10-Q".to_string(),
revenue: Some(100.0),
net_income: Some(30.0),
basic_eps: Some(1.0),
diluted_eps: Some(0.9),
diluted_weighted_average_shares: Some(100.0),
revenue_yoy_change_percent: Some(5.0),
diluted_eps_yoy_change_percent: Some(2.0),
}
}
fn sample_chart_point(label: &str, price: f64, timestamp: Option<&str>) -> crate::terminal::CompanyPricePoint {
crate::terminal::CompanyPricePoint {
label: label.to_string(),
price,
volume: Some(10),
timestamp: timestamp.map(str::to_string),
}
}
}

View File

@@ -6,9 +6,9 @@ use rig::completion::Message;
use tauri::{AppHandle, Runtime}; use tauri::{AppHandle, Runtime};
use crate::agent::{ use crate::agent::{
AgentConfigStatus, AgentRuntimeConfig, AgentStoredSettings, ChatPromptRequest, panel_context::build_panel_context_message, AgentConfigStatus, AgentRuntimeConfig,
PreparedChatTurn, RemoteProviderSettings, RigChatGateway, SaveAgentRuntimeConfigRequest, AgentStoredSettings, ChatPromptRequest, PreparedChatTurn, RemoteProviderSettings,
TaskProfile, UpdateRemoteApiKeyRequest, RigChatGateway, SaveAgentRuntimeConfigRequest, TaskProfile, UpdateRemoteApiKeyRequest,
}; };
use crate::error::AppError; use crate::error::AppError;
@@ -50,6 +50,7 @@ impl SessionManager {
session_id, session_id,
prompt: prompt.to_string(), prompt: prompt.to_string(),
history: prior_history, history: prior_history,
context_messages: Vec::new(),
runtime, runtime,
}) })
} }
@@ -96,9 +97,18 @@ impl<R: Runtime, G: ChatGateway> AgentService<R, G> {
&mut self, &mut self,
request: ChatPromptRequest, request: ChatPromptRequest,
) -> Result<PreparedChatTurn, AppError> { ) -> Result<PreparedChatTurn, AppError> {
let panel_context = request.panel_context.clone();
let runtime = let runtime =
self.resolve_runtime(request.agent_profile, request.model_override.clone())?; self.resolve_runtime(request.agent_profile, request.model_override.clone())?;
self.session_manager.prepare_turn(request, runtime) let mut prepared_turn = self.session_manager.prepare_turn(request, runtime)?;
prepared_turn.context_messages = panel_context
.as_ref()
.map(build_panel_context_message)
.transpose()?
.into_iter()
.collect();
Ok(prepared_turn)
} }
pub fn record_assistant_reply( pub fn record_assistant_reply(
@@ -194,10 +204,11 @@ mod tests {
use super::SessionManager; use super::SessionManager;
use crate::agent::{ use crate::agent::{
default_task_defaults, AgentRuntimeConfig, AgentService, ChatPromptRequest, default_task_defaults, AgentRuntimeConfig, AgentService, ChatPanelContext,
SaveAgentRuntimeConfigRequest, TaskProfile, UpdateRemoteApiKeyRequest, ChatPromptRequest, SaveAgentRuntimeConfigRequest, TaskProfile,
DEFAULT_REMOTE_BASE_URL, DEFAULT_REMOTE_MODEL, UpdateRemoteApiKeyRequest, DEFAULT_REMOTE_BASE_URL, DEFAULT_REMOTE_MODEL,
}; };
use crate::terminal::{Company, CompanyProfile, PanelPayload};
use crate::error::AppError; use crate::error::AppError;
use rig::completion::Message; use rig::completion::Message;
@@ -214,6 +225,7 @@ mod tests {
prompt: " ".to_string(), prompt: " ".to_string(),
agent_profile: None, agent_profile: None,
model_override: None, model_override: None,
panel_context: None,
}, },
sample_runtime(), sample_runtime(),
); );
@@ -233,6 +245,7 @@ mod tests {
prompt: "Summarize AAPL".to_string(), prompt: "Summarize AAPL".to_string(),
agent_profile: None, agent_profile: None,
model_override: None, model_override: None,
panel_context: None,
}, },
sample_runtime(), sample_runtime(),
) )
@@ -255,6 +268,7 @@ mod tests {
prompt: "First prompt".to_string(), prompt: "First prompt".to_string(),
agent_profile: None, agent_profile: None,
model_override: None, model_override: None,
panel_context: None,
}, },
sample_runtime(), sample_runtime(),
) )
@@ -271,6 +285,7 @@ mod tests {
prompt: "Second prompt".to_string(), prompt: "Second prompt".to_string(),
agent_profile: None, agent_profile: None,
model_override: None, model_override: None,
panel_context: None,
}, },
sample_runtime(), sample_runtime(),
) )
@@ -323,6 +338,7 @@ mod tests {
prompt: "hello".to_string(), prompt: "hello".to_string(),
agent_profile: None, agent_profile: None,
model_override: None, model_override: None,
panel_context: None,
}) })
.unwrap(); .unwrap();
assert_eq!(prepared.runtime.base_url, "https://example.test/v4"); assert_eq!(prepared.runtime.base_url, "https://example.test/v4");
@@ -363,6 +379,7 @@ mod tests {
prompt: "hello".to_string(), prompt: "hello".to_string(),
agent_profile: None, agent_profile: None,
model_override: None, model_override: None,
panel_context: None,
}); });
assert_eq!(result.unwrap_err(), AppError::AgentNotConfigured); assert_eq!(result.unwrap_err(), AppError::AgentNotConfigured);
}); });
@@ -395,6 +412,7 @@ mod tests {
prompt: "hello".to_string(), prompt: "hello".to_string(),
agent_profile: None, agent_profile: None,
model_override: Some("glm-override".to_string()), model_override: Some("glm-override".to_string()),
panel_context: None,
}) })
.unwrap(); .unwrap();
@@ -453,12 +471,97 @@ mod tests {
prompt: "hello".to_string(), prompt: "hello".to_string(),
agent_profile: None, agent_profile: None,
model_override: None, model_override: None,
panel_context: None,
}); });
assert_eq!(result.unwrap_err(), AppError::AgentNotConfigured); assert_eq!(result.unwrap_err(), AppError::AgentNotConfigured);
}); });
} }
#[test]
fn prepare_turn_without_panel_context_yields_no_context_messages() {
with_test_home("context-none", || {
let app = build_test_app();
let mut service = configured_service(&app);
let prepared = service
.prepare_turn(ChatPromptRequest {
workspace_id: "workspace-1".to_string(),
session_id: None,
prompt: "hello".to_string(),
agent_profile: None,
model_override: None,
panel_context: None,
})
.unwrap();
assert!(prepared.context_messages.is_empty());
});
}
#[test]
fn prepare_turn_with_panel_context_yields_one_system_context_message() {
with_test_home("context-present", || {
let app = build_test_app();
let mut service = configured_service(&app);
let prepared = service
.prepare_turn(ChatPromptRequest {
workspace_id: "workspace-1".to_string(),
session_id: None,
prompt: "hello".to_string(),
agent_profile: None,
model_override: None,
panel_context: Some(sample_panel_context()),
})
.unwrap();
assert_eq!(prepared.context_messages.len(), 1);
assert!(matches!(prepared.context_messages[0], Message::System { .. }));
});
}
#[test]
fn panel_context_is_not_persisted_in_follow_up_history() {
with_test_home("context-history", || {
let app = build_test_app();
let mut service = configured_service(&app);
let first = service
.prepare_turn(ChatPromptRequest {
workspace_id: "workspace-1".to_string(),
session_id: None,
prompt: "hello".to_string(),
agent_profile: None,
model_override: None,
panel_context: Some(sample_panel_context()),
})
.unwrap();
assert_eq!(first.context_messages.len(), 1);
service
.record_assistant_reply(&first.session_id, "reply")
.unwrap();
let follow_up = service
.prepare_turn(ChatPromptRequest {
workspace_id: "workspace-1".to_string(),
session_id: Some(first.session_id),
prompt: "follow up".to_string(),
agent_profile: None,
model_override: None,
panel_context: None,
})
.unwrap();
assert!(follow_up.context_messages.is_empty());
assert_eq!(follow_up.history.len(), 2);
assert_eq!(follow_up.history[0], Message::user("hello"));
assert_eq!(follow_up.history[1], Message::assistant("reply"));
});
}
fn sample_runtime() -> AgentRuntimeConfig { fn sample_runtime() -> AgentRuntimeConfig {
AgentRuntimeConfig { AgentRuntimeConfig {
base_url: "https://example.com".to_string(), base_url: "https://example.com".to_string(),
@@ -468,6 +571,41 @@ mod tests {
} }
} }
fn sample_panel_context() -> ChatPanelContext {
ChatPanelContext {
source_command: Some("/search AAPL".to_string()),
captured_at: Some("2026-04-06T10:00:00Z".to_string()),
panel: PanelPayload::Company {
data: Company {
symbol: "AAPL".to_string(),
name: "Apple Inc.".to_string(),
price: 200.0,
change: 1.0,
change_percent: 0.5,
market_cap: 3_000_000_000_000.0,
volume: Some(100),
volume_label: Some("Volume".to_string()),
pe: Some(30.0),
eps: Some(6.0),
high52_week: Some(210.0),
low52_week: Some(150.0),
profile: Some(CompanyProfile {
description: Some("Description".to_string()),
wiki_url: None,
ceo: Some("Tim Cook".to_string()),
headquarters: Some("Cupertino".to_string()),
employees: Some(10),
founded: Some(1976),
sector: Some("Technology".to_string()),
website: Some("https://apple.com".to_string()),
}),
price_chart: None,
price_chart_ranges: None,
},
},
}
}
fn build_test_app() -> tauri::App<MockRuntime> { fn build_test_app() -> tauri::App<MockRuntime> {
mock_builder() mock_builder()
.plugin(tauri_plugin_store::Builder::new().build()) .plugin(tauri_plugin_store::Builder::new().build())
@@ -475,6 +613,25 @@ mod tests {
.unwrap() .unwrap()
} }
fn configured_service(app: &tauri::App<MockRuntime>) -> AgentService<MockRuntime> {
let mut service = AgentService::new(&app.handle()).unwrap();
service
.save_runtime_config(SaveAgentRuntimeConfigRequest {
remote_enabled: true,
remote_base_url: "https://example.test/v4".to_string(),
default_remote_model: "glm-test".to_string(),
task_defaults: default_task_defaults("glm-test"),
sec_edgar_user_agent: String::new(),
})
.unwrap();
service
.update_remote_api_key(UpdateRemoteApiKeyRequest {
api_key: "z-ai-key-1".to_string(),
})
.unwrap();
service
}
fn unique_identifier(prefix: &str) -> String { fn unique_identifier(prefix: &str) -> String {
let nanos = SystemTime::now() let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH) .duration_since(UNIX_EPOCH)

View File

@@ -2,6 +2,8 @@ use rig::completion::Message;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::collections::HashMap; use std::collections::HashMap;
use crate::terminal::PanelPayload;
/// Default Z.AI coding plan endpoint used by the app. /// Default Z.AI coding plan endpoint used by the app.
pub const DEFAULT_REMOTE_BASE_URL: &str = "https://api.z.ai/api/coding/paas/v4"; pub const DEFAULT_REMOTE_BASE_URL: &str = "https://api.z.ai/api/coding/paas/v4";
/// Default model used for plain-text terminal chat. /// Default model used for plain-text terminal chat.
@@ -28,6 +30,15 @@ pub struct ChatPromptRequest {
pub prompt: String, pub prompt: String,
pub agent_profile: Option<TaskProfile>, pub agent_profile: Option<TaskProfile>,
pub model_override: Option<String>, pub model_override: Option<String>,
pub panel_context: Option<ChatPanelContext>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ChatPanelContext {
pub source_command: Option<String>,
pub captured_at: Option<String>,
pub panel: PanelPayload,
} }
/// Runtime provider configuration after settings resolution. /// Runtime provider configuration after settings resolution.
@@ -46,6 +57,7 @@ pub struct PreparedChatTurn {
pub session_id: String, pub session_id: String,
pub prompt: String, pub prompt: String,
pub history: Vec<Message>, pub history: Vec<Message>,
pub context_messages: Vec<Message>,
pub runtime: AgentRuntimeConfig, pub runtime: AgentRuntimeConfig,
} }

View File

@@ -66,6 +66,7 @@ pub async fn start_chat_stream(
.stream_chat( .stream_chat(
prepared_turn.runtime.clone(), prepared_turn.runtime.clone(),
prepared_turn.prompt.clone(), prepared_turn.prompt.clone(),
prepared_turn.context_messages.clone(),
prepared_turn.history.clone(), prepared_turn.history.clone(),
) )
.await .await

View File

@@ -10,6 +10,7 @@ pub enum AppError {
AgentNotConfigured, AgentNotConfigured,
RemoteApiKeyMissing, RemoteApiKeyMissing,
InvalidSettings(String), InvalidSettings(String),
PanelContext(String),
UnknownSession(String), UnknownSession(String),
SettingsStore(String), SettingsStore(String),
ProviderInit(String), ProviderInit(String),
@@ -28,6 +29,9 @@ impl Display for AppError {
), ),
Self::RemoteApiKeyMissing => formatter.write_str("remote API key cannot be empty"), Self::RemoteApiKeyMissing => formatter.write_str("remote API key cannot be empty"),
Self::InvalidSettings(message) => formatter.write_str(message), Self::InvalidSettings(message) => formatter.write_str(message),
Self::PanelContext(message) => {
write!(formatter, "panel context could not be prepared: {message}")
}
Self::UnknownSession(session_id) => { Self::UnknownSession(session_id) => {
write!(formatter, "unknown session: {session_id}") write!(formatter, "unknown session: {session_id}")
} }

View File

@@ -10,6 +10,6 @@ pub use types::{
CashFlowPanelData, CashFlowPeriod, ChatCommandRequest, Company, CompanyPricePoint, CashFlowPanelData, CashFlowPeriod, ChatCommandRequest, Company, CompanyPricePoint,
CompanyProfile, DividendEvent, DividendsPanelData, EarningsPanelData, EarningsPeriod, CompanyProfile, DividendEvent, DividendsPanelData, EarningsPanelData, EarningsPeriod,
ErrorPanel, ExecuteTerminalCommandRequest, FilingRef, FinancialsPanelData, Frequency, Holding, ErrorPanel, ExecuteTerminalCommandRequest, FilingRef, FinancialsPanelData, Frequency, Holding,
LookupCompanyRequest, MockFinancialData, PanelPayload, Portfolio, SourceStatus, LookupCompanyRequest, MockFinancialData, NewsItem, PanelPayload, Portfolio, SourceStatus,
StatementPeriod, TerminalCommandResponse, StatementPeriod, StockAnalysis, TerminalCommandResponse,
}; };

View File

@@ -46,7 +46,7 @@ pub enum TerminalCommandResponse {
} }
/// Serializable panel variants shared between Rust and React. /// Serializable panel variants shared between Rust and React.
#[derive(Debug, Clone, Serialize, PartialEq)] #[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
#[serde(tag = "type", rename_all = "camelCase")] #[serde(tag = "type", rename_all = "camelCase")]
pub enum PanelPayload { pub enum PanelPayload {
Company { Company {

View File

@@ -12,6 +12,7 @@ import { useTabs } from './hooks/useTabs';
import { useTickerHistory } from './hooks/useTickerHistory'; import { useTickerHistory } from './hooks/useTickerHistory';
import { createEntry } from './hooks/useTerminal'; import { createEntry } from './hooks/useTerminal';
import { agentSettingsBridge } from './lib/agentSettingsBridge'; import { agentSettingsBridge } from './lib/agentSettingsBridge';
import { extractChatPanelContext } from './lib/chatPanelContext';
import { import {
extractTickerSymbolFromResponse, extractTickerSymbolFromResponse,
resolveTickerCommandFallback, resolveTickerCommandFallback,
@@ -186,6 +187,7 @@ function App() {
} }
// Plain text keeps the current workspace conversation alive and streams into a placeholder response entry. // Plain text keeps the current workspace conversation alive and streams into a placeholder response entry.
const panelContext = extractChatPanelContext(currentWorkspace?.history ?? []);
const commandEntry = createEntry({ type: 'command', content: resolvedCommand }); const commandEntry = createEntry({ type: 'command', content: resolvedCommand });
const responseEntry = createEntry({ type: 'response', content: '' }); const responseEntry = createEntry({ type: 'response', content: '' });
@@ -199,6 +201,7 @@ function App() {
sessionId: currentWorkspace?.chatSessionId, sessionId: currentWorkspace?.chatSessionId,
prompt: resolvedCommand, prompt: resolvedCommand,
agentProfile: 'interactiveChat', agentProfile: 'interactiveChat',
panelContext,
}, },
{ {
onDelta: (event) => { onDelta: (event) => {

View File

@@ -0,0 +1,52 @@
import {
ChatPanelContext,
PanelPayload,
TerminalEntry,
TransportPanelPayload,
} from '../types/terminal';
const toTransportPanelPayload = (panel: PanelPayload): TransportPanelPayload => {
if (panel.type !== 'news') {
return panel;
}
return {
...panel,
data: panel.data.map((item) => ({
...item,
timestamp: item.timestamp.toISOString(),
})),
};
};
export const extractChatPanelContext = (
history: TerminalEntry[],
): ChatPanelContext | undefined => {
for (let index = history.length - 1; index >= 0; index -= 1) {
const entry = history[index];
if (entry.type !== 'panel' || typeof entry.content === 'string') {
continue;
}
let sourceCommand: string | undefined;
for (let commandIndex = index - 1; commandIndex >= 0; commandIndex -= 1) {
const candidate = history[commandIndex];
if (
candidate.type === 'command' &&
typeof candidate.content === 'string' &&
candidate.content.startsWith('/')
) {
sourceCommand = candidate.content;
break;
}
}
return {
sourceCommand,
capturedAt: entry.timestamp?.toISOString(),
panel: toTransportPanelPayload(entry.content),
};
}
return undefined;
};

View File

@@ -41,6 +41,12 @@ export type ResolvedTerminalCommandResponse =
| { kind: 'text'; content: string; portfolio?: Portfolio } | { kind: 'text'; content: string; portfolio?: Portfolio }
| { kind: 'panel'; panel: PanelPayload }; | { kind: 'panel'; panel: PanelPayload };
export interface ChatPanelContext {
sourceCommand?: string;
capturedAt?: string;
panel: TransportPanelPayload;
}
export interface ExecuteTerminalCommandRequest { export interface ExecuteTerminalCommandRequest {
workspaceId: string; workspaceId: string;
input: string; input: string;
@@ -56,6 +62,7 @@ export interface StartChatStreamRequest {
prompt: string; prompt: string;
agentProfile?: TaskProfile; agentProfile?: TaskProfile;
modelOverride?: string; modelOverride?: string;
panelContext?: ChatPanelContext;
} }
export interface ChatStreamStart { export interface ChatStreamStart {