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
Original file line number Diff line number Diff line change
Expand Up @@ -1345,6 +1345,8 @@ test('cached fallback remains readable and retries once per observation generati
await settle();
assert.equal(opens, 1);
assert.equal(store.range().generation, 'cached:generation');
assert.equal(store.range().hasOlder, false, 'a cached transcript offers no earlier history to load');
assert.equal(store.snapshot().hasOlder, false);
await controller.loadEarlier();
assert.equal(earlierReads, 0, 'a cached transcript has no Host to read earlier history from');
controller.observationChanged('ready');
Expand All @@ -1359,6 +1361,7 @@ test('cached fallback remains readable and retries once per observation generati
await settle();
assert.equal(opens, 3);
assert.equal(store.range().generation, 'live-generation');
assert.equal(store.range().hasOlder, true);
assert.deepEqual(errors, []);
await controller.close();
});
Expand Down
164 changes: 164 additions & 0 deletions apps/desktop/src/main/__tests__/transcript-open-cached-preview.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/

import assert from 'node:assert/strict';
import { createRequire } from 'node:module';
import { fileURLToPath } from 'node:url';
import { runInNewContext } from 'node:vm';
import test from 'node:test';
import { build } from 'esbuild';
import type { StoredMessage } from '@maka/core/session';
import type { MakaBridge } from '../../preload/bridge-contract.js';
import type { DesktopTranscriptBatch } from '../../preload/transcript-contract.js';
import { encodeDesktopTranscriptSnapshot } from '../desktop-transcript-ipc.js';
import { DesktopTranscriptRangeStore } from '../../renderer/platform/desktop/desktop-transcript-range-store.js';
import { desktopSessionKey } from '../../shared/runtime-host-identity.js';
import { waitFor } from '@maka/core/test-only/async-primitives';

const OWNER = {
hostId: 'host-1', targetEpoch: 'epoch-1', profileId: 'local',
profileName: 'Local', profileKind: 'local', profileAccess: 'owner', readiness: 'ready',
};
const SESSION_ID = desktopSessionKey({ hostId: OWNER.hostId, sessionId: 'session-1' });

const message = (id: string, turnId: string): StoredMessage => ({
type: 'user', id, turnId, ts: 1, text: id,
});
const CACHED_TAIL = [
{ sequence: 19, message: message('m19', 't19') },
{ sequence: 20, message: message('m20', 't20') },
];
const FULL = Array.from({ length: 20 }, (_, index) => ({
sequence: index + 1, message: message(`m${index + 1}`, `t${index + 1}`),
}));

interface Harness {
readonly bridge: MakaBridge;
readonly state: { cacheReads: number };
}

async function preloadHarness(options: { liveOpenFails?: boolean }): Promise<Harness> {
let bridge: MakaBridge | undefined;
let consumerId = '';
let deliverySequence = 0;
const state = { cacheReads: 0 };
const listeners = new Map<string, (...args: unknown[]) => void>();
const deliver = (batch: Omit<DesktopTranscriptBatch, 'deliverySequence'>) => {
listeners.get(`sessions:transcript:${consumerId}`)?.({}, OWNER, {
...batch, deliverySequence: ++deliverySequence,
});
};
const ipcRenderer = {
on(channel: string, listener: (...args: unknown[]) => void) { listeners.set(channel, listener); },
off(channel: string) { listeners.delete(channel); },
send() {},
async invoke(channel: string, ...args: unknown[]): Promise<unknown> {
if (channel === 'runtime-host:activeIdentity') return OWNER;
if (channel === 'runtime-host:identities') return [OWNER];
if (channel === 'session-local:transcript') {
state.cacheReads += 1;
return {
cachedAt: 1,
batches: [...encodeDesktopTranscriptSnapshot({
beginsAtTurnBoundary: true,
sessionId: 'session-1', generation: 'cached:g1', hostEpoch: 'epoch-1',
durableThrough: 20, durable: CACHED_TAIL, hasOlder: true,
})],
};
}
if (channel === 'sessions:transcript:open') {
consumerId = args[2] as string;
if (options.liveOpenFails) throw new Error('live transcript unavailable');
setImmediate(() => {
for (const batch of encodeDesktopTranscriptSnapshot({
beginsAtTurnBoundary: true,
sessionId: 'session-1', generation: 'live-1', hostEpoch: 'epoch-1',
durableThrough: 20, durable: FULL, hasOlder: false,
})) deliver(batch);
});
return { kind: 'ready', value: {
sessionId: 'session-1', generation: 'live-1', hostEpoch: 'epoch-1',
readThroughMessageId: null,
} };
}
if (
channel === 'sessions:transcript:ack' ||
channel === 'sessions:transcript:acknowledge-tail' ||
channel === 'sessions:transcript:close'
) return;
throw new Error(`Unexpected channel: ${channel}`);
},
};
const bundle = await build({
entryPoints: [fileURLToPath(new URL('../../../src/preload/preload.ts', import.meta.url))],
bundle: true, write: false, platform: 'node', format: 'cjs', external: ['electron'],
});
const require = createRequire(import.meta.url);
runInNewContext(bundle.outputFiles[0]!.text, {
require: (id: string) => id === 'electron' ? {
ipcRenderer,
contextBridge: { exposeInMainWorld(name: string, value: MakaBridge) {
if (name === 'maka') bridge = value;
} },
} : require(id),
process: { env: {} }, Buffer, console, setTimeout, clearTimeout, TextEncoder, TextDecoder,
Uint8Array, crypto: globalThis.crypto,
});
assert.ok(bridge);
return { bridge, state };
}

function publications(store: DesktopTranscriptRangeStore) {
const seen: Array<{ ids: string[]; hasOlder: boolean }> = [];
store.subscribe(() => {
const snapshot = store.snapshot();
if (snapshot.ready) {
seen.push({ ids: snapshot.messages.map((entry) => entry.id), hasOlder: snapshot.hasOlder });
}
});
return seen;
}

// A healthy open publishes the live answer as the first history; the cached
// tail is not a preview the reader ever sees.
test('a healthy transcript open publishes only the live answer and never reads the cache', async () => {
const { bridge, state } = await preloadHarness({ liveOpenFails: false });
const store = new DesktopTranscriptRangeStore(SESSION_ID);
const seen = publications(store);
const handle = await bridge.transcripts.open(SESSION_ID, (batch) => store.accept(batch), () => {}, 'history');
await waitFor(() => seen.length === 1, { timeoutMs: 5_000 });
await handle.close();
assert.deepEqual(seen, [{ ids: FULL.map((entry) => entry.message.id), hasOlder: false }]);
assert.equal(state.cacheReads, 0);
});

// The cache stands in only when the live read never answered; even then it
// advertises no earlier history because nothing can serve the read.
test('a failed live open falls back to the cached transcript without earlier history', async () => {
const { bridge, state } = await preloadHarness({ liveOpenFails: true });
const store = new DesktopTranscriptRangeStore(SESSION_ID);
const seen = publications(store);
const handle = await bridge.transcripts.open(SESSION_ID, (batch) => store.accept(batch), () => {}, 'history');
await waitFor(() => seen.length === 1, { timeoutMs: 5_000 });
assert.deepEqual(seen, [{ ids: ['m19', 'm20'], hasOlder: false }]);
assert.equal(state.cacheReads, 1);
assert.equal(handle.generation, 'cached:g1');
await assert.rejects(handle.loadEarlier(), /Reconnect the Host/);
await handle.close();
});
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,65 @@ test('a bookmark the Turn index does not know, or cannot be asked about, is unav
}
});

test('a cached transcript keeps a stored bookmark pending until the live answer replaces it', async () => {
const store = new DesktopTranscriptRangeStore(SESSION_ID);
for (const batch of encodeDesktopTranscriptSnapshot({
beginsAtTurnBoundary: true,
...IDENTITY, generation: 'cached:generation', durableThrough: 30,
durable: [{ sequence: 30, message: answer('c') }], hasOlder: true,
})) store.accept(batch);
const reads: (number | undefined)[] = [];
const lookups: string[] = [];
const controller = createDesktopTranscriptRangeController(store, async () => handle({
async loadEarlier(throughSequence) {
reads.push(throughSequence);
for (const batch of encodeDesktopTranscriptBatches(IDENTITY, {
durableThrough: 30,
durable: [{ sequence: 10, message: answer('a') }, { sequence: 20, message: answer('b') }],
hasOlder: false, earlierThan: 30, reset: false, ready: true,
})) store.accept(batch);
},
}), { onError: (error) => assert.fail(String(error)) });
let anchor: { turnId: string } | undefined = { turnId: 'a' };
const unavailable: string[] = [];
const lifecycle = createTranscriptRestoreLifecycle();
const restore = () => restoreSessionTranscriptRange({
lifecycle, sessionId: SESSION_ID, controller, readingAnchor: { turnId: 'a' },
isCurrent: () => true,
lookupTurn: async (_sessionId, turnId) => { lookups.push(turnId); return 10; },
setReadingAnchor: (_sessionId, next) => { anchor = next; },
onRestoreUnavailable: (_sessionId, turnId) => { unavailable.push(turnId); },
onError: (error) => assert.fail(String(error)),
});
try {
await controller.ready();
restore();
for (let tick = 0; tick < 4; tick += 1) await settle();
restore();
await settle();
assert.deepEqual(lookups, []);
assert.deepEqual(reads, []);
assert.deepEqual(anchor, { turnId: 'a' });
assert.deepEqual(unavailable, []);
for (const batch of encodeDesktopTranscriptSnapshot({
beginsAtTurnBoundary: true,
...IDENTITY, durableThrough: 30,
durable: [{ sequence: 30, message: answer('c') }], hasOlder: true,
})) store.accept(batch);
restore();
for (let tick = 0; tick < 4; tick += 1) await settle();
restore();
await settle();
assert.deepEqual(lookups, ['a']);
assert.deepEqual(reads, [10]);
assert.deepEqual(store.snapshot().messages.map(({ turnId }) => turnId), ['a', 'b', 'c']);
assert.deepEqual(anchor, { turnId: 'a' });
assert.deepEqual(unavailable, []);
} finally {
await controller.close();
}
});

test('sending before transcript open completes cancels the queued bookmark without delaying admission', { timeout: 5_000 }, async () => {
const store = new DesktopTranscriptRangeStore(SESSION_ID);
const opening = deferred<DesktopTranscriptHandle>();
Expand Down
46 changes: 26 additions & 20 deletions apps/desktop/src/preload/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2669,6 +2669,7 @@ const makaBridge = {
const channel = `sessions:transcript:${consumerId}`;
let identity: DesktopTranscriptIdentity | undefined;
let cachedIdentity: DesktopTranscriptIdentity | undefined;
let session: Awaited<ReturnType<typeof runtimeHostSessionRef>> | undefined;
const retiredGenerations = new Set<string>();
let closed = false;
let requestClose = () => {};
Expand Down Expand Up @@ -2712,22 +2713,15 @@ const makaBridge = {
}
};
ipcRenderer.on(channel, listener);
const openDispatch = runtimeHostSessionRef(sessionId).then(async (session) => {
consumerScope = session.scope;
const cached = await invokeWhenReady(
'session-local:transcript', session.scope, session.sessionId,
).catch(() => null) as import('../shared/session-local-contract.js').DesktopCachedTranscript | null;
const openDispatch = runtimeHostSessionRef(sessionId).then(async (ref) => {
consumerScope = ref.scope;
session = ref;
if (closed) throw new Error('Desktop transcript open was cancelled');
// Local frames do not participate in the live consumer identity or ACK window.
for (const [index, batch] of (cached?.batches ?? []).entries()) {
handler({ ...batch, deliverySequence: index + 1 });
if (batch.ready) cachedIdentity = { generation: batch.generation, hostEpoch: batch.hostEpoch };
}
return {
completion: invokeWhenReady(
'sessions:transcript:open',
session.scope,
session.sessionId,
ref.scope,
ref.sessionId,
consumerId,
mode,
resumeFrom ?? null,
Expand All @@ -2752,14 +2746,26 @@ const makaBridge = {
const cancelled = closed;
closed = true;
ipcRenderer.off(channel, listener);
if (!cancelled && cachedIdentity && !identity) {
const unavailable = async () => { throw new Error('Reconnect the Host to load uncached history'); };
return {
...cachedIdentity, sessionId, readThroughMessageId: null,
acknowledgeTail: unavailable,
loadEarlier: unavailable,
close: async () => {},
};
if (!cancelled && !identity && session) {
// The cache stands in only when the live read never answered; on a
// healthy open the first published history is the live answer itself.
const cached = await invokeWhenReady(
'session-local:transcript', session.scope, session.sessionId,
).catch(() => null) as import('../shared/session-local-contract.js').DesktopCachedTranscript | null;
// Local frames do not participate in the live consumer identity or ACK window.
for (const [index, batch] of (cached?.batches ?? []).entries()) {
handler({ ...batch, deliverySequence: index + 1 });
if (batch.ready) cachedIdentity = { generation: batch.generation, hostEpoch: batch.hostEpoch };
}
if (cachedIdentity) {
const unavailable = async () => { throw new Error('Reconnect the Host to load uncached history'); };
return {
...cachedIdentity, sessionId, readThroughMessageId: null,
acknowledgeTail: unavailable,
loadEarlier: unavailable,
close: async () => {},
};
}
}
throw error;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -168,7 +168,9 @@ export function restoreSessionTranscriptRange<Message>(options: {
const command = options.lifecycle.request(options);
if (!command || command.loading || !controller || !sessionId || !options.isCurrent(sessionId, controller)) return;
const range = currentTranscriptRange(controller, sessionId);
if (!range?.ready) return;
// A cached range is replaced wholesale by the live answer; only that answer
// can say whether the target Turn is reachable.
if (!range?.ready || range.generation?.startsWith('cached:')) return;
const { turnId } = command.target;
if (controller.store.snapshot().messages.some((message) =>
message !== null && typeof message === 'object' && 'turnId' in message && message.turnId === turnId,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -474,7 +474,9 @@ export class DesktopTranscriptRangeStore {
hostEpoch: this.#hostEpoch,
durableThrough: this.#value.through,
oldestSequence: this.#value.order[0] ?? null,
hasOlder: this.#value.hasOlder,
// A cached snapshot cannot serve earlier reads; the live answer replaces
// it rather than continuing it.
hasOlder: this.#value.hasOlder && !this.#generation.startsWith('cached:'),
beginsAtTurnBoundary: this.#value.beginsAtTurnBoundary,
ready: this.#ready,
};
Expand Down
14 changes: 14 additions & 0 deletions apps/desktop/src/renderer/styles/chat-message.css
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,20 @@
width: 100%;
}

/* Like the startup reveal (#5494), the previous transcript stays put until the
live answer mounts the new one, which fades in once — no cached tail, no
intermediate partial state. */
.maka-chat-session-swap {
animation-name: maka-stream-fade-in;
animation-duration: var(--duration-emphasized);
animation-timing-function: var(--ease-out-strong);
}
@media (prefers-reduced-motion: reduce) {
.maka-chat-session-swap {
animation: none;
}
}

/* Native anchoring handles ordinary content growth. The scroll authority
disables it during following and atomic range replacement, when it owns
the position write itself. */
Expand Down
2 changes: 1 addition & 1 deletion packages/ui/src/chat-view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -806,7 +806,7 @@ export function ChatView(props: {
? emptyContent
: null}
{loadEarlierHistoryControl}
<div key={props.activeSession.id} ref={listRef}>
<div key={props.activeSession.id} ref={listRef} className="maka-chat-session-swap">
<Virtualizer
key={measurement.generation}
ref={virtualizerRef}
Expand Down