diff --git a/public/manager/src/settings/pages/Agent.tsx b/public/manager/src/settings/pages/Agent.tsx
index 430f1b4d3..9cd953d2e 100644
--- a/public/manager/src/settings/pages/Agent.tsx
+++ b/public/manager/src/settings/pages/Agent.tsx
@@ -453,13 +453,13 @@ export default function Agent({ port, client, dirty, registerSave }: SettingsPag
});
}}
>
-
+
- 다중 세션이 켜져 있을 때 적용됩니다. steer는 jwc·codex-app 런타임에서 진행 중인 턴을 중단하지 않고 맥락을 유지한 채 주입하며, 그 외 런타임은 중단된 출력을 이어받아 즉시 리다이렉트(kill-steer)합니다. 대기열을 원하면 followup을 선택하세요.
+ 다중 세션이 켜져 있을 때 적용됩니다. jwc·codex-app은 같은 턴에 입력을 전달합니다. Cursor·Grok의 native 모드는 현재 요청의 취소와 정리가 끝난 뒤 같은 세션에 다시 요청합니다(cancel-reprompt). 그 외 런타임은 중단 후 새 실행으로 이어갑니다(kill-steer). 보존되는 맥락은 런타임에 따라 다릅니다. 끝난 뒤 실행하려면 followup을 선택하세요.
diff --git a/tests/unit/activity-view.test.ts b/tests/unit/activity-view.test.ts
index 978e14324..72d9a84be 100644
--- a/tests/unit/activity-view.test.ts
+++ b/tests/unit/activity-view.test.ts
@@ -86,7 +86,8 @@ test('tool fields and latest action render literal XSS text without HTML or mark
assert.equal(rows(view.element)[0].querySelector('pre')?.textContent, [attack, attack, attack].join('\n'));
});
-test('explicit disclosure and focused keyed tool survive replacement, growth and turn end', async () => {
+for (const ending of ['done', 'error'] as const) {
+test(`explicit disclosure and focused keyed tool survive replacement, growth and turn end (${ending}; A08)`, async () => {
const { model, choices, view, group } = mount();
tool(model, 'tool-0', 'old');
view.render(model);
@@ -98,7 +99,7 @@ test('explicit disclosure and focused keyed tool survive replacement, growth and
tool(model, 'tool-0', 'new', 'done');
for (let i = 1; i <= 80; i++) tool(model, `tool-${i}`);
view.render(model);
- send(model, { kind: 'turn-end', status: 'done', finalText: 'Answer' });
+ send(model, { kind: 'turn-end', status: ending, finalText: 'Answer', ...(ending === 'error' ? { error: 'Later failure' } : {}) });
view.render(model);
assert.equal(rows(view.element)[0], row);
assert.equal(row.querySelector('pre')?.textContent, 'new');
@@ -111,6 +112,144 @@ test('explicit disclosure and focused keyed tool survive replacement, growth and
assert.match(group.firstElementChild!.textContent!, /read tool-80/);
assert.match(group.firstElementChild!.textContent!, /81 retained previews/);
});
+}
+
+test('A01: bounded terminal error is literal text outside collapsed Activity, never a final', () => {
+ const { model, view, group, answer, host } = mount();
+ const error = '
**failed** ' + '경로'.repeat(400);
+ send(model, { kind: 'turn-end', status: 'error', error, finalText: 'FINAL_SENTINEL' });
+ view.render(model);
+ const notice = view.element.querySelector('.activity-error')!;
+ assert.equal(group.open, false);
+ assert.equal(notice.hidden, false);
+ assert.equal(notice.closest('details'), null);
+ assert.equal(notice.textContent, error.slice(0, 500));
+ assert.equal(notice.textContent.length, 500);
+ assert.equal(view.element.querySelector('img, script, strong, .activity-final'), null);
+ assert.doesNotMatch(view.element.textContent!, /FINAL_SENTINEL/);
+ assert.equal(host.querySelectorAll('.msg-content').length, 1);
+ assert.equal(host.querySelector('.msg-content'), answer);
+ assert.equal(answer.textContent, 'Canonical answer');
+});
+
+test('A02: stopped, successful and absent errors hide the notice; stopped override clears a prior error', () => {
+ for (const status of ['stopped', 'done', 'error'] as const) {
+ const { model, view } = mount();
+ send(model, { kind: 'turn-end', status, finalText: null, ...(status === 'error' ? {} : { error: 'Not a failure' }) });
+ view.render(model);
+ const notice = view.element.querySelector('.activity-error')!;
+ assert.equal(notice.hidden, true);
+ assert.equal(notice.textContent, '');
+ }
+ const { model, view } = mount();
+ send(model, { kind: 'turn-end', status: 'error', finalText: null, error: 'Failure summary' });
+ view.render(model);
+ assert.equal(view.element.querySelector('.activity-error')!.hidden, false);
+ assert.equal(view.element.querySelector('.activity-error')!.textContent, 'Failure summary');
+ view.render(model, { status: 'stopped', degraded: true });
+ assert.equal(view.element.querySelector('.activity-error')!.hidden, true);
+ assert.equal(view.element.querySelector('.activity-error')!.textContent, '');
+ assert.doesNotMatch(view.element.textContent!, /Failure summary/);
+ assert.equal(view.element.querySelector('.activity-status')!.textContent, 'Stopped');
+});
+
+test('A03: page clicks focus the enabled clicked button or the opposite button at either boundary', t => {
+ const { model, view, group } = mount();
+ for (let i = 0; i < 81; i++) tool(model, `tool-${i}`);
+ view.render(model);
+ group.open = true;
+ const previous = button(view.element, 'Earlier activity'), next = button(view.element, 'Later activity');
+ const focusPrevious = t.mock.method(previous, 'focus'), focusNext = t.mock.method(next, 'focus');
+ // No pre-focus: pointer clicks need the production focus handoff too.
+ previous.click();
+ assert.equal(previous.disabled, false);
+ assert.equal(document.activeElement, previous);
+ assert.deepEqual(focusPrevious.mock.calls.at(-1)!.arguments, [{ preventScroll: true }]);
+ previous.click();
+ assert.equal(previous.disabled, true);
+ assert.equal(document.activeElement, next);
+ assert.deepEqual(focusNext.mock.calls.at(-1)!.arguments, [{ preventScroll: true }]);
+ next.click();
+ assert.equal(next.disabled, false);
+ assert.equal(document.activeElement, next);
+ assert.deepEqual(focusNext.mock.calls.at(-1)!.arguments, [{ preventScroll: true }]);
+ next.click();
+ assert.equal(next.disabled, true);
+ assert.equal(document.activeElement, previous);
+ assert.deepEqual(focusPrevious.mock.calls.at(-1)!.arguments, [{ preventScroll: true }]);
+});
+
+test('A04: page click focuses summary when updated entries leave no enabled page control', t => {
+ const { model, view, group } = mount();
+ for (let i = 0; i < 41; i++) tool(model, `tool-${i}`);
+ view.render(model);
+ group.open = true;
+ const previous = button(view.element, 'Earlier activity'), next = button(view.element, 'Later activity');
+ const summary = group.querySelector('summary')!;
+ previous.focus();
+ model.entries.clear();
+ const focusSummary = t.mock.method(summary, 'focus');
+ previous.click();
+ assert.equal(previous.disabled, true);
+ assert.equal(next.disabled, true);
+ assert.equal(document.activeElement, summary);
+ assert.deepEqual(focusSummary.mock.calls.at(-1)!.arguments, [{ preventScroll: true }]);
+});
+
+test('A05: focused tool eviction restores summary focus and retains explicit disclosure choices', t => {
+ const { model, view, group, choices } = mount();
+ tool(model, 'tool-0'); view.render(model);
+ group.open = true;
+ const row = rows(view.element)[0], summary = group.querySelector('summary')!;
+ row.open = true;
+ row.querySelector('summary')!.focus();
+ const focusSummary = t.mock.method(summary, 'focus');
+ for (let i = 1; i <= 130; i++) tool(model, `tool-${i}`);
+ view.render(model);
+ assert.equal(model.entries.has('tool-0'), false);
+ assert.equal(row.isConnected, false);
+ assert.equal(document.activeElement, summary);
+ assert.deepEqual(focusSummary.mock.calls.at(-1)!.arguments, [{ preventScroll: true }]);
+ assert.equal(group.open, true);
+ assert.equal(choices.items.get('tool-0'), true);
+});
+
+test('A06: external page selection removes the focused last-page row and focuses summary', () => {
+ const { model, view, group, choices } = mount();
+ for (let i = 0; i < 81; i++) tool(model, `tool-${i}`);
+ view.render(model); group.open = true;
+ const row = rows(view.element)[0];
+ assert.equal(row.dataset.activityItemId, 'tool-80');
+ row.querySelector('summary')!.focus();
+ choices.page = 0; view.render(model);
+ assert.equal(row.isConnected, false);
+ assert.equal(rows(view.element)[0].dataset.activityItemId, 'tool-0');
+ assert.equal(document.activeElement, group.querySelector('summary'));
+});
+
+test('A07: streaming, eviction and error preserve external focus and unchanged status text node', t => {
+ const { model, view, group } = mount();
+ tool(model, 'tool-0', 'old'); view.render(model);
+ const external = document.createElement('button');
+ external.textContent = 'Outside Activity'; document.body.append(external);
+ const status = view.element.querySelector('.activity-status')!, statusText = status.firstChild;
+ assert.ok(statusText);
+ const focusSummary = t.mock.method(group.querySelector('summary')!, 'focus');
+ external.focus();
+ tool(model, 'tool-0', 'new'); view.render(model);
+ assert.equal(status.firstChild, statusText);
+ assert.equal(document.activeElement, external);
+ for (let i = 1; i <= 130; i++) tool(model, `tool-${i}`);
+ view.render(model);
+ assert.equal(model.entries.has('tool-0'), false);
+ assert.equal(status.firstChild, statusText);
+ assert.equal(document.activeElement, external);
+ assert.equal(focusSummary.mock.callCount(), 0);
+ send(model, { kind: 'turn-end', status: 'error', error: 'Read failed', finalText: null });
+ view.render(model);
+ assert.equal(document.activeElement, external);
+ assert.equal(focusSummary.mock.callCount(), 0);
+});
test('explicit close and open choices persist across paging and DOM recycling', async () => {
const { model, choices, host, view, group } = mount();
diff --git a/tests/unit/web-cursor-redirect.test.ts b/tests/unit/web-cursor-redirect.test.ts
new file mode 100644
index 000000000..cfc64475f
--- /dev/null
+++ b/tests/unit/web-cursor-redirect.test.ts
@@ -0,0 +1,406 @@
+import '../setup/isolated-home.ts';
+import test, { mock } from 'node:test';
+import assert from 'node:assert/strict';
+import { existsSync, rmSync } from 'node:fs';
+import { setupWebUiDom, resetWebUiDom } from './web-ui-test-dom.ts';
+import type { MainRunState } from '../../src/agent/spawn.ts';
+import type { BusEvent } from '../../src/core/event-bus.ts';
+
+// Ported by assertion from 184d9826b07e909d3b050626aa98eafcde908da5.
+// Real steer admission/commit/SQLite/bus/journal/Web reducer and final renderer;
+// only replacement dispatch and browser transport are ports. No provider or ACP
+// cancellation proof is claimed: cursor-acp-steer owns that distinct boundary.
+const home = process.env.CLI_JAW_HOME!;
+let dispatch: (data: Record) => void;
+let opened: () => void;
+mock.module('../../public/js/event-channel.js', { namedExports: {
+ connectEventChannel() {},
+ subscribe(topic: string, _event: unknown, callback: typeof dispatch) {
+ if (topic === '*') dispatch = callback;
+ return () => {};
+ },
+ onChannelOpen(callback: () => void) { opened = callback; }, onChannelDisconnect() {}, onChannelUnavailable() {},
+} });
+// Trace drawer imports bundler-only SVG assets. This suite never opens Trace.
+mock.module('../../public/js/features/trace-drawer.js', { namedExports: {
+ closeTraceDrawer() {}, openTraceDrawer() { assert.fail('Receipt unexpectedly opened Trace'); },
+} });
+let agent: typeof import('../../src/agent/spawn.ts');
+let bus: typeof import('../../src/core/event-bus.ts');
+let sessions: typeof import('../../src/core/chat-sessions.ts');
+let persistence: typeof import('../../src/agent/session-persistence.ts');
+let liveRun: typeof import('../../src/agent/live-run-state.ts');
+let trace: typeof import('../../src/trace/store.ts');
+let db: typeof import('../../src/core/db.ts')['db'];
+let config: typeof import('../../src/core/config.ts');
+let savedSettings: typeof config.settings;
+let Projection: typeof import('../../src/agent/runtime/projection.ts')['RuntimeProjection'];
+let ui: typeof import('../../public/js/ui.ts');
+let ws: typeof import('../../public/js/ws.ts');
+let activity: typeof import('../../public/js/features/activity-live.ts');
+let history: typeof import('../../public/js/features/activity-history.ts');
+let virtual: typeof import('../../public/js/virtual-scroll.ts');
+let rendering: typeof import('../../public/js/render.ts');
+let state: typeof import('../../public/js/state.ts')['state'];
+let binding: { sessionId: string; scope: string };
+let runId: string, owner: MainRunState, projection: InstanceType;
+let events: BusEvent[] = [], inputs: string[] = [];
+let holdReceipt = false, serial = 0;
+let unsubscribe = () => {};
+const unexpectedRequests: string[] = [], dispatchFailures: unknown[] = [];
+const requests: string[] = [];
+const bootstrapLoad = Promise.withResolvers();
+const bootstrapRelease = Promise.withResolvers();
+const bootstrapDone = Promise.withResolvers();
+const sidebarDone = [Promise.withResolvers(), Promise.withResolvers()];
+const frames = new Map();
+const timers = new Map, number>();
+const realSetTimeout = globalThis.setTimeout, realClearTimeout = globalThis.clearTimeout;
+let frameId = 0;
+const globalNames = ['window', 'document', 'HTMLElement', 'HTMLAnchorElement', 'Element', 'Node', 'NodeFilter',
+ 'navigator', 'localStorage', 'MutationObserver', 'getComputedStyle', 'requestAnimationFrame',
+ 'cancelAnimationFrame', 'IntersectionObserver', 'ResizeObserver', 'atob', 'btoa', 'indexedDB'];
+const globals = new Map(globalNames.map(name => [name, Object.getOwnPropertyDescriptor(globalThis, name)]));
+
+function wire(event: BusEvent): void {
+ // Serialize the producer packet as the SSE boundary does, never add finality.
+ dispatch({ ...JSON.parse(JSON.stringify(event.data)), event: event.event });
+}
+async function drain(): Promise {
+ for (let pass = 0; pass < 50; pass++) {
+ const pending = [...frames.values()]; frames.clear();
+ for (const frame of pending) frame(Date.now());
+ await new Promise(resolve => setImmediate(resolve));
+ if (!frames.size) return;
+ }
+ assert.fail('Web frame queue did not drain within 50 passes');
+}
+
+test.before(async () => {
+ config = await import('../../src/core/config.ts');
+ assert.equal(config.JAW_HOME, home);
+ savedSettings = structuredClone(config.settings);
+ config.settings.memory.enabled = false;
+ config.settings.fallbackOrder = [];
+ agent = await import('../../src/agent/spawn.ts');
+ bus = await import('../../src/core/event-bus.ts');
+ sessions = await import('../../src/core/chat-sessions.ts');
+ persistence = await import('../../src/agent/session-persistence.ts');
+ liveRun = await import('../../src/agent/live-run-state.ts');
+ trace = await import('../../src/trace/store.ts');
+ ({ db } = await import('../../src/core/db.ts'));
+ ({ RuntimeProjection: Projection } = await import('../../src/agent/runtime/projection.ts'));
+ binding = { sessionId: sessions.createChatSession('Web bootstrap').id, scope: 'cursor-web-bootstrap' };
+ setupWebUiDom();
+ // ws.applyOrcState schedules an unexported 700ms cosmetic pulse on each
+ // snapshot. Own browser timers so deleting the DOM cannot strand callbacks.
+ mock.method(globalThis, 'setTimeout', (callback: (...args: unknown[]) => void, delay?: number, ...args: unknown[]) => {
+ const timer = realSetTimeout(() => { timers.delete(timer); callback(...args); }, delay);
+ timers.set(timer, delay ?? 0); return timer;
+ });
+ mock.method(globalThis, 'clearTimeout', timer => {
+ timers.delete(timer as ReturnType); realClearTimeout(timer);
+ });
+ mock.method(globalThis, 'requestAnimationFrame', callback => { frames.set(++frameId, callback); return frameId; });
+ mock.method(globalThis, 'cancelAnimationFrame', id => { frames.delete(id); });
+ const warn = console.warn.bind(console);
+ mock.method(console, 'warn', (...args: unknown[]) => {
+ // Deliberate DOM storage limitation; actual cache writes have their own
+ // web-activity-answer-cache suite. All other warnings remain visible.
+ if (String(args[0]).startsWith('[idb-cache]')) return;
+ warn(...args);
+ });
+ mock.method(globalThis, 'fetch', async (input: string | URL | Request, init?: RequestInit) => {
+ const url = new URL(input instanceof Request ? input.url : String(input), 'http://127.0.0.1');
+ const key = `${init?.method ?? (input instanceof Request ? input.method : 'GET')} ${url.pathname}${url.search}`;
+ requests.push(key);
+ try {
+ assert.equal(url.origin, 'http://127.0.0.1');
+ assert.ok(key.startsWith('GET '));
+ if (url.pathname === '/api/auth/token' && !url.search) return Response.json({ token: 'fixture' });
+ if (url.pathname === '/api/goal' && !url.search) return Response.json({ ok: true, goal: null,
+ pauseGate: { armed: false, attempts: 0, requiredAttempts: 2, reason: null, nextAction: null } });
+ if (url.pathname === '/api/settings' && !url.search) return Response.json({ workingDir: home });
+ if (url.pathname === '/api/messages') {
+ assert.deepEqual([...url.searchParams.entries()], [['limit', '3000'], ['withSession', '1']]);
+ return Response.json({ ok: true, data: { sessionId: binding.sessionId, messages: [] } });
+ }
+ if (url.pathname === '/api/bgtask') {
+ assert.deepEqual([...url.searchParams.entries()], [['status', 'running']]);
+ return Response.json({ tasks: [] });
+ }
+ // Explicitly unavailable peripheral sidebars, not fabricated success.
+ if (['/api/memory-files', '/api/memory/status'].includes(url.pathname) && !url.search) {
+ return Response.json({ error: 'fixture_sidebar_unavailable' }, { status: 503 });
+ }
+ if (url.pathname === '/api/orchestrate/snapshot') {
+ assert.ok([...url.searchParams.keys()].every(name => name === 'session'));
+ assert.ok(!url.search || url.searchParams.get('session') === binding.sessionId);
+ // Current snapshot is raw top-level, NOT the donor ok/data wrapper.
+ return Response.json({ activityIdentity: binding,
+ orc: { state: 'IDLE', scope: binding.scope, ctx: null },
+ heartbeat: { pending: 0, deferredPending: 0 }, workers: [], queued: [],
+ runtime: { queuePending: 0, busy: agent.isAgentBusy(binding.scope) },
+ activeRun: liveRun.getLiveRun(binding.scope) });
+ }
+ if (url.pathname === '/api/messages/count') {
+ assert.ok([...url.searchParams.keys()].every(name => name === 'session'));
+ assert.ok(!url.search || url.searchParams.get('session') === binding.sessionId);
+ const row = db.prepare('SELECT COUNT(*) AS count FROM messages WHERE session_id=?').get(binding.sessionId);
+ return Response.json({ ok: true, data: row });
+ }
+ if (url.pathname === '/api/runtime/requests') {
+ assert.deepEqual([...url.searchParams.entries()], [['sessionId', binding.sessionId]]);
+ return Response.json({ ok: true, data: { requests: [] } });
+ }
+ if (url.pathname === '/api/traces/activity-runs') {
+ assert.deepEqual([...url.searchParams.entries()], [['session', binding.sessionId], ['after', '']]);
+ return Response.json({ ok: true, data: { runs: [], pageSize: 40 } });
+ }
+ throw new Error('Unexpected HTTP route');
+ } catch (error) {
+ unexpectedRequests.push(`${key}: ${String(error)}`);
+ throw error; // API may swallow this; the independent ledger must still fail.
+ }
+ });
+ ui = await import('../../public/js/ui.ts');
+ mock.module('../../public/js/ui.js', { namedExports: { ...ui,
+ async loadMessages() { bootstrapLoad.resolve(); await bootstrapRelease.promise; return ui.loadMessages(); },
+ reconcileChatBottomAfterRestore: (...args: Parameters) => {
+ ui.reconcileChatBottomAfterRestore(...args);
+ if (args[0] === 'reconnect') bootstrapDone.resolve();
+ },
+ } });
+ const memory = await import('../../public/js/features/memory.ts');
+ const background = await import('../../public/js/features/bgtask-badge.ts');
+ mock.module('../../public/js/features/memory.js', { namedExports: { ...memory,
+ async refreshMemorySidebar() { await memory.refreshMemorySidebar(); sidebarDone[0]!.resolve(); },
+ } });
+ mock.module('../../public/js/features/bgtask-badge.js', { namedExports: { ...background,
+ async refreshBgtaskBadge() { await background.refreshBgtaskBadge(); sidebarDone[1]!.resolve(); },
+ } });
+ ws = await import('../../public/js/ws.ts');
+ activity = await import('../../public/js/features/activity-live.ts');
+ history = await import('../../public/js/features/activity-history.ts');
+ virtual = await import('../../public/js/virtual-scroll.ts');
+ rendering = await import('../../public/js/render.ts');
+ ({ state } = await import('../../public/js/state.ts'));
+ ws.connect(); assert.equal(typeof dispatch, 'function'); opened();
+ // Hold the real loader, then observe reconnect's post-snapshot callback and
+ // the real sidebar promises. Event-loop turns are not import completion.
+ await bootstrapLoad.promise;
+ assert.equal(state.activityIdentity, null);
+ assert.equal(requests.some(key => key.includes('/api/orchestrate/snapshot')), false);
+ bootstrapRelease.resolve();
+ await bootstrapDone.promise;
+ await Promise.all(sidebarDone.map(done => done.promise));
+ assert.ok(requests.includes('GET /api/bgtask?status=running'), 'reconnect bootstrap completed');
+ await drain();
+ assert.deepEqual(state.activityIdentity, binding);
+});
+
+test.beforeEach(async () => {
+ rendering.cancelPostRender(); virtual.getVirtualScroll().clear();
+ ui.clearSteer(); ui.cleanupToolActivity(); activity.clearLiveActivity();
+ document.getElementById('chatMessages')!.replaceChildren();
+ binding = { sessionId: sessions.createChatSession('Cursor Web receipt fixture').id, scope: `cursor-web-${++serial}` };
+ inputs = []; events = []; holdReceipt = false;
+ // Admit this chat while idle, then start a fresh live run. A running snapshot
+ // instead requests history recovery, a separately owned N/P test surface.
+ await ws.syncOrchestrateSnapshot('cursor-redirect-test', { hydrateRun: true });
+ assert.deepEqual(state.activityIdentity, binding);
+ assert.equal(state.agentBusy, false);
+ runId = trace.startTraceRun({ cli: 'cursor', sessionId: binding.sessionId, scopeKey: binding.scope });
+ owner = { process: null, starting: false, steering: false,
+ ownerGeneration: persistence.getSessionOwnershipGeneration(binding.scope).global,
+ meta: { origin: 'web', cli: 'cursor', chatSessionId: binding.sessionId },
+ replaceTurn: async (text, commitInput) => { inputs.push(text); commitInput(); return { kind: 'dispatched' }; },
+ };
+ agent.activeMainProcesses.set(binding.scope, owner);
+ liveRun.beginLiveRun(binding.scope, 'cursor'); liveRun.setLiveRunTraceId(binding.scope, runId);
+ unsubscribe = bus.subscribe(event => {
+ if (events.length >= 128) { dispatchFailures.push('event bound exceeded'); return; }
+ events.push(event);
+ try { if (!(holdReceipt && event.event === 'steer_started')) wire(event); }
+ catch (error) { dispatchFailures.push(error); } // bus itself catches listener errors
+ });
+ projection = new Projection({ ...binding, runId, turnId: runId, audience: 'public' });
+ bus.publish('agent', 'agent_status', { running: true, ...binding });
+ assert.equal(state.agentBusy, true); assert.equal(agent.isAgentBusy(binding.scope), true);
+ await drain();
+});
+
+test.afterEach(async () => {
+ unsubscribe();
+ agent.activeMainProcesses.delete(binding.scope); liveRun.clearLiveRun(binding.scope);
+ try {
+ trace.finalizeTraceRun(runId, 'interrupted', null, { onlyIfRunning: true });
+ await drain();
+ assert.deepEqual(unexpectedRequests, []);
+ assert.deepEqual(dispatchFailures, []);
+ } finally {
+ history.disposeActivityHistory(); activity.clearLiveActivity();
+ rendering.cancelPostRender(); ui.cleanupToolActivity(); ui.clearSteer();
+ virtual.getVirtualScroll().clear();
+ assert.equal(frames.size, 0);
+ for (const timer of timers.keys()) realClearTimeout(timer);
+ timers.clear();
+ assert.equal(agent.activeMainProcesses.has(binding.scope), false);
+ assert.equal(liveRun.getLiveRun(binding.scope).running, false);
+ }
+});
+test.after(async () => {
+ try {
+ await drain();
+ assert.deepEqual(unexpectedRequests, []); assert.deepEqual(dispatchFailures, []);
+ assert.ok(requests.some(path => path.includes('/api/orchestrate/snapshot')));
+ console.log('C cleanup: no unknown HTTP, dispatch failures, frames or owned backend runs');
+ } finally {
+ history?.disposeActivityHistory(); activity?.clearLiveActivity();
+ rendering?.cancelPostRender(); ui?.cleanupToolActivity(); virtual?.getVirtualScroll().clear();
+ for (const timer of timers.keys()) realClearTimeout(timer);
+ timers.clear();
+ resetWebUiDom(); mock.restoreAll();
+ for (const [name, descriptor] of globals) {
+ if (descriptor) Object.defineProperty(globalThis, name, descriptor);
+ else Reflect.deleteProperty(globalThis, name);
+ }
+ if (config && savedSettings) {
+ for (const key of Object.keys(config.settings)) Reflect.deleteProperty(config.settings, key);
+ Object.assign(config.settings, savedSettings);
+ }
+ db?.close(); rmSync(home, { recursive: true, force: true });
+ assert.equal(existsSync(home), false);
+ console.log('C cleanup: DB closed; isolated file home removed; DOM/globals/settings/mocks restored');
+ }
+});
+
+function startActivity(): void {
+ projection.start('cursor'); projection.text('message', 'before', 'Before redirect', 'replace', 'commentary');
+ assert.ok(activity.findLiveActivity(runId)); assert.ok(state.currentAgentDiv?.isConnected);
+}
+async function redirect() {
+ const prompt = 'Continue with the redirected task', requestId = `redirect-request-${serial}`;
+ assert.equal(await agent.steerAgent(binding.scope, prompt, 'web', { chatSessionId: binding.sessionId, requestId }), 'steered');
+ const receipts = events.filter(event => event.event === 'steer_started');
+ assert.equal(receipts.length, 1);
+ const receipt = receipts[0]!;
+ assert.deepEqual(receipt.data, { prompt, origin: 'web', ...binding, requestId, mode: 'cancel-reprompt', localDispatch: true });
+ for (const field of ['traceRunId', 'runId', 'runtimeFinality', 'runtimeStatus', 'runtimeOutcome', 'partialText']) {
+ assert.equal(Object.hasOwn(receipt.data, field), false);
+ }
+ assert.deepEqual(inputs, [prompt]);
+ assert.deepEqual(db.prepare("SELECT content FROM messages WHERE session_id=? AND role='user'").all(binding.sessionId), [{ content: prompt }]);
+ assert.equal(events.filter(event => event.event === 'new_message' && event.data['content'] === prompt).length, 1);
+ assert.equal(agent.activeMainProcesses.get(binding.scope), owner);
+ assert.equal(agent.isAgentBusy(binding.scope), true);
+ assert.equal(liveRun.getLiveRun(binding.scope).traceRunId, runId);
+ assert.equal(liveRun.getLiveRun(binding.scope).running, true);
+ assert.equal(events.some(event => event.event === 'agent_done' || event.data['kind'] === 'turn-end'), false);
+ return receipt;
+}
+function capture() {
+ const host = state.currentAgentDiv!;
+ assert.ok(host?.isConnected);
+ return { host, html: host.outerHTML, busy: state.agentBusy, steer: ui.isRecentSteer(),
+ model: structuredClone(activity.findLiveActivity(runId)!.model), backend: liveRun.getLiveRun(binding.scope) };
+}
+function unchanged(before: ReturnType): void {
+ assert.equal(state.currentAgentDiv, before.host, 'receipt is not a terminal');
+ assert.equal(state.agentBusy, before.busy); assert.equal(ui.isRecentSteer(), before.steer);
+ assert.equal(before.host.outerHTML, before.html);
+ assert.deepEqual(activity.findLiveActivity(runId)!.model, before.model);
+ assert.deepEqual(liveRun.getLiveRun(binding.scope), before.backend);
+ assert.equal(agent.activeMainProcesses.get(binding.scope), owner);
+ assert.equal(agent.isAgentBusy(binding.scope), true);
+}
+function finish(expectedRows = 1, answer = 'Exact redirected final'): void {
+ const host = state.currentAgentDiv!;
+ projection.text('message', 'after', 'After redirect', 'replace', 'commentary');
+ assert.ok([...activity.findLiveActivity(runId)!.model.entries.values()]
+ .some(entry => entry.kind === 'message' && entry.text === 'After redirect'));
+ projection.close({ kind: 'turn-end', status: 'done', finalText: answer });
+ const terminals = events.filter(event => event.event === 'agent_runtime' && event.data['runId'] === runId && event.data['kind'] === 'turn-end');
+ assert.equal(terminals.length, 1); wire(terminals[0]!);
+ assert.equal(document.querySelectorAll('.msg-agent').length, expectedRows);
+ assert.equal(host.querySelector('.msg-content')?.getAttribute('data-raw'), answer);
+ assert.equal(activity.findLiveActivity(runId)!.message, host);
+ assert.equal(state.currentAgentDiv, null); assert.equal(state.agentBusy, false);
+}
+
+test('C01/C02: real steerAgent receipt preserves the Web owner through continued Activity and one final', async () => {
+ startActivity(); const before = capture();
+ await redirect(); unchanged(before); finish();
+});
+for (const [row, name, patch] of [
+ ['C03', 'foreign', { sessionId: 'foreign', scope: 'foreign' }],
+ ['C04', 'replayed', { sseReplay: true }],
+ ['C05', 'noncanonical metadata', { sessionId: 7, scope: null, requestId: {}, localDispatch: false }],
+] as const) test(`${row}: cancel-reprompt ${name} receipt cannot mutate ownership or liveness`, async () => {
+ startActivity(); holdReceipt = true; const receipt = await redirect();
+ const before = capture(); dispatch({ ...receipt.data, ...patch, event: receipt.event });
+ unchanged(before); finish();
+});
+test('C06: cancel-reprompt receipt preserves a pre-existing steer guard and the eventual final', async () => {
+ startActivity(); holdReceipt = true; const receipt = await redirect(); ui.markSteered();
+ const before = capture(); assert.equal(before.steer, true);
+ wire(receipt); unchanged(before); finish();
+});
+for (const [row, ending] of [['C07', 'done'], ['C08', 'stopped']] as const) {
+ test(`${row}: late A receipt cannot revive ${ending} A or retire same-session B`, async () => {
+ startActivity(); holdReceipt = true;
+ const receipt = await redirect(), aRunId = runId, aHost = state.currentAgentDiv!;
+ const answerA = ending === 'done' ? 'Answer A' : 'Partial A';
+ projection.close({ kind: 'turn-end', status: ending, finalText: answerA });
+ trace.finalizeTraceRun(aRunId, ending === 'stopped' ? 'interrupted' : 'done');
+ agent.activeMainProcesses.delete(binding.scope); liveRun.clearLiveRun(binding.scope);
+ const aHtml = aHost.outerHTML, aModel = structuredClone(activity.findLiveActivity(aRunId)!.model);
+ const stoppedGuard = ui.isRecentSteer();
+ assert.equal(aHost.querySelector('.msg-content')?.getAttribute('data-raw'), answerA);
+ assert.equal(state.currentAgentDiv, null); assert.equal(state.agentBusy, false);
+ wire(receipt);
+ assert.equal(state.currentAgentDiv, null); assert.equal(state.agentBusy, false);
+ assert.equal(ui.isRecentSteer(), stoppedGuard); assert.equal(aHost.outerHTML, aHtml);
+ assert.deepEqual(activity.findLiveActivity(aRunId)!.model, aModel);
+ assert.equal(document.querySelectorAll('.msg-agent').length, 1);
+
+ // Same exact binding: metadata alone cannot identify this stale receipt.
+ runId = trace.startTraceRun({ cli: 'cursor', sessionId: binding.sessionId, scopeKey: binding.scope });
+ assert.notEqual(runId, aRunId);
+ owner = { ...owner, meta: { ...owner.meta } };
+ agent.activeMainProcesses.set(binding.scope, owner);
+ liveRun.beginLiveRun(binding.scope, 'cursor'); liveRun.setLiveRunTraceId(binding.scope, runId);
+ bus.publish('agent', 'agent_status', { running: true, ...binding });
+ projection = new Projection({ ...binding, runId, turnId: runId, audience: 'public' });
+ startActivity();
+ const before = capture(); assert.notEqual(before.host, aHost); assert.equal(before.busy, true);
+ wire(receipt); unchanged(before);
+ assert.equal(aHost.outerHTML, aHtml); assert.deepEqual(activity.findLiveActivity(aRunId)!.model, aModel);
+ finish(2, 'Exact B final');
+ assert.equal(aHost.outerHTML, aHtml);
+ assert.deepEqual(activity.findLiveActivity(aRunId)!.model, aModel);
+ assert.equal(events.filter(event => event.event === 'agent_runtime' && event.data['kind'] === 'turn-end').length, 2);
+ });
+}
+test('C09 legacy: missing mode retires a no-Activity stream and rejects old packets beyond the time guard', t => {
+ assert.equal(activity.findLiveActivity(runId), undefined);
+ dispatch({ event: 'agent_output', traceRunId: runId, text: 'Legacy provisional', textLen: 18 });
+ const host = state.currentAgentDiv!; assert.ok(host?.isConnected);
+ dispatch({ event: 'steer_started' });
+ assert.equal(ui.isRecentSteer(), true); assert.equal(state.currentAgentDiv, null);
+ assert.equal(state.agentBusy, true, 'legacy replacement is still steering');
+ const count = document.querySelectorAll('.msg-agent').length, html = host.outerHTML;
+ const later = Date.now() + 10_000; t.mock.method(Date, 'now', () => later);
+ assert.equal(ui.isRecentSteer(), false);
+ for (const sseReplay of [false, true]) {
+ dispatch({ event: 'agent_output', traceRunId: runId, text: 'OLD REPLAY', textLen: 100, sseReplay });
+ dispatch({ event: 'agent_done', traceRunId: runId, text: 'OLD FINAL', sseReplay });
+ assert.equal(document.querySelectorAll('.msg-agent').length, count);
+ assert.equal(host.outerHTML, html); assert.equal(state.currentAgentDiv, null);
+ assert.equal(state.agentBusy, true);
+ }
+});
+test('C09 Activity: missing-mode receipt preserves an admitted Activity owner and its later final', () => {
+ startActivity(); const before = capture();
+ dispatch({ event: 'steer_started' }); unchanged(before); finish();
+});