Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 41 additions & 7 deletions app/src/components/intelligence/CodingSessionsCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -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'
Expand All @@ -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);
Expand Down Expand Up @@ -181,6 +190,14 @@ export function CodingSessionsCard({ onToast }: CodingSessionsCardProps) {
</div>
</div>

{scopedCodex && (
<p className="mt-3 text-xs text-content-secondary" role="status">
{t('memorySources.codingSessions.projectScope').replace(
'{project}',
scopedCodex.project_scope ?? ''
)}
</p>
)}
{ingesting && progress && (
<p
className="mt-3 text-xs text-content-secondary"
Expand All @@ -189,6 +206,9 @@ export function CodingSessionsCard({ onToast }: CodingSessionsCardProps) {
{t('memorySources.codingSessions.progress')
.replace('{processed}', String(progress.sessionsProcessed))
.replace('{observations}', String(progress.observations))}
{(progress.checkpointsAdvanced ?? 0) > 0
? ` · ${t('memorySources.codingSessions.checkpoints').replace('{count}', String(progress.checkpointsAdvanced))}`
: ''}
{progress.moreRemaining
? ` · ${t('memorySources.codingSessions.remaining').replace(
'{remaining}',
Expand All @@ -210,10 +230,24 @@ export function CodingSessionsCard({ onToast }: CodingSessionsCardProps) {
<div className="mt-1 text-xs text-content-secondary">
{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')}
</div>
{source.kind === 'codex' && (source.sessions_excluded ?? 0) > 0 && (
<p className="mt-1 text-xs text-content-secondary">
{t('memorySources.codingSessions.excluded').replace(
'{count}',
String(source.sessions_excluded)
)}
</p>
)}
{source.available && source.scan_truncated && (
<div className="mt-1 text-xs text-amber-600 dark:text-amber-400">
{t('memorySources.codingSessions.truncated')}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -288,7 +288,9 @@ describe('CodingSessionsCard', () => {
]);
renderWithProviders(<CodingSessionsCard />);

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 () => {
Expand All @@ -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(<CodingSessionsCard />);
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(<CodingSessionsCard onToast={onToast} />);
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(<CodingSessionsCard />);
fireEvent.click(await screen.findByTestId('coding-sessions-ingest'));
expect(await screen.findByTestId('coding-sessions-progress')).toHaveTextContent(
'5 digest checkpoints saved'
);
});
7 changes: 6 additions & 1 deletion app/src/lib/i18n/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.',
Expand All @@ -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.',
Expand Down
57 changes: 57 additions & 0 deletions app/src/services/memorySourcesService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
25 changes: 23 additions & 2 deletions app/src/services/memorySourcesService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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
Expand Down Expand Up @@ -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. */
Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -471,16 +488,20 @@ 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.
progress.remaining = Math.max(
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',
Expand All @@ -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.
Expand Down
20 changes: 20 additions & 0 deletions crates/openhuman-core/src/memory/sources/rpc/coding_sessions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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;
Loading
Loading