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
7 changes: 3 additions & 4 deletions apps/desktop/renderer-architecture.json
Original file line number Diff line number Diff line change
Expand Up @@ -332,7 +332,6 @@
"./app-shell-copy.js": 1,
"./attachment-preflight.js": 1,
"./composer-attachments.js": 1,
"./features/conversation/index.js": 1,
"./locales/shell-copy.js": 1,
"./model-connection-errors.js": 1,
"./session-workspace-errors.js": 1,
Expand All @@ -341,7 +340,7 @@
"@maka/ui": 1
},
"importSpecifiers": 13,
"nonTriviaTokens": 4076
"nonTriviaTokens": 4057
},
"src/renderer/app-shell-chrome-actions.tsx": {
"importDeclarations": 4,
Expand Down Expand Up @@ -768,7 +767,7 @@
"useAppShellTurnPresentation": 1,
"useCommandPalette": 1,
"useComposerAttachments": 1,
"useEffect": 10,
"useEffect": 8,
"useKeyboardHelp": 1,
"useLayoutEffect": 2,
"useNewTaskChoice": 1,
Expand Down Expand Up @@ -892,7 +891,7 @@
"react": 1
},
"importSpecifiers": 116,
"nonTriviaTokens": 14544
"nonTriviaTokens": 14342
},
"src/renderer/use-app-shell-composer-quotes.ts": {
"importDeclarations": 2,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ export function createTransientState() {
export function createActionsDeps() {
const activeIdRef = { current: undefined as string | undefined };
return {
onFollowLatest: async (_sessionId: string) => true,
uiLocale: 'en' as const,
activeIdRef,
captureComposerImportOwner: () => ({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ import { describe, it } from 'node:test';
import type { LiveTurnProjection } from '@maka/ui';
import type { DesktopTranscriptRangeController } from '../../renderer/desktop-transcript-range-store.js';
import { createAppShellChatActions } from '../../renderer/app-shell-chat-actions.js';
import { prepareTranscriptForSend } from '../../renderer/features/conversation/testing.js';

import {
createActionsDeps,
Expand Down Expand Up @@ -498,13 +499,14 @@ describe('composer first-send cleanup', () => {
assert.equal(resolved, 0);
});

it('accepts a message while a sparse existing Session catches up in the background', async () => {
it('cancels restoration and accepts a message while latest history catches up in the background', async () => {
const latest = deferred<void>();
const order: string[] = [];
const activeIdRef = { current: 'existing-session' as string | undefined };
const transcript = {
store: {
range: () => ({ sessionId: 'existing-session', hasNewer: true }),
sessionId: 'existing-session',
range: () => ({ sessionId: 'existing-session', hasNewer: false }),
snapshot: () => ({ messages: [] }),
},
async loadLatest() {
Expand All @@ -527,16 +529,65 @@ describe('composer first-send cleanup', () => {
...createActionsDeps(),
activeIdRef,
transcriptRangeRef,
onFollowLatest: (sessionId) => prepareTranscriptForSend({
sessionId, currentSessionId: activeIdRef, controller: transcriptRangeRef,
cancel: () => { order.push('cancel-restore'); }, followLatest: () => {},
}),
}).send('hello');
await new Promise((resolve) => setImmediate(resolve));
assert.deepEqual(order, ['latest', 'send']);
assert.deepEqual(order, ['cancel-restore', 'latest', 'send']);
assert.equal(await sending, true);
latest.resolve();
assert.deepEqual(order, ['latest', 'send']);
assert.deepEqual(order, ['cancel-restore', 'latest', 'send']);
} finally {
restoreWindow();
}
});

for (const initialized of [false, true]) {
it(`does not navigate the previous Session controller (${initialized ? 'initialized' : 'opening'}) while sending`, async () => {
const submissions: string[] = [];
let latestReads = 0;
const transcript = {
store: {
sessionId: 'previous-session',
range: () => {
if (!initialized) throw new Error('Desktop transcript range is not initialized');
return { sessionId: 'previous-session' };
},
},
loadLatest: async () => { latestReads += 1; },
} as unknown as DesktopTranscriptRangeController;
const restoreWindow = installWindow({
sessions: {
submitMessage: async (sessionId: string) => {
submissions.push(sessionId);
return { ok: true, attachments: [], skillInvocation: { loaded: [], failed: [] } };
},
},
});
const activeIdRef = { current: 'selected-session' };
const transcriptRangeRef = { current: transcript };
try {
const result = await createAppShellChatActions({
...createActionsDeps(),
activeIdRef,
transcriptRangeRef,
onFollowLatest: (sessionId) => prepareTranscriptForSend({
sessionId, currentSessionId: activeIdRef, controller: transcriptRangeRef,
cancel: () => {},
followLatest: (sessionId) => { assert.equal(sessionId, 'selected-session'); },
}),
setMessages: () => { assert.fail('the previous range must not replace selected messages'); },
}).send('hello');
assert.equal(result, true);
assert.deepEqual(submissions, ['selected-session']);
assert.equal(latestReads, 0, 'the previous Session must not be navigated');
} finally {
restoreWindow();
}
});
}
});
/**
* #1433 round 5: the failure feedback for a send is addressed to the surface
Expand Down
68 changes: 37 additions & 31 deletions apps/desktop/src/main/__tests__/app-shell-session-ui-state.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,10 +32,13 @@ import {
type AppShellSessionUiState,
} from '../../renderer/app-shell-session-ui-state.js';
import {
transcriptReadingPosition,
createTranscriptRestoreLifecycle,
loadTranscriptHistory,
refreshTranscriptTurnLandmarks,
restoreSessionTranscriptRange,
type TranscriptHistoryGates,
type TranscriptHistoryPending,
} from '../../renderer/features/conversation/index.js';
} from '../../renderer/features/conversation/testing.js';

function boundaryRequest(requestId: string): SandboxBoundaryRequestEvent {
return {
Expand Down Expand Up @@ -103,7 +106,7 @@ function deferredHistoryController() {
}

function crossSessionGateScenario() {
type HistoryRequest = Parameters<typeof transcriptReadingPosition.loadHistory>[0]['request'];
type HistoryRequest = Parameters<typeof loadTranscriptHistory>[0]['request'];
const gates: TranscriptHistoryGates = new WeakMap();
const sessionIds = { a: 'session', b: 'session:a' } as const;
const sides = {
Expand Down Expand Up @@ -132,7 +135,7 @@ function crossSessionGateScenario() {
request: { target: 'earlier' | 'later' | 'latest'; anchorTurnId?: string },
) {
const side = sides[id];
return transcriptReadingPosition.loadHistory({
return loadTranscriptHistory({
gates,
sessionId: sessionIds[id],
request,
Expand Down Expand Up @@ -377,7 +380,7 @@ describe('app shell session UI state controller', () => {
let index: { sessionId: string; throughSequence: number | null; turns: readonly string[] } | undefined = {
sessionId: 'owner-session', throughSequence: 0, turns: ['previous-owner-turn'],
};
const dispose = transcriptReadingPosition.refreshLandmarks({
const dispose = refreshTranscriptTurnLandmarks({
sessionId: 'owner-session',
newestDurablePromptSequence: 1,
list: () => new Promise<{ throughSequence: number; landmarks: string[] }>((resolve) => {
Expand All @@ -388,7 +391,7 @@ describe('app shell session UI state controller', () => {
});
// The shell cleans up the Owner effect and passes no ownerActiveId for Guests.
dispose?.();
transcriptReadingPosition.refreshLandmarks<string>({
refreshTranscriptTurnLandmarks<string>({
sessionId: undefined,
newestDurablePromptSequence: 1,
list: async () => assert.fail('Guests cannot query Owner turn landmarks'),
Expand All @@ -400,40 +403,46 @@ describe('app shell session UI state controller', () => {
assert.equal(index, undefined);
});

it('enriches a Turn-only reading anchor when its range sequence arrives later', () => {
it('enriches a Turn-only reading anchor when its range sequence arrives later', async () => {
let anchor: { turnId: string; sequence?: number } | undefined;
transcriptReadingPosition.restoreRange({
const admitted: Array<number | null> = [];
restoreSessionTranscriptRange({
lifecycle: createTranscriptRestoreLifecycle(),
sessionId: 'session',
readingAnchor: { turnId: 'turn' },
controller: {
store: {
sessionId: 'session',
range: () => ({ sessionId: 'session' }),
sequenceForTurn: () => 17,
newestDurableUserSequence: () => 17,
snapshot: () => ({ messages: [] }),
},
ready: async () => undefined,
setReadingAnchor: async (sequence) => { admitted.push(sequence); },
loadAround: async () => assert.fail('the resident Turn must not load another range'),
},
isCurrent: () => true,
setMessages: () => assert.fail('the resident range must not replace messages'),
setReadingAnchor: (_sessionId, next) => {
anchor = next;
},
onError: (error) => assert.fail(String(error)),
});

assert.deepEqual(anchor, { turnId: 'turn', sequence: 17 });
await new Promise<void>((resolve) => setImmediate(resolve));
assert.deepEqual(admitted, [17]);
});

it('does not enrich a reading anchor from another Session range', () => {
let sequenceReads = 0;
let anchor: { turnId: string; sequence?: number } | undefined;
transcriptReadingPosition.restoreRange({
restoreSessionTranscriptRange({
lifecycle: createTranscriptRestoreLifecycle(),
sessionId: 'active',
readingAnchor: { turnId: 'turn' },
controller: {
store: {
sessionId: 'stale',
range: () => ({ sessionId: 'stale' }),
sequenceForTurn: () => {
sequenceReads += 1;
Expand All @@ -442,11 +451,10 @@ describe('app shell session UI state controller', () => {
newestDurableUserSequence: () => 17,
snapshot: () => ({ messages: [] }),
},
ready: async () => undefined,
setReadingAnchor: async () => {},
loadAround: async () => assert.fail('a stale range must not load'),
},
isCurrent: () => true,
setMessages: () => assert.fail('a stale range must not replace messages'),
setReadingAnchor: (_sessionId, next) => {
anchor = next;
},
Expand All @@ -458,78 +466,76 @@ describe('app shell session UI state controller', () => {
});

it('abandons a Turn-only restore that remains absent after the range is ready', async () => {
const anchorWrites: Array<{ turnId: string; sequence?: number } | undefined> = [];
let anchor: { turnId: string; sequence?: number } | undefined = { turnId: 'missing' };
let unavailable: { sessionId: string; turnId: string } | undefined;
const options = {
lifecycle: createTranscriptRestoreLifecycle(),
sessionId: 'session',
readingAnchor: { turnId: 'missing' },
controller: {
store: {
sessionId: 'session',
range: () => ({ sessionId: 'session' }),
sequenceForTurn: () => null,
newestDurableUserSequence: () => null,
snapshot: () => ({ messages: [] }),
},
ready: async () => undefined,
setReadingAnchor: async () => {},
loadAround: async () => assert.fail('a Turn-only anchor has no load target'),
},
isCurrent: () => true,
setMessages: () => assert.fail('an unavailable target must not replace messages'),
setReadingAnchor: (_sessionId: string, next: { turnId: string; sequence?: number } | undefined) => {
anchorWrites.push(next);
anchor = next;
},
onRestoreUnavailable: (sessionId: string, turnId: string) => {
unavailable = { sessionId, turnId };
},
onError: (error: unknown) => assert.fail(String(error)),
};

transcriptReadingPosition.restoreRange(options);
restoreSessionTranscriptRange(options);
await new Promise<void>((resolve) => setImmediate(resolve));

assert.deepEqual(anchorWrites, [undefined]);
assert.equal(anchor, undefined);
assert.deepEqual(unavailable, { sessionId: 'session', turnId: 'missing' });
});

it('abandons a known-sequence restore when loadAround cannot make the Turn resident', async () => {
let loadedSequence: number | undefined;
let unavailable: { sessionId: string; turnId: string } | undefined;
let messages: Array<{ id: string }> | undefined;
const anchorWrites: Array<{ turnId: string; sequence?: number } | undefined> = [];
let anchor: { turnId: string; sequence?: number } | undefined = { turnId: 'removed', sequence: 23 };
const options = {
lifecycle: createTranscriptRestoreLifecycle(),
sessionId: 'session',
readingAnchor: { turnId: 'removed', sequence: 23 },
controller: {
store: {
sessionId: 'session',
range: () => ({ sessionId: 'session' }),
sequenceForTurn: () => null,
newestDurableUserSequence: () => 29,
snapshot: () => ({ messages: [{ id: 'latest' }] }),
},
ready: async () => undefined,
setReadingAnchor: async () => assert.fail('a missing durable Turn must load its range'),
loadAround: async (sequence: number) => {
loadedSequence = sequence;
},
},
isCurrent: () => true,
setMessages: (next: Array<{ id: string }>) => {
messages = next;
},
setReadingAnchor: (_sessionId: string, next: { turnId: string; sequence?: number } | undefined) => {
anchorWrites.push(next);
anchor = next;
},
onRestoreUnavailable: (sessionId: string, turnId: string) => {
unavailable = { sessionId, turnId };
},
onError: (error: unknown) => assert.fail(String(error)),
};

transcriptReadingPosition.restoreRange(options);
restoreSessionTranscriptRange(options);
await new Promise<void>((resolve) => setImmediate(resolve));

assert.equal(loadedSequence, 23);
assert.deepEqual(messages, [{ id: 'latest' }]);
assert.deepEqual(anchorWrites, [undefined]);
assert.equal(anchor, undefined);
assert.deepEqual(unavailable, { sessionId: 'session', turnId: 'removed' });
});

Expand Down Expand Up @@ -580,7 +586,7 @@ describe('app shell session UI state controller', () => {
scenario.sides.a.failBefore(new Error('earlier read failed'));
await stale;
assert.deepEqual(scenario.errors.a, []);
assert.deepEqual(scenario.pending.a, [{ target: 'earlier' }, undefined]);
assert.deepEqual(scenario.pending.a, [{ target: 'earlier' }]);
assert.deepEqual(scenario.pending.b, []);
});

Expand Down Expand Up @@ -657,7 +663,7 @@ describe('app shell session UI state controller', () => {
await new Promise<void>((resolve) => setImmediate(resolve));
assert.deepEqual(scenario.sides.a.calls, ['before']);
assert.deepEqual(scenario.sides.b.calls, []);
assert.deepEqual(scenario.pending.a, [{ target: 'earlier' }, undefined]);
assert.deepEqual(scenario.pending.a, [{ target: 'earlier' }]);
scenario.sides.a.settleLatest();
await queued;
});
Expand Down
Loading