Skip to content
Merged
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
36 changes: 36 additions & 0 deletions apps/desktop/e2e/streaming-remount.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ interface SessionObservationLatchWindow extends Window {
/** E2E-only preload affordance; see the MAKA_E2E block in preload.ts. */
makaE2eLatch?: {
rejectNextSessionObservation(message: string): void;
rejectNextTranscriptOpen(message: string): void;
};
}

Expand Down Expand Up @@ -73,6 +74,41 @@ test('a failed first observation seed reconnects to the live Turn', async ({ win
});
});

test('a failed transcript open recovers when its Session observation becomes ready', async ({
window: page,
}) => {
const originalPrompt = 'transcript recovery source';
const composer = page.locator(COMPOSER_INPUT);
await composer.fill(originalPrompt);
await awaitSendReady(page);
await composer.press('Enter');
await expect(page.getByRole('log')).toContainText(`Fake backend received: ${originalPrompt}`, {
timeout: 20_000,
});

const sidebar = page.getByRole('navigation', { name: '任务列表' });
await ensureSidebarExpanded(page);
const originalSessionId = await sidebar
.locator('[data-session-id]:has([aria-current="page"])')
.getAttribute('data-session-id');
expect(originalSessionId).toBeTruthy();

await sidebar.getByRole('button', { name: '新任务', exact: true }).click();

const latchInstalled = await page.evaluate(() => {
const latch = (window as SessionObservationLatchWindow).makaE2eLatch;
if (!latch) return false;
latch.rejectNextTranscriptOpen('forced first transcript failure');
return true;
});
expect(latchInstalled, 'the preload E2E latch is installed').toBe(true);

await sessionRow(sidebar, originalSessionId!).click();
await expect(page.getByRole('log')).toContainText(`Fake backend received: ${originalPrompt}`, {
timeout: 20_000,
});
});

test('remounting a live surface leaves accumulated output settled', async ({
window: page,
}) => {
Expand Down
10 changes: 5 additions & 5 deletions apps/desktop/renderer-architecture.json
Original file line number Diff line number Diff line change
Expand Up @@ -546,8 +546,8 @@
"@maka/ui": 1,
"react": 1
},
"importSpecifiers": 39,
"nonTriviaTokens": 3836
"importSpecifiers": 37,
"nonTriviaTokens": 3823
},
"src/renderer/app-shell-overlays.tsx": {
"importDeclarations": 14,
Expand Down Expand Up @@ -851,7 +851,7 @@
"useNewTaskChoice": 1,
"useOnboardingSnapshot": 1,
"usePlanModeState": 1,
"useRef": 25,
"useRef": 24,
"useSessionCollaborationDialog": 1,
"useSessionEventHealthPolling": 1,
"useSessionNavigationReads": 1,
Expand Down Expand Up @@ -979,8 +979,8 @@
"@maka/ui/icons": 1,
"react": 1
},
"importSpecifiers": 184,
"nonTriviaTokens": 15686
"importSpecifiers": 180,
"nonTriviaTokens": 15620
},
"src/renderer/use-app-shell-composer-quotes.ts": {
"importDeclarations": 3,
Expand Down
37 changes: 21 additions & 16 deletions apps/desktop/src/main/__tests__/desktop-session-projection.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,8 @@ import {
projectDesktopTurnRecord,
projectDesktopUsageStats,
} from '../../shared/desktop-session-projection.js';
import { runtimeHostChangeRetiresSession } from '../../shared/runtime-host-identity.js';
import { projectDesktopSharedSessionSummary } from '../../shared/shared-session-catalog-projection.js';
import { sessionCatalogRetiresSession } from '../../shared/runtime-host-identity.js';

test('keeps equal raw Session ids distinct across Runtime Hosts', () => {
const raw = summary('same-session');
Expand Down Expand Up @@ -60,7 +61,22 @@ test('keeps equal raw Session ids distinct across Runtime Hosts', () => {
assert.equal(remote.profileName, 'Office');
});

test('retires an active Session only after it leaves the refreshed Host catalog', () => {
test('preserves the authenticated shared Session revision', () => {
assert.equal(
projectDesktopSharedSessionSummary({
kind: 'shared_session',
id: 'shared-session',
revision: 7,
createdAt: 1,
activityAt: 2,
name: 'Shared',
status: 'active',
}).revision,
7,
);
});

test('retires an active Session only after it leaves the refreshed catalog', () => {
const owner = projectDesktopSessionSummary(
{
hostId: 'shared-root',
Expand All @@ -79,20 +95,9 @@ test('retires an active Session only after it leaves the refreshed Host catalog'
},
summary('shared-session'),
);
const removedGuest = {
epoch: 'guest-epoch',
profileId: 'guest',
profileName: 'Guest',
profileKind: 'remote',
profileAccess: 'session_guest',
readiness: 'unavailable',
hostId: 'shared-root',
isDefault: false,
removed: true,
} as const;

assert.equal(runtimeHostChangeRetiresSession(removedGuest, guest.id, [owner]), false);
assert.equal(runtimeHostChangeRetiresSession(removedGuest, guest.id, []), true);
assert.equal(sessionCatalogRetiresSession(guest.id, [owner]), false);
assert.equal(sessionCatalogRetiresSession(guest.id, []), true);
assert.equal(sessionCatalogRetiresSession(undefined, []), false);
});

test('projects typed linked Session ids without rewriting opaque tool data', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import {
DESKTOP_TRANSCRIPT_RANGE_MAX_BYTES,
} from '../../preload/transcript-contract.js';
import {
createDesktopTranscriptReconnectRecovery,
createDesktopTranscriptRangeController,
DesktopTranscriptRangeStore,
} from '../../renderer/desktop-transcript-range-store.js';
Expand Down Expand Up @@ -1324,6 +1325,44 @@ test('reopens a failed transcript range with a fresh generation', async () => {
await controller.close();
});

test('retries a failed transcript recovery after a newer observation becomes ready', async () => {
let rejectFirstReload!: (error: Error) => void;
const firstReload = new Promise<void>((_resolve, reject) => {
rejectFirstReload = reject;
});
let resolveSecondReload!: () => void;
const secondReload = new Promise<void>((resolve) => {
resolveSecondReload = resolve;
});
const reloads: Promise<void>[] = [firstReload, secondReload];
const errors: string[] = [];
const recovery = createDesktopTranscriptReconnectRecovery({
reload: () => {
const reload = reloads.shift();
if (!reload) throw new Error('unexpected transcript reload');
return reload;
},
onError(error) {
errors.push(error instanceof Error ? error.message : String(error));
},
});

recovery.transcriptFailed(new Error('initial open failed'));
recovery.observationChanged('ready');
await Promise.resolve();
recovery.observationChanged('pending');
recovery.observationChanged('ready');
rejectFirstReload(new Error('replaced transcript failed'));
await new Promise<void>((resolve) => setImmediate(resolve));

assert.equal(reloads.length, 0, 'the newer ready signal starts one trailing reload');
resolveSecondReload();
await new Promise<void>((resolve) => setImmediate(resolve));

assert.deepEqual(errors, ['initial open failed', 'replaced transcript failed']);
recovery.close();
});

test('forwards a larger logical history range without changing batch size', async () => {
const store = transcriptStore();
for (const batch of encodeDesktopTranscriptSnapshot({
Expand Down
26 changes: 26 additions & 0 deletions apps/desktop/src/main/__tests__/live-content-seed.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,37 @@ import assert from 'node:assert/strict';
import { test } from 'node:test';
import {
EMPTY_LIVE_CONTENT_SEED,
EMPTY_SESSION_OBSERVATION_AUTHORITY,
advanceSessionObservationAuthority,
beginLiveContentSeed,
completeLiveContentSeed,
liveContentSeedRevision,
} from '../../renderer/live-content-seed.js';

test('catalog hydration does not replace an already-bound Session observation', () => {
const selected = advanceSessionObservationAuthority(
EMPTY_SESSION_OBSERVATION_AUTHORITY,
'session-a',
undefined,
);
const hydrated = advanceSessionObservationAuthority(selected, 'session-a', 'profile-a');

assert.equal(hydrated.profileId, 'profile-a');
assert.equal(hydrated.revision, selected.revision);
});

test('a real Session observation authority handoff advances the revision', () => {
const selected = advanceSessionObservationAuthority(
EMPTY_SESSION_OBSERVATION_AUTHORITY,
'session-a',
'profile-a',
);
const handedOff = advanceSessionObservationAuthority(selected, 'session-a', 'profile-b');

assert.equal(handedOff.profileId, 'profile-b');
assert.equal(handedOff.revision, selected.revision + 1);
});

test('withholds live content until the current observation generation is ready', () => {
const first = beginLiveContentSeed(EMPTY_LIVE_CONTENT_SEED, 'session-a');
assert.equal(liveContentSeedRevision(first, 'session-a'), 0);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -185,11 +185,12 @@ test('owns one complete Desktop candidate generation and can restart cleanly', a
assert.equal(ipc.size, 0);
});

test('registers only shared observation IPC and consumes scoped catalog changes for a Guest', async () => {
test('routes Guest catalog changes through the mount projection authority', async () => {
const ipc = ipcHarness();
const sharedResource = sharedShellRunUpdate('session-guest');
const host = connectionHarness('guest', { runtimeResourceUpdate: sharedResource });
const changes: Array<{ reason: string; sessionId?: string }> = [];
let catalogChanges = 0;
const rendererEvents: Array<{ channel: string; payload: unknown }> = [];
const candidate = await createCandidate(
host.connection,
Expand All @@ -198,6 +199,9 @@ test('registers only shared observation IPC and consumes scoped catalog changes
emitSessionsChanged: (_scope, reason, sessionId) => {
changes.push({ reason, ...(sessionId === undefined ? {} : { sessionId }) });
},
onGuestSessionCatalogChanged: () => {
catalogChanges += 1;
},
renderer: {
send(channel, _scope, payload) {
rendererEvents.push({ channel, payload });
Expand All @@ -210,10 +214,7 @@ test('registers only shared observation IPC and consumes scoped catalog changes
'session_guest',
);

assert.deepEqual(
((await ipc.invoke('sessions:list')) as SessionCatalogProjection[]).map(({ id }) => id),
['session-guest'],
);
assert.equal(ipc.channels.includes('sessions:list'), false);
assert.equal(ipc.channels.includes('sessions:observe'), true);
assert.equal(ipc.channels.includes('sessions:transcript:open'), true);
assert.equal(ipc.channels.includes('sessions:send'), false);
Expand Down Expand Up @@ -242,7 +243,8 @@ test('registers only shared observation IPC and consumes scoped catalog changes
),
);
host.publishSessionCatalogChange('session-guest');
assert.deepEqual(changes, [{ reason: 'updated', sessionId: 'session-guest' }]);
assert.equal(catalogChanges, 1);
assert.deepEqual(changes, []);

await candidate.close();
});
Expand Down Expand Up @@ -859,7 +861,7 @@ test('drops a stale shared Session observation when Guest access is gone', async
});
const firstCandidate = await createCandidate(
firstHost.connection,
deps(firstIpc),
{ ...deps(firstIpc), onGuestSessionCatalogChanged: () => undefined },
observations,
'external',
'remote',
Expand All @@ -879,6 +881,7 @@ test('drops a stale shared Session observation when Guest access is gone', async
emitSessionsChanged: (_scope, reason, sessionId) => {
changes.push({ reason, ...(sessionId === undefined ? {} : { sessionId }) });
},
onGuestSessionCatalogChanged: () => undefined,
},
observations,
'external',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -605,8 +605,8 @@ test('keeps independent shared-session credentials active for the same Host', as
{ startCandidate: async () => ready(candidates.shift()!) },
);

await manager.mountGuest(remoteTarget('shared-one', 'shared', 'session_guest'));
await manager.mountGuest(remoteTarget('shared-two', 'shared', 'session_guest'));
await manager.mountGuest(remoteTarget('shared-one', 'shared', 'session_guest'), () => undefined);
await manager.mountGuest(remoteTarget('shared-two', 'shared', 'session_guest'), () => undefined);
await manager.enable(remoteTarget('owner', 'shared'));

assert.deepEqual(manager.entries().map(({ target }) => target.profile.id), [
Expand Down Expand Up @@ -642,6 +642,7 @@ test('aborts an in-flight Guest mount without publishing a late target', async (
const abort = new AbortController();
const mounting = manager.mountGuest(
remoteTarget('shared-cancelled', 'shared', 'session_guest'),
() => undefined,
abort.signal,
);
await started;
Expand Down Expand Up @@ -746,6 +747,7 @@ test('completes Guest import at credential activation while reconnect continues'
);
await manager.mountGuest(
peerGuestTarget('shared-session'),
() => undefined,
undefined,
(phase) => phases.push(phase),
);
Expand Down
Loading