mirror of
https://github.com/DeusData/codebase-memory-mcp.git
synced 2026-11-04 10:48:20 +00:00
Fix integration regressions in agent handover and graph scope
Preserve unfinished model operations across repeated project switches, retain truncated graph metadata for file and folder scopes, and give each browser hand test a fresh profile directory without deleting existing data. Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
This commit is contained in:
1 parent
94645e6272
commit
7cfc1b8771
7 files changed
+93
-4
No files matched your search
@@ -114,6 +114,43 @@ describe('the loaded model across an in-page project switch (K24)', () => {
|
||||
expect(container.textContent).not.toContain('Old project question');
|
||||
});
|
||||
|
||||
it.each(['token counting', 'answer generation'] as const)('keeps the old %s barrier across successive project switches', async stage => {
|
||||
const { props, runtime } = fixture();
|
||||
const counting = deferred<number>(), answer = deferred<string>();
|
||||
await load(props, 'django-demo');
|
||||
if (stage === 'token counting') runtime.countTokens.mockReturnValueOnce(counting.promise);
|
||||
else runtime.chat.mockReturnValueOnce(answer.promise);
|
||||
await type('Old project question');
|
||||
await act(async () => button('Send ↑').click());
|
||||
expect(runtime.countTokens).toHaveBeenCalledOnce();
|
||||
expect(runtime.chat).toHaveBeenCalledTimes(stage === 'token counting' ? 0 : 1);
|
||||
|
||||
try {
|
||||
await switchTo(props, 'cbm');
|
||||
await switchTo(props, 'third-project');
|
||||
await type('Final project question');
|
||||
// The first operation stays blocked by our promise, independent of timing.
|
||||
await act(async () => { container.querySelector('textarea')!.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })); });
|
||||
expect(runtime.countTokens).toHaveBeenCalledOnce();
|
||||
expect(runtime.chat).toHaveBeenCalledTimes(stage === 'token counting' ? 0 : 1);
|
||||
expect(button('Stopping…').disabled).toBe(true);
|
||||
expect(runtime.dispose).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
await act(async () => { counting.resolve(100); answer.resolve('OLD_PROJECT_ANSWER'); });
|
||||
}
|
||||
|
||||
await act(async () => button('Send ↑').click());
|
||||
expect(runtime.countTokens).toHaveBeenCalledTimes(2);
|
||||
expect(runtime.chat).toHaveBeenCalledTimes(stage === 'token counting' ? 1 : 2);
|
||||
const sent = runtime.chat.mock.calls.at(-1)![0].map(message => message.content).join('\n');
|
||||
expect(sent).toContain('Final project question');
|
||||
expect(sent).toContain('SOURCE_OF_third-project');
|
||||
expect(sent).not.toContain('SOURCE_OF_django-demo');
|
||||
expect(container.textContent).not.toContain('OLD_PROJECT_ANSWER');
|
||||
expect(props.createRuntime).toHaveBeenCalledOnce();
|
||||
expect(runtime.prepare).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('keeps a model that is still loading and finishes loading it in the new project', async () => {
|
||||
const { props, runtime, states } = fixture(); const loading = deferred<void>();
|
||||
runtime.prepare.mockReturnValueOnce(loading.promise);
|
||||
|
||||
@@ -539,6 +539,8 @@ export default function BrowserChatDock({ proactiveSelection, selectionScope = "
|
||||
const adopted = handover.runtime;
|
||||
runtime.current = adopted; runtimeModel.current = handover.modelId;
|
||||
adopted.setFatalHandler?.(failure => { if (runtime.current === adopted) invalidateRuntime(failure); });
|
||||
// A further project switch must carry the same unfinished operation with it.
|
||||
operationSettled.current = handover.settled;
|
||||
const waiting = handover.ready ?? handover.settled;
|
||||
if (!waiting) { setPhase('ready'); return; }
|
||||
const ticket = epoch.current;
|
||||
@@ -550,6 +552,7 @@ export default function BrowserChatDock({ proactiveSelection, selectionScope = "
|
||||
} else { stopRequested.current = true; setStopping(true); setPhase('counting'); }
|
||||
void waiting.then(() => {
|
||||
if (epoch.current !== ticket || runtime.current !== adopted) return;
|
||||
if (operationSettled.current === handover.settled) operationSettled.current = undefined;
|
||||
pending.current = false; stopRequested.current = false; loading.current = undefined; setStopping(false);
|
||||
if (handover.ready) { setCached(previous => new Set(previous).add(handover.modelId)); setProgress(undefined); }
|
||||
setPhase('ready');
|
||||
|
||||
@@ -79,6 +79,19 @@ describe('complete scoped graph acquisition', () => {
|
||||
expect(result.data.nodes.map(node => node.id)).toEqual([1, 2]);
|
||||
expect(result.data.edges.map(edge => edge.id)).toEqual([1]);
|
||||
});
|
||||
it.each(['file', 'folder'] as const)('preserves edge-limit partial metadata when an unexpanded %s stops between pages', async kind => {
|
||||
const a = node(1), b = node(2), c = node(3);
|
||||
const queryGraph = vi.fn<GraphQueryClient['queryGraph']>().mockResolvedValueOnce(page([row(a), row(b), row(c)]))
|
||||
.mockResolvedValueOnce(page([edgeRow(1, a, b), edgeRow(2, b, c)], { total: 3, offset: 0, nextOffset: 2, nextCursor: 'third', hasMore: true }))
|
||||
.mockResolvedValueOnce(page([edgeRow(3, c, a)], { total: 3, offset: 2 }));
|
||||
const result = await loadGraphScope('p', { kind, path: kind === 'file' ? 'src/a.ts' : 'src', name: kind }, 0, 'both', undefined,
|
||||
{ client: { queryGraph }, limits: { nodes: 100, edges: 1 } });
|
||||
expect(queryGraph).toHaveBeenCalledTimes(2);
|
||||
expect(result.depth).toBe(0);
|
||||
expect(result.data.nodes.map(node => node.id)).toEqual([1, 2, 3]);
|
||||
expect(result.data.edges.map(edge => edge.id)).toEqual([1, 2]);
|
||||
expect(result.partial).toEqual({ layer: 1, nodes: 3, edges: 2, limit: 'edges' });
|
||||
});
|
||||
});
|
||||
|
||||
it('render limits retain selected roots and never retain dangling edges', () => {
|
||||
|
||||
@@ -241,7 +241,7 @@ export async function loadGraphScope(project: string, scope: GraphScope, depth:
|
||||
// A cluster starts with all of its members and their internal edges.
|
||||
if (depth === 0 && roots.size > 1) {
|
||||
const initial = [...edges.values()].filter(edge => roots.has(edge.source) && roots.has(edge.target));
|
||||
return { data: arrangeScopedGraph([...nodes.values()].filter(node => roots.has(node.id)), initial, roots, levels), roots, depth: 0, exhausted: edgeTypes?.length === 0, frontier: edgeTypes?.length === 0 ? [] : [...roots], levels, traversalKey };
|
||||
return { data: arrangeScopedGraph([...nodes.values()].filter(node => roots.has(node.id)), initial, roots, levels), roots, depth: 0, exhausted: edgeTypes?.length === 0, frontier: edgeTypes?.length === 0 ? [] : [...roots], levels, traversalKey, ...(partial ? { partial } : {}) };
|
||||
}
|
||||
return { data: arrangeScopedGraph([...nodes.values()], [...edges.values()], roots, levels), roots, depth: reachedDepth,
|
||||
exhausted: !partial && (edgeTypes?.length === 0 || (depth > 0 && frontier.length === 0)), frontier, levels, traversalKey, ...(partial ? { partial } : {}) };
|
||||
|
||||
@@ -32,8 +32,9 @@
|
||||
*/
|
||||
|
||||
import { chromium } from 'playwright';
|
||||
import { mkdir, rm, writeFile } from 'node:fs/promises';
|
||||
import { mkdir, writeFile } from 'node:fs/promises';
|
||||
import { join, resolve } from 'node:path';
|
||||
import { prepareHandtestProfile } from './lib/handtest-profile.mjs';
|
||||
|
||||
const argv = process.argv.slice(2);
|
||||
const arg = (name, fallback) => {
|
||||
@@ -142,9 +143,9 @@ const historyButton = (page, label) => page.locator(`.atlas-architecture [aria-l
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Ablauf */
|
||||
|
||||
await rm(PROFILE, { recursive: true, force: true });
|
||||
const runProfile = await prepareHandtestProfile(PROFILE);
|
||||
await mkdir(OUT, { recursive: true });
|
||||
const context = await chromium.launchPersistentContext(PROFILE, {
|
||||
const context = await chromium.launchPersistentContext(runProfile, {
|
||||
headless: true, viewport: VIEWPORT, deviceScaleFactor: 2,
|
||||
args: ['--enable-unsafe-webgpu', '--ignore-gpu-blocklist'],
|
||||
});
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { afterEach, expect, it } from 'vitest';
|
||||
import { mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { prepareHandtestProfile } from './lib/handtest-profile.mjs';
|
||||
|
||||
const fixtures = [];
|
||||
afterEach(async () => {
|
||||
await Promise.all(fixtures.splice(0).map((path) => rm(path, { recursive: true, force: true })));
|
||||
});
|
||||
|
||||
it('preserves an existing profile and creates separate fresh run directories', async () => {
|
||||
const parent = await mkdtemp(join(tmpdir(), 'cbm-profile-test-'));
|
||||
fixtures.push(parent);
|
||||
const marker = join(parent, 'existing-profile-data');
|
||||
await writeFile(marker, 'keep this profile');
|
||||
|
||||
const first = await prepareHandtestProfile(parent);
|
||||
expect(await readFile(marker, 'utf8').catch(() => undefined)).toBe('keep this profile');
|
||||
expect(dirname(first)).toBe(parent);
|
||||
expect((await stat(first)).isDirectory()).toBe(true);
|
||||
await writeFile(join(first, 'previous-run'), 'keep this run too');
|
||||
|
||||
const second = await prepareHandtestProfile(parent);
|
||||
expect(second).not.toBe(first);
|
||||
expect(dirname(second)).toBe(parent);
|
||||
expect(await readFile(join(first, 'previous-run'), 'utf8')).toBe('keep this run too');
|
||||
});
|
||||
@@ -0,0 +1,7 @@
|
||||
import { mkdir, mkdtemp } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
|
||||
export async function prepareHandtestProfile(parent) {
|
||||
await mkdir(parent, { recursive: true });
|
||||
return mkdtemp(join(parent, 'run-'));
|
||||
}
|
||||
Reference in new issue
Block a user