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
7 changes: 6 additions & 1 deletion core/src/sessions/base_session_service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,12 @@ export interface AppendEventRequest {
/**
* The response of listing sessions.
*
* The events and states are not set within each Session object.
* Events are never populated on a listed session; use `getSession` to load
* them. State is populated: each listed session carries the same merged view
* `getSession` returns for it, i.e. its session-scoped state plus the
* `app:`-prefixed application state and the `user:`-prefixed state of the
* session's own user, for services that keep separate application and user
* state stores.
* When no pagination params were requested, `page` is 1, `limit` equals
* `totalItems`, and `totalPages` is 1 (or 0 when there are no sessions).
*/
Expand Down
6 changes: 1 addition & 5 deletions core/src/sessions/database_session_service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -262,11 +262,7 @@ export class DatabaseSessionService extends BaseSessionService {
await this.init();
const em = this.orm!.em.fork();

// One scope drives both the session query and the user-state query, so the
// two can never disagree about which users are in range.
const where = (
userId === undefined ? {appName} : {appName, userId}
) satisfies FilterQuery<StorageSession> & FilterQuery<StorageUserState>;
const where = userId === undefined ? {appName} : {appName, userId};

const orderBy =
order === 'asc'
Expand Down
6 changes: 5 additions & 1 deletion core/src/sessions/in_memory_session_service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -152,7 +152,11 @@ export class InMemorySessionService extends BaseSessionService {
id: session.id,
appName: session.appName,
userId: session.userId,
state: {},
state: mergeStates(
this.appState[appName],
this.userState[appName]?.[session.userId],
session.state,
),
events: [],
lastUpdateTime: session.lastUpdateTime,
}),
Expand Down
21 changes: 21 additions & 0 deletions core/test/sessions/database_session_service_test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -382,6 +382,27 @@ describe('DatabaseSessionService', () => {
expect(listEmpty.sessions).toEqual([]);
});

it('listSessions returns merged app, user and session state', async () => {
await service.createSession({
appName: 'app1',
userId: 'u1',
sessionId: 's1',
state: {
[`${State.APP_PREFIX}appKey`]: 'av',
[`${State.USER_PREFIX}userKey`]: 'uv',
sessionKey: 'sv',
},
});

const list = await service.listSessions({appName: 'app1', userId: 'u1'});

expect(list.sessions[0].state).toEqual({
[`${State.APP_PREFIX}appKey`]: 'av',
[`${State.USER_PREFIX}userKey`]: 'uv',
sessionKey: 'sv',
});
});

it('should handle errors', async () => {
await service.createSession({
appName: 'app1',
Expand Down
235 changes: 235 additions & 0 deletions core/test/sessions/in_memory_session_service_test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -618,6 +618,241 @@ describe('InMemorySessionService', () => {

expect(response.sessions).toEqual([]);
});

it('returns merged app, user and session state for each listed session', async () => {
const appName = 'app';
const userId = 'user';
const session = await service.createSession({appName, userId});
await service.appendEvent({
session,
event: createEvent({
timestamp: 1000,
actions: createEventActions({
stateDelta: {
sessionKey: 'sv',
[`${State.APP_PREFIX}appKey`]: 'av',
[`${State.USER_PREFIX}userKey`]: 'uv',
},
}),
}),
});

const response = await service.listSessions({appName, userId});

expect(response.sessions[0].state).toEqual({
sessionKey: 'sv',
[`${State.APP_PREFIX}appKey`]: 'av',
[`${State.USER_PREFIX}userKey`]: 'uv',
});
});

it('returns the same state from listSessions as getSession', async () => {
const appName = 'app';
const userId = 'user';
const session = await service.createSession({appName, userId});
await service.appendEvent({
session,
event: createEvent({
timestamp: 1000,
actions: createEventActions({
stateDelta: {
sessionKey: 'sv',
[`${State.APP_PREFIX}appKey`]: 'av',
[`${State.USER_PREFIX}userKey`]: 'uv',
},
}),
}),
});

const response = await service.listSessions({appName, userId});
const fetched = await service.getSession({
appName,
userId,
sessionId: session.id,
});

expect(response.sessions[0].state).toEqual(fetched?.state);
});

it('app state is visible on every listed session of the app', async () => {
const appName = 'app';
const userId = 'user';
const session1 = await service.createSession({appName, userId});
await service.createSession({appName, userId});
await service.appendEvent({
session: session1,
event: createEvent({
timestamp: 1000,
actions: createEventActions({
stateDelta: {[`${State.APP_PREFIX}appKey`]: 'av'},
}),
}),
});

const response = await service.listSessions({appName, userId});

expect(response.sessions).toHaveLength(2);
for (const listed of response.sessions) {
expect(listed.state[`${State.APP_PREFIX}appKey`]).toBe('av');
}
});

it("does not leak another user's user: state into listed sessions", async () => {
const appName = 'app';
const session1 = await service.createSession({appName, userId: 'u1'});
await service.createSession({appName, userId: 'u2'});
await service.appendEvent({
session: session1,
event: createEvent({
timestamp: 1000,
actions: createEventActions({
stateDelta: {[`${State.USER_PREFIX}userKey`]: 'uv'},
}),
}),
});

const listU1 = await service.listSessions({appName, userId: 'u1'});
const listU2 = await service.listSessions({appName, userId: 'u2'});

expect(listU1.sessions[0].state).toEqual({
[`${State.USER_PREFIX}userKey`]: 'uv',
});
expect(listU2.sessions[0].state).toEqual({});
});

it("merges each session owner's user state when userId is omitted", async () => {
const appName = 'app';
const session1 = await service.createSession({
appName,
userId: 'u1',
sessionId: 's1',
});
const session2 = await service.createSession({
appName,
userId: 'u2',
sessionId: 's2',
});
await service.appendEvent({
session: session1,
event: createEvent({
timestamp: 1000,
actions: createEventActions({
stateDelta: {[`${State.USER_PREFIX}pref`]: 'A'},
}),
}),
});
await service.appendEvent({
session: session2,
event: createEvent({
timestamp: 2000,
actions: createEventActions({
stateDelta: {[`${State.USER_PREFIX}pref`]: 'B'},
}),
}),
});

const response = await service.listSessions({appName});

const byId = new Map(response.sessions.map((s) => [s.id, s]));
expect(byId.get('s1')?.state[`${State.USER_PREFIX}pref`]).toBe('A');
expect(byId.get('s2')?.state[`${State.USER_PREFIX}pref`]).toBe('B');
});

it('listed sessions carry state but never events', async () => {
const appName = 'app';
const userId = 'user';
const session = await service.createSession({appName, userId});
await service.appendEvent({
session,
event: createEvent({
timestamp: 1000,
actions: createEventActions({
stateDelta: {[`${State.APP_PREFIX}appKey`]: 'av'},
}),
}),
});

const response = await service.listSessions({appName, userId});

expect(response.sessions[0].events).toEqual([]);
expect(response.sessions[0].state).toEqual({
[`${State.APP_PREFIX}appKey`]: 'av',
});
});

it('mutating returned state does not affect stored session state', async () => {
const appName = 'app';
const userId = 'user';
const session = await service.createSession({appName, userId});
await service.appendEvent({
session,
event: createEvent({
timestamp: 1000,
actions: createEventActions({stateDelta: {sessionKey: 'sv'}}),
}),
});

const response = await service.listSessions({appName, userId});
response.sessions[0].state['sessionKey'] = 'mutated';
response.sessions[0].state['injectedKey'] = 'injected';

const fetched = await service.getSession({
appName,
userId,
sessionId: session.id,
});

expect(fetched?.state).toEqual({sessionKey: 'sv'});
});

it('merged state is returned on the paginated path', async () => {
const appName = 'app';
const userId = 'user';
const created: Session[] = [];
for (let i = 1; i <= 3; i++) {
created.push(
await service.createSession({appName, userId, sessionId: `s${i}`}),
);
}
await service.appendEvent({
session: created[0],
event: createEvent({
timestamp: 1000,
actions: createEventActions({
stateDelta: {[`${State.APP_PREFIX}appKey`]: 'av'},
}),
}),
});

const response = await service.listSessions({
appName,
userId,
limit: 2,
order: 'asc',
});

expect(response.sessions).toHaveLength(2);
for (const listed of response.sessions) {
expect(listed.state[`${State.APP_PREFIX}appKey`]).toBe('av');
}
});

it('returns session state when no app or user state exists', async () => {
const appName = 'app';
const userId = 'user';
const session = await service.createSession({appName, userId});
await service.appendEvent({
session,
event: createEvent({
timestamp: 1000,
actions: createEventActions({stateDelta: {sessionKey: 'sv'}}),
}),
});

const response = await service.listSessions({appName, userId});

expect(response.sessions[0].state).toEqual({sessionKey: 'sv'});
});
});

describe('deleteSession', () => {
Expand Down
32 changes: 32 additions & 0 deletions core/test/sessions/vertex_ai_session_service_test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -911,6 +911,38 @@ describe('VertexAiSessionService', () => {

expect(result.sessions.map((s) => s.id)).toEqual(['s2', 's3']);
});

it('listSessions state matches getSession state for the same session', async () => {
const sessionState = {sessionKey: 'sv', [`${State.APP_PREFIX}k`]: 'av'};
mockClient.listInternal.mockResolvedValue({
sessions: [
{
name: 'projects/p/locations/l/sessions/s1',
userId: 'testUser',
sessionState,
updateTime: '2026-01-01T00:00:00Z',
},
],
});
mockClient.get.mockResolvedValue({
userId: 'testUser',
sessionState,
updateTime: '2026-01-01T00:00:00Z',
});

const listed = await service.listSessions({
appName: '12345',
userId: 'testUser',
});
const fetched = await service.getSession({
appName: '12345',
userId: 'testUser',
sessionId: 's1',
});

expect(listed.sessions[0].state).toEqual(fetched?.state);
expect(listed.sessions[0].state).toEqual(sessionState);
});
});

describe('deleteSession', () => {
Expand Down