diff --git a/app/src/components/intelligence/CodingSessionsCard.tsx b/app/src/components/intelligence/CodingSessionsCard.tsx index 28829762c27..ea44d42ab12 100644 --- a/app/src/components/intelligence/CodingSessionsCard.tsx +++ b/app/src/components/intelligence/CodingSessionsCard.tsx @@ -59,8 +59,10 @@ export function CodingSessionsCard({ onToast }: CodingSessionsCardProps) { }), [sources] ); - const hasImportableHistory = - totals.files > 0 || sources.some(source => source.scan_truncated === true); + const scopedCodex = sources.find(source => source.kind === 'codex' && source.project_scope); + const hasImportableHistory = scopedCodex + ? scopedCodex.session_files > 0 || scopedCodex.scan_truncated === true + : totals.files > 0 || sources.some(source => source.scan_truncated === true); const ingest = useCallback(async () => { console.debug('[coding-sessions] drain: entry'); @@ -108,11 +110,17 @@ export function CodingSessionsCard({ onToast }: CodingSessionsCardProps) { : t('memorySources.codingSessions.completeMessage') .replace('{processed}', String(result.sessionsProcessed)) .replace('{observations}', String(result.observations)); + const diagnostics = result.failures + ?.slice(0, 10) + .map(failure => failure.summary) + .join(' '); const title = result.timedOut ? t('memorySources.codingSessions.stillRunning') - : incomplete - ? t('memorySources.codingSessions.stopped') - : t('memorySources.codingSessions.complete'); + : result.sessionsFailed > 0 + ? t('memorySources.codingSessions.failed') + : incomplete + ? t('memorySources.codingSessions.stopped') + : t('memorySources.codingSessions.complete'); onToast?.({ type: result.timedOut ? 'info' @@ -125,6 +133,7 @@ export function CodingSessionsCard({ onToast }: CodingSessionsCardProps) { message, }); await load(); + if (!result.timedOut && result.sessionsFailed > 0) setError(diagnostics || message); } catch (cause) { console.error('[coding-sessions] drain failed', cause); const message = cause instanceof Error ? cause.message : String(cause); @@ -181,6 +190,14 @@ export function CodingSessionsCard({ onToast }: CodingSessionsCardProps) { + {scopedCodex && ( +

+ {t('memorySources.codingSessions.projectScope').replace( + '{project}', + scopedCodex.project_scope ?? '' + )} +

+ )} {ingesting && progress && (

0 + ? ` · ${t('memorySources.codingSessions.checkpoints').replace('{count}', String(progress.checkpointsAdvanced))}` + : ''} {progress.moreRemaining ? ` · ${t('memorySources.codingSessions.remaining').replace( '{remaining}', @@ -210,10 +230,24 @@ export function CodingSessionsCard({ onToast }: CodingSessionsCardProps) {

{source.available ? t('memorySources.codingSessions.counts') - .replace('{files}', String(source.session_files)) - .replace('{evidence}', String(source.evidence_units)) + .replace( + '{files}', + `${source.session_files}${source.scan_truncated ? '+' : ''}` + ) + .replace( + '{evidence}', + `${source.evidence_units}${source.scan_truncated ? '+' : ''}` + ) : t('memorySources.codingSessions.notFound')}
+ {source.kind === 'codex' && (source.sessions_excluded ?? 0) > 0 && ( +

+ {t('memorySources.codingSessions.excluded').replace( + '{count}', + String(source.sessions_excluded) + )} +

+ )} {source.available && source.scan_truncated && (
{t('memorySources.codingSessions.truncated')} diff --git a/app/src/components/intelligence/__tests__/CodingSessionsCard.test.tsx b/app/src/components/intelligence/__tests__/CodingSessionsCard.test.tsx index 9ba2be2c9d1..337d0acb350 100644 --- a/app/src/components/intelligence/__tests__/CodingSessionsCard.test.tsx +++ b/app/src/components/intelligence/__tests__/CodingSessionsCard.test.tsx @@ -288,7 +288,9 @@ describe('CodingSessionsCard', () => { ]); renderWithProviders(); - expect(await screen.findByText('Scan limited to the first 1,000 session files.')).toBeVisible(); + expect( + await screen.findByText('Scan reached its file or byte limit. Counts are lower bounds.') + ).toBeVisible(); }); it('keeps ingestion enabled when a capped scan has not found evidence yet', async () => { @@ -307,3 +309,83 @@ describe('CodingSessionsCard', () => { expect(await screen.findByTestId('coding-sessions-ingest')).toBeEnabled(); }); }); + +describe('Codex import diagnostics', () => { + it('shows scope, exclusions and lower-bound scan counts', async () => { + mockedStatus.mockResolvedValue([ + { + kind: 'codex', + available: true, + session_files: 12, + evidence_units: 20, + invalid_files: 0, + scan_truncated: true, + project_scope: '/workspace/project', + sessions_excluded: 3, + }, + ]); + renderWithProviders(); + expect( + await screen.findByText('Importing Codex sessions for /workspace/project only.') + ).toBeInTheDocument(); + expect(screen.getByTestId('coding-session-source-codex')).toHaveTextContent( + '12+ sessions · 20+ human turns' + ); + expect(screen.getByTestId('coding-session-source-codex')).toHaveTextContent( + '3 sessions excluded' + ); + }); + + it('keeps actionable partial failure visible and never announces success', async () => { + mockedStatus.mockResolvedValue([ + { kind: 'codex', available: true, session_files: 2, evidence_units: 2, invalid_files: 0 }, + ]); + mockedDrain.mockResolvedValue({ + passes: 1, + sessionsProcessed: 1, + sessionsFailed: 1, + observations: 2, + remaining: 1, + moreRemaining: true, + timedOut: false, + failures: [ + { + code: 'provider_failure', + session_id: 'opaque', + summary: 'Check provider access and retry.', + }, + ], + }); + const onToast = vi.fn(); + renderWithProviders(); + fireEvent.click(await screen.findByTestId('coding-sessions-ingest')); + expect(await screen.findByRole('alert')).toHaveTextContent('Check provider access and retry.'); + expect(onToast).toHaveBeenCalledWith( + expect.objectContaining({ type: 'warning', title: 'Coding-session ingestion failed' }) + ); + }); +}); + +it('shows saved checkpoint progress before a session completes', async () => { + mockedStatus.mockResolvedValue([ + { kind: 'codex', available: true, session_files: 1, evidence_units: 100, invalid_files: 0 }, + ]); + mockedDrain.mockImplementation(({ onProgress } = {}) => { + onProgress?.({ + passes: 1, + sessionsProcessed: 0, + sessionsFailed: 0, + observations: 0, + remaining: 1, + moreRemaining: true, + timedOut: false, + checkpointsAdvanced: 5, + }); + return new Promise(() => {}); + }); + renderWithProviders(); + fireEvent.click(await screen.findByTestId('coding-sessions-ingest')); + expect(await screen.findByTestId('coding-sessions-progress')).toHaveTextContent( + '5 digest checkpoints saved' + ); +}); diff --git a/app/src/lib/i18n/en.ts b/app/src/lib/i18n/en.ts index 990426720fe..159071d7bb7 100644 --- a/app/src/lib/i18n/en.ts +++ b/app/src/lib/i18n/en.ts @@ -7466,6 +7466,10 @@ const en: TranslationMap = { 'memoryBudget.exhaustedMessage': 'Your embedding budget is used up, so new content is no longer being added to memory. Set up local embeddings or add your own API key to resume.', 'memoryBudget.cta': 'Set up embeddings', + 'memorySources.codingSessions.checkpoints': '{count} digest checkpoints saved', + 'memorySources.codingSessions.projectScope': 'Importing Codex sessions for {project} only.', + 'memorySources.codingSessions.excluded': + '{count} sessions excluded: project provenance is missing or outside the selected project.', 'memorySources.codingSessions.title': 'Coding-agent sessions', 'memorySources.codingSessions.description': 'Turn your Codex and Claude Code decisions and corrections into private persona memory.', @@ -7483,7 +7487,8 @@ const en: TranslationMap = { 'memorySources.codingSessions.counts': '{files} sessions · {evidence} human turns', 'memorySources.codingSessions.notFound': 'No local history found', 'memorySources.codingSessions.scanning': 'Scanning local session history…', - 'memorySources.codingSessions.truncated': 'Scan limited to the first 1,000 session files.', + 'memorySources.codingSessions.truncated': + 'Scan reached its file or byte limit. Counts are lower bounds.', 'memorySources.codingSessions.complete': 'Coding sessions ingested', 'memorySources.codingSessions.completeMessage': '{processed} sessions produced {observations} persona observations.', diff --git a/app/src/services/memorySourcesService.test.ts b/app/src/services/memorySourcesService.test.ts index 4a590403bf3..13e709adb9d 100644 --- a/app/src/services/memorySourcesService.test.ts +++ b/app/src/services/memorySourcesService.test.ts @@ -415,3 +415,60 @@ describe('memorySourcesService', () => { expect(result.moreRemaining).toBe(true); }); }); + +describe('Codex checkpoint reports', () => { + it('continues after checkpoint progress without a completed session', async () => { + const base = { + mode: 'incremental', + files_seen: 1, + sessions_skipped: 0, + sessions_failed: 0, + evidence_units: 20, + observations: 0, + }; + mockedCall.mockReset(); + mockedCall.mockResolvedValueOnce({ + result: { ...base, sessions_processed: 0, checkpoints_advanced: 5, budget_hit: true }, + logs: [], + } as never); + mockedCall.mockResolvedValueOnce({ + result: { ...base, sessions_processed: 1, checkpoints_advanced: 2, budget_hit: false }, + logs: [], + } as never); + const result = await drainCodingSessions(); + expect(mockedCall).toHaveBeenCalledTimes(2); + expect(result.sessionsProcessed).toBe(1); + expect(result.checkpointsAdvanced).toBe(7); + expect(result.moreRemaining).toBe(false); + }); + + it('retains failures and stops automatic retry even with a remaining budget', async () => { + mockedCall.mockReset(); + const failures = [ + { + code: 'parse_failure', + session_id: 'opaque', + summary: 'Check the summarisation provider and retry.', + }, + ]; + mockedCall.mockResolvedValue({ + result: { + mode: 'incremental', + files_seen: 2, + sessions_processed: 1, + sessions_skipped: 0, + sessions_failed: 1, + evidence_units: 2, + observations: 1, + checkpoints_advanced: 1, + failures, + budget_hit: true, + }, + logs: [], + } as never); + const result = await drainCodingSessions(); + expect(mockedCall).toHaveBeenCalledTimes(1); + expect(result.moreRemaining).toBe(true); + expect(result.failures).toEqual(failures); + }); +}); diff --git a/app/src/services/memorySourcesService.ts b/app/src/services/memorySourcesService.ts index d032126cd40..707702ef00c 100644 --- a/app/src/services/memorySourcesService.ts +++ b/app/src/services/memorySourcesService.ts @@ -252,6 +252,14 @@ export interface CodingSessionSourceStatus { evidence_units: number; invalid_files: number; scan_truncated?: boolean; + project_scope?: string | null; + sessions_excluded?: number; +} + +export interface CodingSessionFailure { + code: string; + session_id: string; + summary: string; } export interface CodingSessionIngestResult { @@ -264,6 +272,9 @@ export interface CodingSessionIngestResult { observations: number; budget_hit: boolean; pack_path?: string | null; + checkpoints_advanced?: number; + failures?: CodingSessionFailure[]; + sessions_excluded?: number; } // A single ingest RPC is bounded so it fits under the core RPC client's hard @@ -331,6 +342,10 @@ export async function ingestCodingSessions( } export interface CodingSessionDrainProgress { + /** Newly persisted digest pieces across completed passes. */ + checkpointsAdvanced?: number; + /** Sanitised diagnostics from the latest pass. */ + failures?: CodingSessionFailure[]; /** Bounded ingest RPC passes completed so far in this drain. */ passes: number; /** Sessions distilled across every pass in this drain. */ @@ -440,6 +455,8 @@ export async function drainCodingSessions( remaining: 0, moreRemaining: false, timedOut: false, + checkpointsAdvanced: 0, + failures: [], }; log('drain_coding_sessions: entry max_per_pass=%d max_passes=%d', maxSessionsPerPass, maxPasses); @@ -471,6 +488,9 @@ export async function drainCodingSessions( progress.passes += 1; progress.sessionsProcessed += result.sessions_processed; progress.sessionsFailed = result.sessions_failed; + progress.checkpointsAdvanced = + (progress.checkpointsAdvanced ?? 0) + (result.checkpoints_advanced ?? 0); + progress.failures = result.failures ?? []; progress.observations += result.observations; // files_seen is the discovered total for this scan; skipped + processed is // what this pass accounted for, so the remainder is the honest backlog. @@ -478,9 +498,10 @@ export async function drainCodingSessions( 0, result.files_seen - result.sessions_skipped - result.sessions_processed ); - progress.moreRemaining = result.budget_hit; + progress.moreRemaining = result.budget_hit || result.sessions_failed > 0; onProgress?.({ ...progress }); + if (result.sessions_failed > 0) break; if (!result.budget_hit) { log( 'drain_coding_sessions: drained after pass=%d processed=%d', @@ -489,7 +510,7 @@ export async function drainCodingSessions( ); break; } - if (result.sessions_processed === 0) { + if (result.sessions_processed === 0 && (result.checkpoints_advanced ?? 0) === 0) { // The backlog still reports more work, but this pass distilled nothing // new — every remaining candidate failed or could not advance. Stop // rather than spin; the caller surfaces the retained failures. diff --git a/crates/openhuman-core/src/memory/sources/rpc/coding_sessions.rs b/crates/openhuman-core/src/memory/sources/rpc/coding_sessions.rs index 89591948c47..ff9b3d10d6e 100644 --- a/crates/openhuman-core/src/memory/sources/rpc/coding_sessions.rs +++ b/crates/openhuman-core/src/memory/sources/rpc/coding_sessions.rs @@ -175,6 +175,10 @@ pub async fn ingest_coding_sessions_rpc( )); }; + // Check the existing consent/provider ladder before any transcript can be + // sent to inference. This reports a missing step without changing settings. + validate_ingestion_provider(&config)?; + // Wall-clock ceiling so a stalled provider call or a wedged session step // can't keep the RPC waiting indefinitely (#4863 review), sized to the // requested backfill so a legitimate large run isn't killed mid-flight @@ -205,3 +209,19 @@ pub async fn ingest_coding_sessions_rpc( ); Ok(Outcome::new(report, vec![])) } + +/// Refuse missing processing prerequisites without changing consent or routing. +fn validate_ingestion_provider(config: &Config) -> Result<(), String> { + if !config.local_ai.runtime_enabled && !config.memory_tree.cloud_summarization_opt_in { + return Err("cloud_processing_disabled: enable local AI, or explicitly opt in to cloud memory processing before importing sessions".into()); + } + let (available, _) = crate::memory::tree::tree_runtime::ops::summarizer_available(config); + if !available { + return Err("summarization_unavailable: configure a summarisation provider in Connections, or check that your local AI model is available".into()); + } + Ok(()) +} + +#[cfg(test)] +#[path = "coding_sessions_tests.rs"] +mod tests; diff --git a/crates/openhuman-core/src/memory/sources/rpc/coding_sessions_tests.rs b/crates/openhuman-core/src/memory/sources/rpc/coding_sessions_tests.rs new file mode 100644 index 00000000000..285e186fab8 --- /dev/null +++ b/crates/openhuman-core/src/memory/sources/rpc/coding_sessions_tests.rs @@ -0,0 +1,38 @@ +//! Coding-session ingestion prerequisite checks. +use super::*; + +#[test] +fn cloud_opt_out_is_refused_before_provider_construction_and_stays_unchanged() { + let mut config = Config::default(); + config.local_ai.runtime_enabled = false; + config.memory_tree.cloud_summarization_opt_in = false; + let before = config.memory_provider.clone(); + let error = validate_ingestion_provider(&config).unwrap_err(); + assert!(error.starts_with("cloud_processing_disabled:")); + assert!(!config.memory_tree.cloud_summarization_opt_in); + assert_eq!(config.memory_provider, before); +} + +#[test] +fn local_processing_passes_preflight_without_cloud_opt_in() { + let mut config = Config::default(); + config.local_ai.runtime_enabled = true; + config.local_ai.chat_model_id = "offline-fixture".into(); + config.memory_tree.cloud_summarization_opt_in = false; + assert!(validate_ingestion_provider(&config).is_ok()); + assert!(!config.memory_tree.cloud_summarization_opt_in); +} + +#[test] +fn missing_provider_reports_the_step_without_changing_the_route() { + let mut config = Config::default(); + config.local_ai.runtime_enabled = false; + config.memory_tree.cloud_summarization_opt_in = true; + config.memory_provider = Some("invalid-fixture-provider:model".into()); + let error = validate_ingestion_provider(&config).unwrap_err(); + assert!(error.starts_with("summarization_unavailable:")); + assert_eq!( + config.memory_provider.as_deref(), + Some("invalid-fixture-provider:model") + ); +} diff --git a/docs/RELEASE-MANUAL-SMOKE.md b/docs/RELEASE-MANUAL-SMOKE.md index b3008c2205d..bb64e0e7c1b 100644 --- a/docs/RELEASE-MANUAL-SMOKE.md +++ b/docs/RELEASE-MANUAL-SMOKE.md @@ -129,3 +129,5 @@ Notes: ``` Paste the filled block as a commit comment on the `v-staging` tagged commit before promoting to production. + +- [ ] **Codex session import** — On Brain > Sources, import a Codex history with a long session and pause between passes. Expected: saved checkpoints advance before session completion and resume without re-sending completed windows. With a configured driver project scope, only matching Codex sessions are imported and exclusions are explained. Missing processing consent/provider shows an actionable alert without changing settings; partial failures never show a success toast. diff --git a/docs/TEST-COVERAGE-MATRIX.md b/docs/TEST-COVERAGE-MATRIX.md index 54625141c30..fd98b0eb204 100644 --- a/docs/TEST-COVERAGE-MATRIX.md +++ b/docs/TEST-COVERAGE-MATRIX.md @@ -354,7 +354,7 @@ End-to-end coverage of the agent harness via the web-chat RPC surface against an | 8.2.2 | Memory Consistency | RI | `tests/worker_c_modules_e2e.rs` | ✅ | Worker C RPC E2E verifies memory-tree ingest is reflected by `memory_sync_status_list` | | 8.2.3 | Memory Scaling | RU | `vendor/tinymemory/crates/tinymemory-core/src/ingest_pipeline_tests.rs`, `vendor/tinymemory/crates/tinymemory-core/src/ingestion/queue_tests.rs` (vendored `tinymemory`) | 🟡 | Ingestion moved into the `tinymemory` module; soak/scale benchmark not asserted | | 8.2.4 | Raw-archive sync reconcile | RU+RI | `crates/openhuman-core/src/memory/sources/reconcile_tests.rs`, `crates/openhuman-core/src/memory/sources/sync_tests.rs`, `vendor/tinymemory/crates/tinymemory-core/src/sync/workspace/periodic_tests.rs` (vendored `tinymemory`), `tests/json_rpc_e2e.rs` (`json_rpc_memory_sources_reconcile_reports_pending_raw_files`), `tests/memory_sources_e2e.rs` | ✅ | Coverage gate + incremental rebuild + workspace periodic scheduler + `memory_sources_reconcile` RPC | -| 8.2.5 | Coding-session persona ingestion | RU+RI+VU+WD | `vendor/tinymemory/crates/tinymemory-core/src/engine/persona_tests.rs` (vendored `tinymemory`), `crates/openhuman-core/src/memory/sources/rpc_tests.rs`, `tests/json_rpc_e2e.rs`, `app/src/components/intelligence/__tests__/CodingSessionsCard.test.tsx`, `app/src/services/memorySourcesService.test.ts`, `app/test/e2e/specs/coding-session-memory.spec.ts`, `app/test/playwright/specs/coding-session-memory.spec.ts` | ✅ | Discovers Codex and Claude Code histories, excludes machine-authored turns, exposes status/ingest RPCs, and surfaces incremental ingestion on Brain > Sources | +| 8.2.5 | Coding-session persona ingestion | RU+RI+VU+WD | `vendor/tinymemory/crates/tinymemory-core/src/engine/persona_tests.rs` (vendored `tinymemory`), `crates/openhuman-core/src/memory/sources/rpc_tests.rs`, `crates/openhuman-core/src/memory/sources/rpc/coding_sessions_tests.rs`, `vendor/tinymemory/vendor/tinycortex/src/memory/persona/checkpoint_tests.rs`, `tests/json_rpc_e2e.rs`, `app/src/components/intelligence/__tests__/CodingSessionsCard.test.tsx`, `app/src/services/memorySourcesService.test.ts`, `app/test/e2e/specs/coding-session-memory.spec.ts`, `app/test/playwright/specs/coding-session-memory.spec.ts` | ✅ | Discovers Codex and Claude Code histories, excludes machine-authored turns, exposes status/ingest RPCs, and surfaces incremental ingestion on Brain > Sources; Codex project filtering, durable digest checkpoints, safe diagnostics and processing preflight | | 8.2.6 | Folder-source path picker | RU+VU | `crates/openhuman-app/src/directory_picker.rs`, `app/src/utils/tauriCommands/directoryPicker.test.ts`, `app/src/components/intelligence/FolderField.test.tsx` | ✅ | Browse resolves an absolute path through the OS-native chooser and refuses to store a non-resolvable value (bare directory name) when none is available (#5831) | | 8.2.7 | Sync History and live sync activity | RU+RI+VU | `crates/openhuman-core/src/memory/sources/run_history_tests.rs`, `crates/openhuman-core/src/memory/sources/rpc/driver_run_tests.rs`, `crates/openhuman-core/src/memory/sources/rpc_monthly_summary_tests_tests.rs`, `crates/openhuman-core/src/integrations/composio/ops/connector_runs_tests.rs`, `crates/openhuman-core/src/integrations/composio/ops/source_rows_tests.rs`, `crates/openhuman-core/src/memory/sync_activity_tests.rs`, `tests/memory_sources_e2e.rs` (`sources_sync_dispatches_to_the_module_rather_than_refusing_the_capability`), `app/src/components/intelligence/SyncAuditPanel.test.tsx`, `app/src/components/intelligence/SyncActivityCard.test.tsx`, `app/src/components/intelligence/__tests__/memorySyncActivityStore.test.ts`, `app/src/components/intelligence/MemorySourceRow.test.tsx`, `app/src/components/intelligence/MemoryTreeStatusPanel.test.tsx` | ✅ | Every host-driven run (Sync button, Apply all, Composio) leaves one history row merged with the driver's audit log; manual driver runs end in `completed` or `failed`; a late per-document stage cannot reopen a finished row; Brain > Sync shows what is syncing and the job queue, and re-reads the history when a run ends (#6257) | | 8.2.8 | Selectable memory engines and migration | RU+RI+VU | `crates/openhuman-core/src/memory/ops/engine_migrate_tests.rs`, `tests/memory_engine_e2e.rs`, `tests/memory_engine_migrate_e2e.rs`, `app/src/components/settings/panels/MemoryEnginePanel.test.tsx`, `app/src/components/intelligence/MemoryFamilyGate.test.tsx` | ✅ | Engine selection, migration copy/cancel/failure behavior, hosted and external drivers, and capability gating | diff --git a/vendor/tinymemory b/vendor/tinymemory index 99cdd6e33fb..f979b6805a2 160000 --- a/vendor/tinymemory +++ b/vendor/tinymemory @@ -1 +1 @@ -Subproject commit 99cdd6e33fbf6ecb1ce5d50af9526b6002af0f9a +Subproject commit f979b6805a2fd9458a9404f30711121caf03cd91