mirror of
https://github.com/DeusData/codebase-memory-mcp.git
synced 2026-11-04 18:58:00 +00:00
fix(ui): retire a cached explanation when its scope's evidence changed
The explanation cache is keyed by selection and scope shape only, so that a scope that is still loading finds its explanation. After a re-index of the same project in the same session, returning to a selection still showed the explanation of the old graph. Every cached explanation now keeps the id of the complete evidence it was written from. When the same selection completes with other evidence, the entry is dropped and the selection is explained again; while it loads, the cached text stays. Signed-off-by: Bernhard Jackiewicz <bj@techport.io>
This commit is contained in:
1 parent
fdb8142473
commit
62257fd418
4 files changed
+40
-6
No files matched your search
@@ -1049,6 +1049,22 @@ describe('per-selection explanation cache', () => {
|
||||
await settleSelection(); expect(runtime.chat).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('explains again when a returning selection now carries other evidence, as after a re-index', async () => {
|
||||
vi.useFakeTimers(); const { props, runtime } = fixture();
|
||||
runtime.chat.mockResolvedValueOnce('Before the re-index.').mockResolvedValueOnce('Two layers.').mockResolvedValueOnce('After the re-index.');
|
||||
await render({ ...props, proactive: true, proactiveSelection: jsonbAggEvidence() }); await click('Download & load');
|
||||
await settleSelection(); expect(card()).toContain('Before the re-index.');
|
||||
await render({ ...props, proactive: true, proactiveSelection: jsonbAggEvidence({ depth: 2 }) }); await settleSelection();
|
||||
// The same selection, loading and then complete, after a re-index removed a caller.
|
||||
await render({ ...props, proactive: true, proactiveSelection: jsonbAggEvidence({ state: 'loading-partial-preview' }) });
|
||||
expect(card()).toContain('Before the re-index.');
|
||||
await render({ ...props, proactive: true, proactiveSelection: jsonbAggEvidence({ edges: jsonbAggScope().edges.slice(2) }) });
|
||||
expect(card()).not.toContain('Before the re-index.');
|
||||
await settleSelection();
|
||||
expect(runtime.chat).toHaveBeenCalledTimes(3);
|
||||
expect(card()).toContain('After the re-index.');
|
||||
});
|
||||
|
||||
it('regenerates on request and keeps the cache bounded', async () => {
|
||||
vi.useFakeTimers(); const { props, runtime } = fixture();
|
||||
let calls = 0; runtime.chat.mockImplementation(async () => `Explanation number ${++calls}.`);
|
||||
|
||||
@@ -42,7 +42,7 @@ export interface BrowserChatDockProps {
|
||||
|
||||
type Phase = 'off' | 'preparing' | 'ready' | 'counting' | 'generating' | 'removing';
|
||||
type ChatTurn = BrowserChatTurn & { evidence?: PreparedExplanationContext };
|
||||
type Explanation = { key: string; label: string; answer: string; status: string; error?: string; packet?: PreparedExplanationContext; citation?: ReturnType<typeof citedInterpretation>; mode?: 'interpretation'; shortened?: boolean };
|
||||
type Explanation = { key: string; label: string; answer: string; status: string; error?: string; packet?: PreparedExplanationContext; citation?: ReturnType<typeof citedInterpretation>; mode?: 'interpretation'; shortened?: boolean; evidence?: string };
|
||||
/** Finished explanations per selection, so returning to one does not run the model again. */
|
||||
const EXPLANATION_CACHE_SIZE = 32;
|
||||
const initialModel = BROWSER_MODELS.find(model => model.availability === 'available')!;
|
||||
@@ -213,19 +213,25 @@ export default function BrowserChatDock({ proactiveSelection, selectionScope = "
|
||||
}
|
||||
}, [selected?.key, automatic, proactive]);
|
||||
useEffect(() => {
|
||||
// Returning to an explained selection shows what was already written.
|
||||
// Returning to an explained selection shows what was already written, unless its
|
||||
// complete scope now carries other evidence, as after a re-index.
|
||||
const cached = selected && explanations.current.get(selected.key);
|
||||
if (!cached) return;
|
||||
if (selected.evidence && cached.evidence !== selected.evidence) {
|
||||
explanations.current.delete(selected.key); lastAttempt.current = undefined;
|
||||
setExplanation(previous => previous?.key === selected.key ? undefined : previous);
|
||||
return;
|
||||
}
|
||||
lastAttempt.current = cached.key;
|
||||
setExplanation(previous => previous?.key === cached.key ? previous : cached);
|
||||
}, [selected?.key]);
|
||||
}, [selected?.key, selected?.evidence]);
|
||||
useEffect(() => {
|
||||
if (!historyReady || !proactive || !automatic || !selected || selected.waiting || phase !== 'ready' || manualRequest.current || pending.current
|
||||
|| lastAttempt.current === selected.key || explanations.current.has(selected.key)) return;
|
||||
const snapshot = selected;
|
||||
const timer = setTimeout(() => { void explain(snapshot); }, EXPLANATION_DELAY_MS);
|
||||
return () => clearTimeout(timer);
|
||||
}, [selected?.key, selected?.waiting, phase, automatic, proactive, retryExplanation, historyReady]);
|
||||
}, [selected?.key, selected?.waiting, selected?.evidence, phase, automatic, proactive, retryExplanation, historyReady]);
|
||||
const remember = (entry: Explanation): void => {
|
||||
const cache = explanations.current;
|
||||
cache.delete(entry.key); cache.set(entry.key, entry);
|
||||
@@ -269,7 +275,8 @@ export default function BrowserChatDock({ proactiveSelection, selectionScope = "
|
||||
const result = parseExplanationResponse(answer, packet);
|
||||
if (!followExplanation.current) setNewExplanation(true);
|
||||
if (result.status === 'generated') {
|
||||
const complete: Explanation = { key: snapshot.key, label: snapshot.label, answer: result.markdown, status: 'complete', mode: 'interpretation', packet, citation: result.citation, ...shortened ? { shortened } : {} };
|
||||
const complete: Explanation = { key: snapshot.key, label: snapshot.label, answer: result.markdown, status: 'complete', mode: 'interpretation', packet, citation: result.citation,
|
||||
...shortened ? { shortened } : {}, ...snapshot.evidence ? { evidence: snapshot.evidence } : {} };
|
||||
remember(complete); setExplanation(complete);
|
||||
} else setExplanation({ key: snapshot.key, label: snapshot.label, answer: '', status: 'error', packet, error: result.reason });
|
||||
}
|
||||
|
||||
@@ -22,6 +22,15 @@ describe('selection identity for explanations', () => {
|
||||
expect(key('django-demo:galaxy', { id: 'o', label: 'OrderableAggMixin', text: JSON.stringify(other) })).not.toBe(base);
|
||||
});
|
||||
|
||||
it('fingerprints the evidence of a complete scope only, so a re-index can retire a cached explanation', () => {
|
||||
const complete = explanationInput('p:galaxy', undefined, jsonbAggEvidence())!;
|
||||
expect(complete.evidence).toBe(jsonbAggEvidence().id);
|
||||
expect(explanationInput('p:galaxy', undefined, jsonbAggEvidence({ state: 'loading-partial-preview' }))?.evidence).toBeUndefined();
|
||||
const reindexed = explanationInput('p:galaxy', undefined, jsonbAggEvidence({ edges: jsonbAggScope().edges.slice(2) }))!;
|
||||
expect(reindexed.key).toBe(complete.key);
|
||||
expect(reindexed.evidence).not.toBe(complete.evidence);
|
||||
});
|
||||
|
||||
it('marks incomplete Galaxy scopes as waiting and leaves other graph evidence unchanged', () => {
|
||||
expect(explanationInput('p:galaxy', undefined, jsonbAggEvidence())?.waiting).toBeUndefined();
|
||||
expect(explanationInput('p:galaxy', undefined, jsonbAggEvidence({ state: 'loading-partial-preview' }))?.waiting).toBe('loading');
|
||||
|
||||
@@ -11,6 +11,8 @@ export interface ExplanationInput {
|
||||
graph?: BrowserChatContext;
|
||||
/** A Galaxy scope that is not complete yet is shown, never explained. */
|
||||
waiting?: 'loading' | 'partial';
|
||||
/** The complete scope's evidence: a cached explanation of other evidence (a re-index) is stale. */
|
||||
evidence?: string;
|
||||
}
|
||||
|
||||
/** Snapshot exact evidence; generated UI event IDs are not selection identity. */
|
||||
@@ -26,7 +28,7 @@ export function explanationInput(scope: string, reader?: BrowserChatReaderContex
|
||||
if (galaxy) {
|
||||
// The selection and how its scope is drawn; counts and loading state change while it loads.
|
||||
const key = JSON.stringify([scope, 'galaxy', galaxy.identity, galaxy.direction, galaxy.edgeTypes, galaxy.depth]);
|
||||
return { key, label: graph.label, graph: { ...graph }, ...galaxy.state === 'complete' ? {} : { waiting: galaxy.state } };
|
||||
return { key, label: graph.label, graph: { ...graph }, ...galaxy.state === 'complete' ? { evidence: graph.id } : { waiting: galaxy.state } };
|
||||
}
|
||||
return { key: JSON.stringify([scope, graph.label, graph.text]), label: graph.label, graph: { ...graph } };
|
||||
}
|
||||
Reference in new issue
Block a user