Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
7355a03
feat(session-ingest): paginate authorized session history
iscekic Jul 15, 2026
7367a6e
Merge remote-tracking branch 'origin/main' into feature/mobile-sessio…
iscekic Jul 15, 2026
01ce2e4
feat(agent-sessions): paginate session message history
iscekic Jul 15, 2026
abdb73d
Merge remote-tracking branch 'origin/main' into feature/mobile-sessio…
iscekic Jul 15, 2026
265aa4c
feat(mobile): load earlier session messages
iscekic Jul 15, 2026
1029fa2
test(mobile): cover session pagination trigger guards
iscekic Jul 15, 2026
1d9ca23
Merge remote-tracking branch 'origin/main' into feature/mobile-sessio…
iscekic Jul 15, 2026
93a7de9
Merge remote-tracking branch 'origin/main' into feature/mobile-sessio…
iscekic Jul 15, 2026
37e63b9
Merge remote-tracking branch 'origin/main' into feature/mobile-sessio…
iscekic Jul 15, 2026
9fedc64
fix(agent-sessions): guard pagination and auto-scroll races
iscekic Jul 15, 2026
d98709a
Merge remote-tracking branch 'origin/main' into feature/mobile-sessio…
iscekic Jul 15, 2026
f516dd3
Merge remote-tracking branch 'origin/main' into feature/mobile-sessio…
iscekic Jul 15, 2026
ed2c197
Merge remote-tracking branch 'origin/main' into feature/mobile-sessio…
iscekic Jul 16, 2026
a936852
Merge remote-tracking branch 'origin/main' into feature/mobile-sessio…
iscekic Jul 16, 2026
95bad8e
Merge remote-tracking branch 'origin/main' into feature/mobile-sessio…
iscekic Jul 16, 2026
8604526
Merge remote-tracking branch 'origin/main' into feature/mobile-sessio…
iscekic Jul 16, 2026
6227021
Merge remote-tracking branch 'origin/main' into feature/mobile-sessio…
iscekic Jul 16, 2026
e8c25c3
Merge remote-tracking branch 'origin/main' into feature/mobile-sessio…
iscekic Jul 16, 2026
5e68c42
Merge remote-tracking branch 'origin/main' into feature/mobile-sessio…
iscekic Jul 16, 2026
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 @@ -95,6 +95,7 @@ type CapturedSessionManagerConfig = {
lifecycleHooks?: unknown;
fetchSession: (kiloSessionId: string) => Promise<{ associatedPr: unknown }>;
fetchSnapshot: (kiloSessionId: string) => Promise<{ info: unknown; messages: unknown[] }>;
fetchSnapshotPage: (kiloSessionId: string, options: { cursor?: string }) => Promise<unknown>;
prepare: (input: {
prompt: string;
mode: string;
Expand Down
2 changes: 2 additions & 0 deletions apps/mobile/src/components/agents/mobile-session-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
formatSafeCloudAgentFailureDiagnostic,
withCloudAgentDiagnostics,
} from '@/components/agents/mobile-session-diagnostics';
import { fetchMobileSessionSnapshotPage } from '@/components/agents/mobile-session-page-adapter';
import { trpcClient } from '@/lib/trpc';
import { API_BASE_URL, CLOUD_AGENT_WS_URL, WEB_BASE_URL } from '@/lib/config';
import { AUTH_TOKEN_KEY } from '@/lib/storage-keys';
Expand Down Expand Up @@ -130,6 +131,7 @@ export function createMobileAgentSessionManager({
messages: messagesResult.messages as SessionSnapshot['messages'],
};
},
fetchSnapshotPage: fetchMobileSessionSnapshotPage,
api: {
send: async input => {
await withCloudAgentDiagnostics('send', organizationId, async () => {
Expand Down
218 changes: 218 additions & 0 deletions apps/mobile/src/components/agents/mobile-session-page-adapter.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,218 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import {
type KiloSdkMessageHistory,
type KiloSdkMessageHistoryPage,
type KiloSdkStoredMessage,
type KiloSessionId,
type SessionSnapshotPageOutcome,
} from 'cloud-agent-sdk';

const mocks = vi.hoisted(() => ({
getSessionMessagesPageQuery: vi.fn(),
}));

vi.mock('@/lib/trpc', () => ({
trpcClient: {
cliSessionsV2: {
getSessionMessagesPage: { query: mocks.getSessionMessagesPageQuery },
},
},
}));

function kiloSessionId(id: string): KiloSessionId {
return id as KiloSessionId;
}

function storedMessage(
overrides: {
id?: string;
sessionID?: string;
created?: number;
text?: string;
} = {}
): KiloSdkStoredMessage {
const id = overrides.id ?? 'msg_user_01';
const sessionID = overrides.sessionID ?? 'ses_123';
const created = overrides.created ?? 1_761_000_000_100;
const text = overrides.text ?? 'hello';

return {
info: {
id,
sessionID,
role: 'user',
time: { created },
agent: 'build',
model: { providerID: 'openrouter', modelID: 'anthropic/claude-sonnet-4' },
},
parts: [
{
id: `${id}-text`,
sessionID,
messageID: id,
type: 'text',
text,
},
],
};
}

function historyPage(
overrides: Partial<KiloSdkMessageHistoryPage> = {}
): KiloSdkMessageHistoryPage {
return {
messages: [],
nextCursor: null,
omittedItemCount: 0,
...overrides,
};
}

async function importAdapter(): Promise<{
fetchMobileSessionSnapshotPage: (
kiloSessionId: KiloSessionId,
options: { cursor?: string }
) => Promise<SessionSnapshotPageOutcome | null>;
}> {
const adapter = await import('@/components/agents/mobile-session-page-adapter');
return adapter;
}

describe('fetchMobileSessionSnapshotPage', () => {
beforeEach(() => {
vi.clearAllMocks();
});

it('maps a successful shared history page to SessionSnapshotPageOutcome success', async () => {
const message = storedMessage();
const page = historyPage({
messages: [message],
nextCursor: 'opaque-cursor',
omittedItemCount: 2,
});
mocks.getSessionMessagesPageQuery.mockResolvedValueOnce({
kiloSessionId: 'ses_123',
history: page,
});

const { fetchMobileSessionSnapshotPage } = await importAdapter();
const result = await fetchMobileSessionSnapshotPage(kiloSessionId('ses_123'), {});

expect(result).toEqual({
kind: 'success',
info: { id: 'ses_123' },
messages: page.messages,
nextCursor: 'opaque-cursor',
omittedItemCount: 2,
});
// No `cursor` was supplied, so the tRPC layer must see only `session_id`
// and let its input schema default the bounded page size to 50.
expect(mocks.getSessionMessagesPageQuery).toHaveBeenCalledWith({ session_id: 'ses_123' });
});

it('forwards the continuation cursor to the tRPC layer as `cursor`', async () => {
mocks.getSessionMessagesPageQuery.mockResolvedValueOnce({
kiloSessionId: 'ses_123',
history: historyPage({ nextCursor: null }),
});

const { fetchMobileSessionSnapshotPage } = await importAdapter();
await fetchMobileSessionSnapshotPage(kiloSessionId('ses_123'), { cursor: 'opaque-cursor' });

expect(mocks.getSessionMessagesPageQuery).toHaveBeenCalledWith({
session_id: 'ses_123',
cursor: 'opaque-cursor',
});
});

it('omits the cursor from the tRPC request when the manager has no continuation', async () => {
mocks.getSessionMessagesPageQuery.mockResolvedValueOnce({
kiloSessionId: 'ses_123',
history: historyPage({ nextCursor: null }),
});

const { fetchMobileSessionSnapshotPage } = await importAdapter();
await fetchMobileSessionSnapshotPage(kiloSessionId('ses_123'), { cursor: '' });

// An empty cursor must not be forwarded; the tRPC schema would reject
// a non-positive length string anyway, and the manager only sets
// `nextCursor: null` once the history is fully read.
expect(mocks.getSessionMessagesPageQuery).toHaveBeenCalledWith({ session_id: 'ses_123' });
});

it('preserves a null history result so the manager can mark it terminal', async () => {
mocks.getSessionMessagesPageQuery.mockResolvedValueOnce({
kiloSessionId: 'ses_123',
history: null,
});

const { fetchMobileSessionSnapshotPage } = await importAdapter();
await expect(fetchMobileSessionSnapshotPage(kiloSessionId('ses_123'), {})).resolves.toBeNull();
});

it('passes typed retryable_failure through verbatim so the UI can offer Retry', async () => {
const history: KiloSdkMessageHistory = {
kind: 'retryable_failure',
phase: 'page_parts',
};
mocks.getSessionMessagesPageQuery.mockResolvedValueOnce({
kiloSessionId: 'ses_123',
history,
});

const { fetchMobileSessionSnapshotPage } = await importAdapter();
await expect(fetchMobileSessionSnapshotPage(kiloSessionId('ses_123'), {})).resolves.toEqual({
kind: 'retryable_failure',
phase: 'page_parts',
});
});

it('passes typed too_large and invalid_data failures through verbatim', async () => {
const histories: KiloSdkMessageHistory[] = [
{
kind: 'too_large',
maximumBytes: 8 * 1024 * 1024,
phase: 'message_scan',
},
{ kind: 'invalid_data' },
];

// Queue every response up-front so the mock implementation is fully
// deterministic regardless of which adapter call hits the mock first.
mocks.getSessionMessagesPageQuery.mockReset();
for (const history of histories) {
mocks.getSessionMessagesPageQuery.mockResolvedValueOnce({
kiloSessionId: 'ses_123',
history,
});
}

const { fetchMobileSessionSnapshotPage } = await importAdapter();
// Two independent adapter calls; the mock queue delivers the matching
// history to each. `Promise.all` runs them in parallel and the assertion
// checks the collected results in call order.
const results = await Promise.all([
fetchMobileSessionSnapshotPage(kiloSessionId('ses_123'), {}),
fetchMobileSessionSnapshotPage(kiloSessionId('ses_123'), {}),
]);

expect(results).toEqual(histories);
});

it('lets the tRPC input schema default the limit to 50 on the initial read', async () => {
// The mobile adapter never sets `limit` itself so the shared contract
// default of 50 always applies. This test pins that contract: any
// future change here must keep the request bounded by default.
mocks.getSessionMessagesPageQuery.mockResolvedValueOnce({
kiloSessionId: 'ses_123',
history: historyPage({ nextCursor: null }),
});

const { fetchMobileSessionSnapshotPage } = await importAdapter();
await fetchMobileSessionSnapshotPage(kiloSessionId('ses_123'), {});

const call = mocks.getSessionMessagesPageQuery.mock.calls[0]?.[0] as Record<string, unknown>;
expect(call).toBeDefined();
expect(call.limit).toBeUndefined();
});
});
55 changes: 55 additions & 0 deletions apps/mobile/src/components/agents/mobile-session-page-adapter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import {
type KiloSdkMessageHistory,
type KiloSdkMessageHistoryPage,
type KiloSessionId,
type SessionSnapshotPage,
type SessionSnapshotPageOutcome,
} from 'cloud-agent-sdk';
import { trpcClient } from '@/lib/trpc';

/**
* Type guard to narrow KiloSdkMessageHistory to the page variant.
* The shared union is discriminated by the presence of the `messages` array.
*/
function isHistoryPage(history: KiloSdkMessageHistory): history is KiloSdkMessageHistoryPage {
return 'messages' in history && Array.isArray(history.messages);
}

/**
* Fetch a bounded page of session messages for the mobile client.
*
* Maps the shared `KiloSdkMessageHistory` union to the SDK's
* `SessionSnapshotPageOutcome`:
* - Page variant → success outcome with messages, cursor, and omitted count
* - Failure variants → passed through verbatim (retryable, too_large, invalid_data)
* - Null history → null (access not found)
*
* The adapter does not re-validate the tRPC response; it trusts the shared
* contract and uses structural narrowing to distinguish page from failure.
*/
export async function fetchMobileSessionSnapshotPage(
kiloSessionId: KiloSessionId,
options: { cursor?: string }
): Promise<SessionSnapshotPageOutcome | null> {
const result = await trpcClient.cliSessionsV2.getSessionMessagesPage.query({
session_id: kiloSessionId,
...(options.cursor ? { cursor: options.cursor } : {}),
});

const history = result.history as KiloSdkMessageHistory | null;
if (history === null) {
return null;
}

if (isHistoryPage(history)) {
return {
kind: 'success',
info: { id: result.kiloSessionId },
messages: history.messages as SessionSnapshotPage['messages'],
nextCursor: history.nextCursor,
omittedItemCount: history.omittedItemCount,
};
}

return history;
}
Loading