Skip to content
Open
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
53 changes: 45 additions & 8 deletions apps/mobile/src/components/agents/mobile-session-manager.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
/* eslint-disable require-await, @typescript-eslint/require-await -- injectable query/sleep fakes settle without await */
/* eslint-disable max-lines -- the manager suite pins retry cadence and attachment mints in one file. */
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { createStore } from 'jotai';
import { RequestDeadlineError } from '@kilocode/event-service';

import { type AgentAttachmentSubmissionPayload } from '@/lib/agent-attachments/agent-attachment-types';
Expand Down Expand Up @@ -46,14 +47,8 @@ vi.mock('@/components/agents/mobile-session-diagnostics', () => ({
vi.mock('@/components/agents/mobile-session-page-adapter', () => ({
fetchMobileSessionSnapshotPage: vi.fn(),
}));
// The transcript cache owns the encrypted KV (SQLCipher) chain; this suite is
// pure and only needs the call seams.
vi.mock('@/lib/persist/session-transcript-cache', () => ({
readSessionTranscriptPage: vi.fn(async () => null),
writeSessionTranscriptPage: vi.fn(async () => undefined),
clearSessionTranscriptPage: vi.fn(async () => undefined),
}));
// Same seam for the resolved-delivery-failure memory: it shares that chain.
// The resolved-delivery-failure memory owns the encrypted KV (SQLCipher)
// chain; this suite is pure and only needs the call seam.
vi.mock('@/lib/persist/resolved-delivery-failures', () => ({
readResolvedDeliveryFailures: vi.fn(async () => []),
persistResolvedDeliveryFailure: vi.fn(async () => undefined),
Expand Down Expand Up @@ -814,3 +809,45 @@ describe('createMobileAgentSessionManager metadata memo', () => {
expect(getSessionQuery).toHaveBeenCalledTimes(1);
});
});

describe('createMobileAgentSessionManager cold open', () => {
beforeEach(() => {
configHolder.current = null;
mockCreateSessionManager.mockClear();
getWithRuntimeStateQuery.mockReset();
getSessionQuery.mockReset();
getSessionMessagesQuery.mockReset();
});

it('omits the cached-snapshot reader so a cold open cannot paint stale rows', () => {
const options = { store: {}, userWebConnection: {} };
createMobileAgentSessionManager(options as never);
const config = configHolder.current;
if (config === null) {
throw new Error('createSessionManager did not capture a config');
}
expect(config.readCachedSnapshotPage).toBeUndefined();
});

it('leaves the transcript empty while the live page has not landed', async () => {
const store = createStore();
// The live metadata read never settles: the open is still in flight, so the
// screen keeps its full-page skeleton instead of painting rows.
const pending = Promise.withResolvers<unknown>();
getWithRuntimeStateQuery.mockReturnValue(pending.promise);
const options = { store, userWebConnection: {} };
createMobileAgentSessionManager(options as never);
const config = configHolder.current;
if (config === null) {
throw new Error('createSessionManager did not capture a config');
}
const { createSessionManager } = await import('@kilocode/cloud-agent-sdk/session-manager');
const manager = createSessionManager(config);

void manager.switchSession(SESSION_ID);

expect(store.get(manager.atoms.messagesList)).toHaveLength(0);
expect(store.get(manager.atoms.isLoading)).toBe(true);
expect(store.get(manager.atoms.isRefreshingCachedTranscript)).toBe(false);
});
});
38 changes: 13 additions & 25 deletions apps/mobile/src/components/agents/mobile-session-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,10 +33,6 @@ import { createNativeUserWebConnectionLifecycleHooks } from '@/lib/user-web-conn
import { answerSessionPermission } from '@/lib/glanceable/approve-ask';
import { cacheToolAttachment } from '@/components/agents/tool-card-image-cache';
import { cacheFilePart } from '@/components/agents/file-part-cache';
import {
readSessionTranscriptPage,
writeSessionTranscriptPage,
} from '@/lib/persist/session-transcript-cache';
import {
persistResolvedDeliveryFailure,
readResolvedDeliveryFailures,
Expand Down Expand Up @@ -177,11 +173,12 @@ type CreateMobileAgentSessionManagerOptions = {
userWebConnection: UserWebConnection;
organizationId?: string;
/**
* The authenticated owner the cached transcript is scoped to. Empty means
* the owner is not confirmed yet, in which case the cache is skipped
* entirely rather than writing to a shared anonymous scope.
* The authenticated owner the resolved-delivery-failure memory is scoped to.
* The manager's own persisted transcript cache is gone, so this scope is only
* read by that memory; an absent owner skips it rather than writing to a
* shared anonymous scope.
*/
userId: string;
userId?: string;
};

const skipBatchOptions = { context: { skipBatch: true } };
Expand Down Expand Up @@ -209,27 +206,24 @@ export function createMobileAgentSessionManager({
sessionId: KiloSessionId;
cloudAgentSessionId: CloudAgentSessionId | null;
} | null = null;
// The auth epoch this manager was created under. A transcript-cache write
// captured before a sign-out/sign-in must not land in the previous account's
// scope, so the write path re-checks this epoch (same fence as the read
// cache's persister).
const transcriptOwner = { userId, authEpoch: currentAuthEpoch() };
// The auth epoch this manager was created under. A resolved-delivery-failure
// write captured before a sign-out/sign-in must not land in the previous
// account's scope, so the write path re-checks this epoch. An empty owner
// skips the memory entirely.
const resolvedDeliveryOwner = { userId: userId ?? '', authEpoch: currentAuthEpoch() };
return createSessionManager({
store,
websocketBaseUrl: CLOUD_AGENT_WS_URL,
websocketHeaders: { Origin: WEB_BASE_URL },
lifecycleHooks: createNativeUserWebConnectionLifecycleHooks(),
userWebConnection,
// Thin cache passthrough: `readSessionTranscriptPage` already returns the
// promise and no-ops on an empty owner.
// eslint-disable-next-line @typescript-eslint/promise-function-async -- passthrough returns the promise directly
readCachedSnapshotPage: (id: KiloSessionId) => readSessionTranscriptPage(userId, id),
// Durable memory of retried delivery failures, so the DO's stored-event
// replay on the next open cannot restore a footer the retry cleared.
// eslint-disable-next-line @typescript-eslint/promise-function-async -- passthrough returns the promise directly
readResolvedDeliveryFailures: (id: KiloSessionId) => readResolvedDeliveryFailures(userId, id),
readResolvedDeliveryFailures: (id: KiloSessionId) =>
readResolvedDeliveryFailures(resolvedDeliveryOwner.userId, id),
persistResolvedDeliveryFailure: (id: KiloSessionId, messageId: string) => {
void persistResolvedDeliveryFailure(transcriptOwner, id, messageId);
void persistResolvedDeliveryFailure(resolvedDeliveryOwner, id, messageId);
},
// A tRPC call whose client control-plane deadline expired never got an
// answer: the open is stalled, not failed. The manager keeps the skeleton
Expand Down Expand Up @@ -315,12 +309,6 @@ export function createMobileAgentSessionManager({
},
fetchSnapshotPage: async (id: KiloSessionId, options: { cursor?: string }) => {
const outcome = await fetchMobileSessionSnapshotPage(id, options);
// Only the first page (no cursor) is cached: it holds the newest
// messages, which is what a warm open paints before the live refresh.
// Best effort — the write never affects the returned page.
if (outcome.kind === 'success' && options.cursor === undefined) {
void writeSessionTranscriptPage(transcriptOwner, id, outcome);
}
return outcome;
},
api: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,10 @@ vi.mock('@/components/ui/button', () => ({
Button: 'Button',
}));
vi.mock('@/components/ui/icons', () => ({ RefreshCw: 'RefreshCw' }));
// The loading profile row renders the reanimated `Skeleton`; stub it so this
// node suite neither loads reanimated nor loses the `findElementByType`
// assertion for the loading placeholder.
vi.mock('@/components/ui/skeleton', () => ({ Skeleton: 'Skeleton' }));

vi.mock('@/components/ui/segmented-control', () => ({
SegmentedControl: 'SegmentedControl',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -100,11 +100,13 @@ describe('retryFailedMessage', () => {
['m-retried', { status: 'failed', error: 'boom', reason: 'execution' }],
]);

// The marker folds onto the message row as `timeMarker`, so the failed
// submission is the only item and its key no longer carries a `time:` row.
expect(
mergeSessionTranscript([submission], [], deliveryStates).map(item =>
getSessionTranscriptItemKey(item)
)
).toEqual(['time:m-retried', 'm-retried']);
).toEqual(['m-retried']);

await retryFailedMessage({
message: submission,
Expand Down
141 changes: 140 additions & 1 deletion apps/mobile/src/components/agents/session-detail-content.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,8 @@ import { SessionGoalSection } from '@/components/agents/session-goal-section';
import { SessionSkeletonMessages } from '@/components/agents/session-detail-skeleton';
import { SESSION_SLOW_LOAD_MS } from '@/components/agents/session-slow-load';
import { SessionMessageList } from '@/components/agents/session-message-list';
import type * as SessionTranscript from '@/components/agents/session-transcript';
import { type SessionTranscriptItem } from '@/components/agents/session-transcript';
import { WorkingIndicator } from '@/components/agents/working-indicator';
import {
resolveSendAttachmentKind,
Expand Down Expand Up @@ -273,7 +275,10 @@ vi.mock('@/components/agents/text-part-renderer', () => ({
vi.mock('@/components/agents/chat-markdown-text', () => ({
ChatMarkdownText: ({ value }: { value: string }) => createElement('Text', null, value),
}));
vi.mock('@/components/agents/tool-cards', () => ({ TaskToolCard: 'TaskToolCard' }));
vi.mock('@/components/agents/tool-cards', () => ({
TaskToolCard: 'TaskToolCard',
ReadToolCard: 'ReadToolCard',
}));
vi.mock('@/components/agents/suggest-tool-card', () => ({ SuggestToolCard: 'SuggestToolCard' }));
vi.mock('@/components/agents/session-message-list', () => ({
SessionMessageList: function MessageList<T>(props: ComponentProps<typeof SessionMessageList<T>>) {
Expand Down Expand Up @@ -401,6 +406,21 @@ vi.mock('@/lib/hooks/use-condense-tool-calls-preference', () => ({
setCondenseToolCalls: vi.fn(),
}),
}));
// The part→item-key map is only read back by the condensed build, so the
// component must not walk the transcript for it while condensing is off.
const transcriptKeyCollection = vi.hoisted(() => ({ calls: 0 }));
vi.mock('@/components/agents/session-transcript', async importOriginal => {
const actual = await importOriginal<typeof SessionTranscript>();
return {
...actual,
collectTranscriptItemKeysByPart: (
...args: Parameters<typeof actual.collectTranscriptItemKeysByPart>
) => {
transcriptKeyCollection.calls += 1;
return actual.collectTranscriptItemKeysByPart(...args);
},
};
});
vi.mock('@/lib/hooks/use-session-model-options', () => ({
useSessionModelOptions: () => ({ options: [], selectedValue: '', selectedVariant: '' }),
}));
Expand Down Expand Up @@ -579,6 +599,23 @@ function messageLists(renderer: ReactTestRenderer): ReactTestInstance[] {
return renderer.root.findAll(node => Object.is(node.type, 'MessageList'));
}

/**
* The FlashList keys the first message list would mount rows under. The list is
* stubbed, so read the props the stub was handed: the same `keyExtractor` the
* real FlashList uses for its viewport anchor.
*/
function transcriptKeys(renderer: ReactTestRenderer): string[] {
const list = renderer.root.findAllByType(SessionMessageList)[0];
if (!list) {
return [];
}
const { items, keyExtractor } = list.props as {
items: readonly SessionTranscriptItem[];
keyExtractor: (item: SessionTranscriptItem) => string;
};
return items.map(item => keyExtractor(item));
}

beforeEach(() => {
navigationRoutes.splice(0, navigationRoutes.length, 'session-detail');
openRenameModal.mockClear();
Expand Down Expand Up @@ -1909,6 +1946,45 @@ describe('SessionDetailContent condensed tool runs', () => {
expect(runRows).toHaveLength(1);
expect(runRows[0]?.parent?.type).toBe('MessageErrorBoundary');
});

it('keeps the condensed row key when an older tool-only page prepends', async () => {
condensePreference.value = true;
rootPageNextCursor = 'older-cursor';
const view = await mountDetails([toolRunMessage(ROOT_ID, 'm2', ['t2'])]);
// A lone tool part condenses to its message row, keyed by the message id.
expect(transcriptKeys(view.renderer)).toEqual(['m2']);

// Loading older messages prepends an older tool-only message whose part
// joins the run. The row FlashList anchored on must keep its key, or the
// viewport jumps (the reported defect).
await act(async () => {
void view.manager.loadOlderMessages();
await Promise.resolve();
});
await view.respond(ROOT_ID, [toolRunMessage(ROOT_ID, 'm1', ['t1'])]);

expect(transcriptKeys(view.renderer)).toEqual(['m2']);
});
});

describe('SessionDetailContent transcript key collection', () => {
it('does not walk the transcript for part keys while condensing is off', async () => {
condensePreference.value = false;
transcriptKeyCollection.calls = 0;

await mountDetails([toolRunMessage(ROOT_ID, 'm-tool-run', ['t1', 't2'])]);

expect(transcriptKeyCollection.calls).toBe(0);
});

it('collects part keys once condensing is on', async () => {
condensePreference.value = true;
transcriptKeyCollection.calls = 0;

await mountDetails([toolRunMessage(ROOT_ID, 'm-tool-run', ['t1', 't2'])]);

expect(transcriptKeyCollection.calls).toBeGreaterThan(0);
});
});

describe('session detail exit retry row', () => {
Expand Down Expand Up @@ -1949,6 +2025,69 @@ describe('session detail exit retry row', () => {
});
});

describe('transcript time markers', () => {
it.each(['message', 'tool-run'] as const)(
'keeps the %s subtree mounted when a prepend moves its marker',
async kind => {
condensePreference.value = kind === 'tool-run';
rootPageNextCursor = 'older-cursor';
const message =
kind === 'tool-run'
? toolRunMessage(ROOT_ID, 'm2', ['t2a', 't2b'])
: childMessage(ROOT_ID, 'Existing answer');
message.info.time.created = 1_000_000_000;
const view = await mountDetails([message]);
const findRow = () =>
kind === 'tool-run'
? view.renderer.root.find(node => Object.is(node.type, 'CondensedToolRunRow'))
: view.renderer.root.findByProps({ children: 'Existing answer' });
const before = findRow();
expect(before).toBeDefined();
const keys = transcriptKeys(view.renderer);

await act(async () => {
void view.manager.loadOlderMessages();
await Promise.resolve();
});
const older = childMessage(ROOT_ID, 'Older answer');
older.info = { ...older.info, id: 'm1', time: { created: 999_999_000 } };
older.parts = [
stubTextPart({ id: 'text-m1', sessionID: ROOT_ID, messageID: 'm1', text: 'Older answer' }),
];
await view.respond(ROOT_ID, [older]);

expect(transcriptKeys(view.renderer)).toEqual(['m1', ...keys]);
expect(
view.renderer.root.findAll(node => Object.is(node.type, 'TranscriptTimeMarker'))
).toHaveLength(1);
expect(findRow() === before).toBe(true);
}
);

it('renders the marker in the same row as the message that opens the burst', async () => {
const message: StoredMessage = {
info: { ...assistantMessage('msg-marker').info, sessionID: ROOT_ID },
parts: [
stubTextPart({
id: 'text-msg-marker',
sessionID: ROOT_ID,
messageID: 'msg-marker',
text: 'Marked answer',
}),
],
};

const view = await mountDetails([message]);

// The first message of the page opens the burst, so its row carries the
// marker above the bubble instead of the marker being an item of its own.
expect(
view.renderer.root.findAll(node => Object.is(node.type, 'TranscriptTimeMarker'))
).toHaveLength(1);
expect(renderedText(view.renderer.root)).toContain('Marked answer');
});
});

describe('hide thinking preference', () => {
function partMessage(id: string, parts: StoredMessage['parts']): StoredMessage {
return { info: { ...assistantMessage(id).info, sessionID: ROOT_ID }, parts };
Expand Down
Loading
Loading