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:
Martin Vogel committed 2026-10-07 23:39:29 +02:00
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');
+13
View File
@@ -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', () => {
+1 -1
View File
@@ -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 } : {}) };
+4 -3
View File
@@ -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'],
});
+28
View File
@@ -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');
});
+7
View File
@@ -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-'));
}