fix(subconscious): pause when provider unavailable

## Summary
- Skip Subconscious ticks before writing per-task activity rows when no OpenHuman session/local provider path is available.
- Add provider availability fields to `subconscious_status` and show a paused/configuration banner in Intelligence > Subconscious.
- Route the tick evaluator through `subconscious_provider` while reusing the existing memory-tree chat provider plumbing.

Closes #1374

## Testing
- [x] `cargo fmt --all --check`
- [x] `pnpm --filter openhuman-app test -- src/components/intelligence/__tests__/IntelligenceSubconsciousTab.test.tsx`
- [x] `pnpm --filter openhuman-app compile`
- [x] `pnpm i18n:check`
- [x] `git diff --check`
- [x] `cargo test -p openhuman subconscious::engine::tests --lib` attempted locally, blocked before tests by `whisper-rs-sys` missing `libclang` (`LIBCLANG_PATH` unset).

## PR Checklist
- [x] I linked the relevant issue(s) above.
- [x] I added or updated tests for the changed behavior, or explained why this is not needed.
- [x] I ran the relevant local checks, or documented why they could not be run.
- [x] I kept the change scoped to the issue.


<!-- This is an auto-generated comment: release notes by coderabbit.ai -->
## Summary by CodeRabbit

* **New Features**
  * Show an amber warning when the AI provider is unavailable, display the unavailable reason, provide a quick link to AI settings, disable the "Run Now" button, and show a translated tooltip explaining the unavailable status.

* **Internationalization**
  * Added provider-unavailable title and settings label translations across multiple languages.

* **Tests**
  * Added tests covering the provider-unavailable UI, disabled Run Now behavior, and settings navigation.

<!-- review_stack_entry_start -->

[![Review Change Stack](https://storage.googleapis.com/coderabbit_public_assets/review-stack-in-coderabbit-ui.svg)](https://app.coderabbit.ai/change-stack/tinyhumansai/openhuman/pull/2314?utm_source=github_walkthrough&utm_medium=github&utm_campaign=change_stack)

<!-- review_stack_entry_end -->
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

Co-authored-by: aqilaziz <gonzes7@gmail.com>
Co-authored-by: Steven Enamakel <enamakel@tinyhumans.ai>
This commit is contained in:
Aqil Aziz
2026-05-20 16:57:21 -07:00
committed by GitHub
co-authored by Steven Enamakel
parent 08bbe3bc42
commit ca31ed1c55
20 changed files with 360 additions and 45 deletions
@@ -59,6 +59,10 @@ export default function IntelligenceSubconsciousTab({
const { t } = useT();
const navigate = useNavigate();
const dispatch = useDispatch();
const providerUnavailable = status?.provider_available === false;
const providerUnavailableReason = providerUnavailable
? (status?.provider_unavailable_reason ?? t('subconscious.providerUnavailableTitle'))
: null;
// Reflection "Act" callback — sets the freshly-spawned thread as the
// selected one and navigates the user to the chat surface so they
@@ -239,7 +243,8 @@ export default function IntelligenceSubconsciousTab({
</div>
<button
onClick={() => void handleRunTick()}
disabled={triggering}
disabled={triggering || providerUnavailable}
title={providerUnavailable ? t('subconscious.providerUnavailableTitle') : undefined}
className="flex items-center gap-1.5 px-3 py-1.5 text-xs bg-stone-50 dark:bg-neutral-800/60 hover:bg-stone-100 dark:hover:bg-neutral-800 dark:bg-neutral-800 disabled:opacity-40 border border-stone-200 dark:border-neutral-800 rounded-lg text-stone-600 dark:text-neutral-300 transition-colors">
{triggering ? (
<div className="w-3 h-3 border border-stone-400 border-t-transparent rounded-full animate-spin" />
@@ -258,6 +263,27 @@ export default function IntelligenceSubconsciousTab({
</div>
</div>
{providerUnavailable && (
<div className="rounded-lg border border-amber-200 dark:border-amber-500/30 bg-amber-50 dark:bg-amber-500/10 p-3">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<p className="text-sm font-medium text-amber-800 dark:text-amber-200">
{t('subconscious.providerUnavailableTitle')}
</p>
<p className="mt-1 text-xs text-amber-700 dark:text-amber-300 break-words">
{providerUnavailableReason}
</p>
</div>
<button
type="button"
onClick={() => navigate('/settings/llm')}
className="flex-shrink-0 rounded-md bg-amber-600 px-2.5 py-1.5 text-xs font-medium text-white hover:bg-amber-700 transition-colors">
{t('subconscious.providerSettings')}
</button>
</div>
</div>
)}
<SubconsciousReflectionCards
onNavigateToThread={handleNavigateToReflectionThread}
pollIntervalMs={15_000}
@@ -10,6 +10,7 @@
* the full Redux/router stack.
*/
import { fireEvent, render, screen } from '@testing-library/react';
import type { ComponentProps } from 'react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { setSelectedThread } from '../../../store/threadSlice';
@@ -48,7 +49,7 @@ function baseProps() {
removeSubconsciousTask: vi.fn(),
setExpandedLogIds: vi.fn(),
setNewTaskTitle: vi.fn(),
status: null,
status: null as ComponentProps<typeof IntelligenceSubconsciousTab>['status'],
tasks: [],
toggleSubconsciousTask: vi.fn(),
triggerTick: vi.fn(),
@@ -79,4 +80,36 @@ describe('IntelligenceSubconsciousTab', () => {
// and the user lands somewhere unexpected.
expect(mockNavigate).toHaveBeenCalledWith('/chat');
});
it('shows provider unavailable state and blocks manual ticks', () => {
const triggerTick = vi.fn();
render(
<IntelligenceSubconsciousTab
{...baseProps()}
triggerTick={triggerTick}
status={{
enabled: true,
provider_available: false,
provider_unavailable_reason: 'Sign in or configure a local Subconscious provider.',
interval_minutes: 5,
last_tick_at: null,
total_ticks: 0,
task_count: 3,
pending_escalations: 0,
consecutive_failures: 1,
}}
/>
);
expect(screen.getByText('Subconscious is paused')).toBeInTheDocument();
expect(screen.getByText(/configure a local Subconscious provider/i)).toBeInTheDocument();
const runNow = screen.getByRole('button', { name: /Run Now/i });
expect(runNow).toBeDisabled();
fireEvent.click(runNow);
expect(triggerTick).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole('button', { name: /AI settings/i }));
expect(mockNavigate).toHaveBeenCalledWith('/settings/llm');
});
});
+2
View File
@@ -99,6 +99,8 @@ const ar3: TranslationMap = {
'subconscious.failed': 'فشل',
'subconscious.tickInterval': 'فترة الدورة',
'subconscious.runNow': 'تشغيل الآن',
'subconscious.providerUnavailableTitle': 'تم إيقاف اللاوعي مؤقتًا',
'subconscious.providerSettings': 'إعدادات الذكاء الاصطناعي',
'subconscious.approvalNeeded': 'يلزم الموافقة',
'subconscious.requiresApproval': 'يتطلب الموافقة',
'subconscious.fixInConnections': 'إصلاح في الاتصالات',
+2
View File
@@ -101,6 +101,8 @@ const bn3: TranslationMap = {
'subconscious.failed': 'ব্যর্থ',
'subconscious.tickInterval': 'টিক ইন্টারভাল',
'subconscious.runNow': 'এখনই চালান',
'subconscious.providerUnavailableTitle': 'Subconscious বিরত আছে',
'subconscious.providerSettings': 'AI সেটিংস',
'subconscious.approvalNeeded': 'অনুমোদন প্রয়োজন',
'subconscious.requiresApproval': 'অনুমোদন প্রয়োজন',
'subconscious.fixInConnections': 'সংযোগে ঠিক করুন',
+2
View File
@@ -100,6 +100,8 @@ const en3: TranslationMap = {
'subconscious.failed': 'failed',
'subconscious.tickInterval': 'Tick Interval',
'subconscious.runNow': 'Run Now',
'subconscious.providerUnavailableTitle': 'Subconscious is paused',
'subconscious.providerSettings': 'AI settings',
'subconscious.approvalNeeded': 'Approval Needed',
'subconscious.requiresApproval': 'Requires approval',
'subconscious.fixInConnections': 'Fix in Connections',
+2
View File
@@ -103,6 +103,8 @@ const es3: TranslationMap = {
'subconscious.failed': 'fallido',
'subconscious.tickInterval': 'Intervalo de tick',
'subconscious.runNow': 'Ejecutar ahora',
'subconscious.providerUnavailableTitle': 'Subconsciente en pausa',
'subconscious.providerSettings': 'Ajustes de IA',
'subconscious.approvalNeeded': 'Se necesita aprobación',
'subconscious.requiresApproval': 'Requiere aprobación',
'subconscious.fixInConnections': 'Corregir en Conexiones',
+2
View File
@@ -104,6 +104,8 @@ const fr3: TranslationMap = {
'subconscious.failed': 'échoué',
'subconscious.tickInterval': 'Intervalle de tick',
'subconscious.runNow': 'Exécuter maintenant',
'subconscious.providerUnavailableTitle': 'Subconscient en pause',
'subconscious.providerSettings': 'Paramètres IA',
'subconscious.approvalNeeded': 'Approbation requise',
'subconscious.requiresApproval': 'Nécessite une approbation',
'subconscious.fixInConnections': 'Corriger dans Connexions',
+2
View File
@@ -100,6 +100,8 @@ const hi3: TranslationMap = {
'subconscious.failed': 'विफल',
'subconscious.tickInterval': 'टिक इंटरवल',
'subconscious.runNow': 'अभी चलाएं',
'subconscious.providerUnavailableTitle': 'Subconscious रुका हुआ है',
'subconscious.providerSettings': 'AI सेटिंग्स',
'subconscious.approvalNeeded': 'अनुमति चाहिए',
'subconscious.requiresApproval': 'अनुमति ज़रूरी है',
'subconscious.fixInConnections': 'Connections में ठीक करें',
+2
View File
@@ -101,6 +101,8 @@ const id3: TranslationMap = {
'subconscious.failed': 'gagal',
'subconscious.tickInterval': 'Interval Tick',
'subconscious.runNow': 'Jalankan Sekarang',
'subconscious.providerUnavailableTitle': 'Subconscious dijeda',
'subconscious.providerSettings': 'Pengaturan AI',
'subconscious.approvalNeeded': 'Persetujuan Diperlukan',
'subconscious.requiresApproval': 'Memerlukan persetujuan',
'subconscious.fixInConnections': 'Perbaiki di Koneksi',
+2
View File
@@ -104,6 +104,8 @@ const it3: TranslationMap = {
'subconscious.failed': 'fallito',
'subconscious.tickInterval': 'Intervallo di tick',
'subconscious.runNow': 'Esegui ora',
'subconscious.providerUnavailableTitle': 'Subconscio in pausa',
'subconscious.providerSettings': 'Impostazioni IA',
'subconscious.approvalNeeded': 'Approvazione necessaria',
'subconscious.requiresApproval': 'Richiede approvazione',
'subconscious.fixInConnections': 'Correggi in Connessioni',
+2
View File
@@ -100,6 +100,8 @@ const ko3: TranslationMap = {
'subconscious.failed': '실패',
'subconscious.tickInterval': '틱 간격',
'subconscious.runNow': '지금 실행',
'subconscious.providerUnavailableTitle': 'Subconscious 일시 중지됨',
'subconscious.providerSettings': 'AI 설정',
'subconscious.approvalNeeded': '승인 필요',
'subconscious.requiresApproval': '승인이 필요함',
'subconscious.fixInConnections': '연결에서 수정',
+2
View File
@@ -102,6 +102,8 @@ const pt3: TranslationMap = {
'subconscious.failed': 'falhou',
'subconscious.tickInterval': 'Intervalo de Tick',
'subconscious.runNow': 'Executar Agora',
'subconscious.providerUnavailableTitle': 'Subconsciente pausado',
'subconscious.providerSettings': 'Configurações de IA',
'subconscious.approvalNeeded': 'Aprovação Necessária',
'subconscious.requiresApproval': 'Requer aprovação',
'subconscious.fixInConnections': 'Corrigir em Conexões',
+2
View File
@@ -101,6 +101,8 @@ const ru3: TranslationMap = {
'subconscious.failed': 'ошибка',
'subconscious.tickInterval': 'Интервал тика',
'subconscious.runNow': 'Запустить сейчас',
'subconscious.providerUnavailableTitle': 'Подсознание приостановлено',
'subconscious.providerSettings': 'Настройки ИИ',
'subconscious.approvalNeeded': 'Требуется подтверждение',
'subconscious.requiresApproval': 'Требует подтверждения',
'subconscious.fixInConnections': 'Исправить в подключениях',
+2
View File
@@ -98,6 +98,8 @@ const zhCN3: TranslationMap = {
'subconscious.failed': '失败',
'subconscious.tickInterval': '滴答间隔',
'subconscious.runNow': '立即运行',
'subconscious.providerUnavailableTitle': '潜意识已暂停',
'subconscious.providerSettings': 'AI 设置',
'subconscious.approvalNeeded': '需要审批',
'subconscious.requiresApproval': '需要审批',
'subconscious.fixInConnections': '在连接中修复',
+2
View File
@@ -1034,6 +1034,8 @@ const en: TranslationMap = {
'subconscious.failed': 'failed',
'subconscious.tickInterval': 'Tick Interval',
'subconscious.runNow': 'Run Now',
'subconscious.providerUnavailableTitle': 'Subconscious is paused',
'subconscious.providerSettings': 'AI settings',
'subconscious.approvalNeeded': 'Approval Needed',
'subconscious.requiresApproval': 'Requires approval',
'subconscious.fixInConnections': 'Fix in Connections',
@@ -42,6 +42,8 @@ export interface SubconsciousEscalation {
export interface SubconsciousStatus {
enabled: boolean;
provider_available: boolean;
provider_unavailable_reason: string | null;
interval_minutes: number;
last_tick_at: number | null;
total_ticks: number;
+136 -43
View File
@@ -19,6 +19,8 @@ use super::types::{
EscalationPriority, EvaluationResponse, SubconsciousStatus, SubconsciousTask, TaskEvaluation,
TaskRecurrence, TaskSource, TickDecision, TickResult,
};
use crate::openhuman::config::Config;
use crate::openhuman::credentials::{AuthService, APP_SESSION_PROVIDER};
use crate::openhuman::memory::tree::chat::{
build_chat_provider, ChatConsumer, ChatPrompt, ChatProvider,
};
@@ -48,6 +50,7 @@ struct EngineState {
last_tick_at: f64,
total_ticks: u64,
consecutive_failures: u64,
provider_unavailable_reason: Option<String>,
seeded: bool,
}
@@ -107,6 +110,7 @@ impl SubconsciousEngine {
last_tick_at,
total_ticks: 0,
consecutive_failures: 0,
provider_unavailable_reason: None,
seeded,
}),
tick_generation: AtomicU64::new(0),
@@ -193,6 +197,39 @@ impl SubconsciousEngine {
debug!("[subconscious] {} due tasks", due_tasks.len());
let config_for_report = match Config::load_or_init().await {
Ok(c) => c,
Err(e) => {
warn!("[subconscious] config load for situation report failed: {e}");
let reason = format!("Config unavailable: {e}");
state.provider_unavailable_reason = Some(reason);
state.consecutive_failures += 1;
state.total_ticks += 1;
return Ok(TickResult {
tick_at,
evaluations: vec![],
executed: 0,
escalated: 0,
duration_ms: started.elapsed().as_millis() as u64,
});
}
};
if let Some(reason) = subconscious_provider_unavailable_reason(&config_for_report) {
info!("[subconscious] provider unavailable, skipping tick: {reason}");
state.provider_unavailable_reason = Some(reason);
state.consecutive_failures += 1;
state.total_ticks += 1;
return Ok(TickResult {
tick_at,
evaluations: vec![],
executed: 0,
escalated: 0,
duration_ms: started.elapsed().as_millis() as u64,
});
}
state.provider_unavailable_reason = None;
// 2. Insert in_progress log entries for each due task
let log_ids: HashMap<String, String> =
store::with_connection(&self.workspace_dir, |conn| {
@@ -221,17 +258,6 @@ impl SubconsciousEngine {
})?;
// 3. Build situation report — memory-tree-derived sections (#623).
let config_for_report = match crate::openhuman::config::Config::load_or_init().await {
Ok(c) => c,
Err(e) => {
warn!("[subconscious] config load for situation report failed: {e}");
// Without config we cannot read memory_tree tables — but we
// can still build the env+tasks+reflections sections by
// passing a default config. The signal sections will report
// themselves as unavailable.
crate::openhuman::config::Config::default()
}
};
// Fetch last 8 reflections for anti-double-emit context.
let recent_reflections = super::store::with_connection(&self.workspace_dir, |conn| {
super::reflection_store::list_recent(conn, 8, None)
@@ -257,7 +283,7 @@ impl SubconsciousEngine {
// 5. Evaluate tasks + emit reflections via cloud chat (#623).
let (evaluations, reflection_drafts) = self
.evaluate_tasks_and_reflections(&due_tasks, &report, &identity)
.evaluate_tasks_and_reflections(&config_for_report, &due_tasks, &report, &identity)
.await;
// Check if we were superseded by a newer tick
@@ -388,6 +414,8 @@ impl SubconsciousEngine {
SubconsciousStatus {
enabled: self.enabled,
provider_available: state.provider_unavailable_reason.is_none(),
provider_unavailable_reason: state.provider_unavailable_reason.clone(),
interval_minutes: self.interval_minutes,
last_tick_at: if state.last_tick_at > 0.0 {
Some(state.last_tick_at)
@@ -504,29 +532,34 @@ impl SubconsciousEngine {
}
}
/// Run the per-tick LLM call. Routes to cloud `summarization-v1` via
/// the memory_tree chat provider (#623). On failure returns
/// Run the per-tick LLM call. Routes via `subconscious_provider`
/// while reusing the memory-tree chat provider plumbing (#623). On failure returns
/// `(empty_evaluations, empty_drafts)` so `last_tick_at` is NOT
/// advanced — the next tick re-fetches from the same point.
async fn evaluate_tasks_and_reflections(
&self,
config: &Config,
tasks: &[SubconsciousTask],
report: &str,
identity: &str,
) -> (Vec<TaskEvaluation>, Vec<ReflectionDraft>) {
let prompt_text = prompt::build_evaluation_prompt(tasks, report, identity);
let config = match crate::openhuman::config::Config::load_or_init().await {
Ok(c) => c,
// Build the chat provider. The subconscious tick uses
// `ChatConsumer::Summarise` because the per-tick payload is
// closer in shape to a structured-summary call than a per-chunk
// entity extraction.
let provider: Arc<dyn ChatProvider> = match build_subconscious_chat_provider(config) {
Ok(p) => p,
Err(e) => {
warn!("[subconscious] config load failed: {e}");
warn!("[subconscious] chat provider init failed: {e}");
return (
tasks
.iter()
.map(|t| TaskEvaluation {
task_id: t.id.clone(),
decision: TickDecision::Noop,
reason: format!("Evaluation failed: config load: {e}"),
reason: format!("Evaluation failed: provider init: {e}"),
})
.collect(),
vec![],
@@ -534,31 +567,6 @@ impl SubconsciousEngine {
}
};
// Build the cloud chat provider. The subconscious tick uses
// `ChatConsumer::Summarise` because the per-tick payload is
// closer in shape to a structured-summary call than a per-chunk
// entity extraction. No local fallback (per #623): if cloud is
// unreachable, return empty results so the tick is treated as a
// skip rather than a malformed advance.
let provider: Arc<dyn ChatProvider> =
match build_chat_provider(&config, ChatConsumer::Summarise) {
Ok(p) => p,
Err(e) => {
warn!("[subconscious] cloud chat provider init failed: {e}");
return (
tasks
.iter()
.map(|t| TaskEvaluation {
task_id: t.id.clone(),
decision: TickDecision::Noop,
reason: format!("Evaluation failed: provider init: {e}"),
})
.collect(),
vec![],
);
}
};
let chat_prompt = ChatPrompt {
system: prompt_text,
user: "Evaluate the due tasks and surface reflections. Reply with JSON only."
@@ -780,6 +788,91 @@ impl SubconsciousEngine {
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
enum SubconsciousProviderRoute {
LocalOllama { endpoint_set: bool, model: String },
OpenHumanCloud,
Other(String),
}
pub(crate) fn subconscious_provider_unavailable_reason(config: &Config) -> Option<String> {
match resolve_subconscious_route(config) {
SubconsciousProviderRoute::LocalOllama {
endpoint_set: true, ..
} => None,
SubconsciousProviderRoute::LocalOllama {
endpoint_set: false,
..
} => Some(
"Configure the Ollama summarizer endpoint for Subconscious in Settings > AI."
.to_string(),
),
SubconsciousProviderRoute::OpenHumanCloud => {
if crate::openhuman::scheduler_gate::is_signed_out() {
return Some(
"Sign in to use the OpenHuman cloud Subconscious provider.".to_string(),
);
}
let state_dir = config
.config_path
.parent()
.map(std::path::PathBuf::from)
.unwrap_or_else(|| config.workspace_dir.clone());
let auth = AuthService::new(&state_dir, config.secrets.encrypt);
match auth.get_provider_bearer_token(APP_SESSION_PROVIDER, None) {
Ok(Some(token)) if !token.trim().is_empty() => None,
Ok(_) => Some(
"Sign in or configure a local Subconscious provider in Settings > AI."
.to_string(),
),
Err(e) => Some(format!("Unable to read the OpenHuman session: {e}")),
}
}
SubconsciousProviderRoute::Other(_) => None,
}
}
fn resolve_subconscious_route(config: &Config) -> SubconsciousProviderRoute {
if let Some(model) = config.workload_local_model("subconscious") {
let endpoint_set = config
.memory_tree
.llm_summariser_endpoint
.as_deref()
.map(str::trim)
.is_some_and(|s| !s.is_empty());
return SubconsciousProviderRoute::LocalOllama {
endpoint_set,
model,
};
}
let raw = config
.subconscious_provider
.as_deref()
.map(str::trim)
.filter(|s| !s.is_empty())
.unwrap_or("cloud");
let is_openhuman_cloud = raw.eq_ignore_ascii_case("cloud")
|| raw.eq_ignore_ascii_case("openhuman")
|| raw.to_ascii_lowercase().starts_with("openhuman:");
if is_openhuman_cloud {
SubconsciousProviderRoute::OpenHumanCloud
} else {
SubconsciousProviderRoute::Other(raw.to_string())
}
}
fn build_subconscious_chat_provider(config: &Config) -> Result<Arc<dyn ChatProvider>> {
let mut routed = config.clone();
routed.memory_provider = match resolve_subconscious_route(config) {
SubconsciousProviderRoute::LocalOllama { model, .. } => Some(format!("ollama:{model}")),
SubconsciousProviderRoute::OpenHumanCloud => Some("cloud".to_string()),
SubconsciousProviderRoute::Other(provider) => Some(provider),
};
build_chat_provider(&routed, ChatConsumer::Summarise)
}
/// Parse the per-tick LLM response into evaluations + reflection drafts.
///
/// Best-effort: if the JSON has only `evaluations`, `reflections` is
+126
View File
@@ -1,5 +1,27 @@
use super::*;
struct EnvVarGuard {
key: &'static str,
old: Option<std::ffi::OsString>,
}
impl EnvVarGuard {
fn set_to_path(key: &'static str, path: &std::path::Path) -> Self {
let old = std::env::var_os(key);
std::env::set_var(key, path);
Self { key, old }
}
}
impl Drop for EnvVarGuard {
fn drop(&mut self) {
match &self.old {
Some(value) => std::env::set_var(self.key, value),
None => std::env::remove_var(self.key),
}
}
}
fn test_tasks() -> Vec<SubconsciousTask> {
vec![
SubconsciousTask {
@@ -27,6 +49,110 @@ fn test_tasks() -> Vec<SubconsciousTask> {
]
}
#[tokio::test]
async fn tick_skips_unavailable_provider_without_activity_log_spam() {
let _env_lock = crate::openhuman::config::TEST_ENV_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let tmp = tempfile::tempdir().expect("tempdir");
let _workspace = EnvVarGuard::set_to_path("OPENHUMAN_WORKSPACE", tmp.path());
let config = Config::load_or_init().await.expect("load test config");
let engine = SubconsciousEngine::from_heartbeat_config(
&config.heartbeat,
config.workspace_dir.clone(),
None,
);
let result = engine.tick().await.expect("tick should skip cleanly");
assert!(result.evaluations.is_empty());
let logs = store::with_connection(&config.workspace_dir, |conn| {
store::list_log_entries(conn, None, 20)
})
.expect("list logs");
assert!(
logs.is_empty(),
"provider skip must not append per-task failure log entries"
);
let status = engine.status().await;
assert_eq!(status.consecutive_failures, 1);
assert!(!status.provider_available);
assert!(status
.provider_unavailable_reason
.as_deref()
.unwrap_or_default()
.contains("Sign in"));
let _second = engine.tick().await.expect("repeat skip should be clean");
let logs = store::with_connection(&config.workspace_dir, |conn| {
store::list_log_entries(conn, None, 20)
})
.expect("list logs after repeat");
assert!(logs.is_empty(), "repeat skips must not spam activity log");
}
#[test]
fn local_subconscious_provider_with_endpoint_is_available() {
let tmp = tempfile::tempdir().expect("tempdir");
let mut config = Config::default();
config.config_path = tmp.path().join("config.toml");
config.workspace_dir = tmp.path().join("workspace");
config.subconscious_provider = Some("ollama:qwen2.5:0.5b".into());
config.memory_tree.llm_summariser_endpoint = Some("http://localhost:11434".into());
assert!(subconscious_provider_unavailable_reason(&config).is_none());
}
#[test]
fn local_subconscious_route_preserves_ollama_model() {
let mut config = Config::default();
config.subconscious_provider = Some("ollama:qwen2.5:0.5b".into());
config.memory_tree.llm_summariser_endpoint = Some("http://localhost:11434".into());
assert_eq!(
resolve_subconscious_route(&config),
SubconsciousProviderRoute::LocalOllama {
endpoint_set: true,
model: "qwen2.5:0.5b".into(),
}
);
}
#[test]
fn local_subconscious_provider_without_endpoint_is_unavailable() {
let mut config = Config::default();
config.subconscious_provider = Some("ollama:qwen2.5:0.5b".into());
config.memory_tree.llm_summariser_endpoint = None;
let reason = subconscious_provider_unavailable_reason(&config).expect("unavailable reason");
assert!(reason.contains("Ollama summarizer endpoint"));
}
#[test]
fn openhuman_subconscious_alias_uses_cloud_route() {
let mut config = Config::default();
config.subconscious_provider = Some("openhuman:summarization".into());
assert_eq!(
resolve_subconscious_route(&config),
SubconsciousProviderRoute::OpenHumanCloud
);
}
#[test]
fn explicit_subconscious_provider_uses_other_route() {
let mut config = Config::default();
config.subconscious_provider = Some("custom-provider".into());
assert_eq!(
resolve_subconscious_route(&config),
SubconsciousProviderRoute::Other("custom-provider".into())
);
assert!(subconscious_provider_unavailable_reason(&config).is_none());
}
#[test]
fn parse_evaluation_response() {
let json = r#"{"evaluations": [
+7
View File
@@ -291,8 +291,15 @@ fn handle_status(_params: Map<String, Value>) -> ControllerFuture {
})
.map_err(|e| e.to_string())?;
let provider_unavailable_reason = if hb.enabled && hb.inference_enabled {
super::engine::subconscious_provider_unavailable_reason(&config)
} else {
None
};
let status = super::types::SubconsciousStatus {
enabled: hb.enabled && hb.inference_enabled,
provider_available: provider_unavailable_reason.is_none(),
provider_unavailable_reason,
interval_minutes: hb.interval_minutes.max(5),
last_tick_at,
total_ticks,
+2
View File
@@ -148,6 +148,8 @@ pub enum EscalationStatus {
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SubconsciousStatus {
pub enabled: bool,
pub provider_available: bool,
pub provider_unavailable_reason: Option<String>,
pub interval_minutes: u32,
pub last_tick_at: Option<f64>,
pub total_ticks: u64,