Files
thedotmack__claude-mem/tests/server/runtime/server-session-linkage.test.ts
Alessandro Costa 1699604460 fix(server-beta): accept contentSessionId on /v1/memories, mirroring /v1/events
* fix(server-beta): accept contentSessionId on /v1/memories, mirroring /v1/events

#2634 taught the ingest path to resolve contentSessionId into a server_sessions
row, because callers writing from inside a session know the agent's own session
id, not the server_sessions row UUID. The memory write path never got the same
affordance: /v1/memories accepts only serverSessionId.

Any client writing a memory from within a session therefore lands with
server_session_id NULL, and those rows can never be grouped, scoped or compared
by session afterwards. Measured on a production deployment: 2,948 of 2,948 such
observations (100%) are unlinked, against 85.7% linked on rows produced through
the generation path.

Accept contentSessionId and resolve it the same way. An explicit serverSessionId
still wins; the lookup is best-effort and a failure stores the memory unlinked
exactly as before, so no write can start failing because of this.

Unlike /v1/events there is no idempotency concern: observations key off id and
server_session_id is a plain FK (ON DELETE SET NULL), so resolving the link later
cannot drift a key -- the drift raised in review of #2634 does not apply here.

Tests: 4 cases in the #2634 linkage suite -- explicit id wins without querying,
contentSessionId resolves, neither id stores unlinked, and a throwing lookup
never fails the write.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(server-beta): scope memory linkage by platform and thread it through the client

Addresses both review findings.

1. Platform scope was lost (greptile P1 / codex P2). resolveMemorySessionLink
   called findIdByContentSessionId without the platform scope, so when two
   sessions in the same team/project share a contentSessionId the lookup fell
   back to whichever started last -- a Cursor memory could be attached to a
   Codex session. The route now accepts platformSource and passes the scope
   through the same sessionLookupPlatformScope helper the ingest path uses, so
   an omitted field and an explicit null keep meaning different things.

2. No supported caller could send the field (codex P2). The route accepted
   contentSessionId but ServerAddObservationRequest, buildAddObservationPayload
   and the MCP observation_add args exposed only serverSessionId, so in-repo
   callers kept producing unlinked memories. All three now carry
   contentSessionId and platformSource.

Tests: two cases pinning the scope -- the platform reaches the query, and an
omitted platformSource is distinguishable from an explicit null.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Alessandro <alessandropcostabr@users.noreply.github.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-10 20:29:53 -07:00

260 lines
9.1 KiB
TypeScript

// SPDX-License-Identifier: Apache-2.0
import { describe, expect, it } from 'bun:test';
import { ServerV1PostgresRoutes } from '../../../src/server/routes/v1/ServerV1PostgresRoutes.js';
import { DisabledServerQueueManager } from '../../../src/server/runtime/types.js';
import type { CreatePostgresAgentEventInput } from '../../../src/storage/postgres/agent-events.js';
import type { PostgresPool } from '../../../src/storage/postgres/pool.js';
describe('ServerV1PostgresRoutes content session linkage', () => {
it('applies cached platform-scoped contentSessionId lookups to batch inputs', async () => {
const calls: Array<{ text: string; values?: unknown[] }> = [];
const pool = {
async query(text: string, values?: unknown[]) {
calls.push({ text, values });
const key = JSON.stringify(values);
const rowsByKey = new Map<string, Array<{ id: string }>>([
[JSON.stringify(['shared-content', 'project-1', 'team-1', true, 'cursor']), [{ id: 'cursor-session' }]],
[JSON.stringify(['shared-content', 'project-1', 'team-1', true, 'codex']), [{ id: 'codex-session' }]],
]);
return {
command: 'SELECT',
rowCount: rowsByKey.get(key)?.length ?? 0,
oid: 0,
fields: [],
rows: rowsByKey.get(key) ?? [],
};
},
} as unknown as PostgresPool;
const routes = new ServerV1PostgresRoutes({
pool,
queueManager: new DisabledServerQueueManager('unit test'),
});
const inputs = [
createInput({ platformSource: 'cursor', payload: { index: 1 } }),
createInput({ platformSource: 'cursor', payload: { index: 2 } }),
createInput({ platformSource: 'codex', payload: { index: 3 } }),
createInput({ contentSessionId: 'missing-content', platformSource: 'cursor', payload: { index: 4 } }),
];
await (routes as unknown as {
applyContentSessionLinks(
inputs: CreatePostgresAgentEventInput[],
rawBodies: unknown[],
teamId: string,
): Promise<void>;
}).applyContentSessionLinks(
inputs,
[
{ platformSource: 'Cursor' },
{ platformSource: 'cursor-cli' },
{ platformSource: 'Codex CLI' },
{ platformSource: 'cursor' },
],
'team-1',
);
expect(inputs.map(input => input.serverSessionId ?? null)).toEqual([
'cursor-session',
'cursor-session',
'codex-session',
null,
]);
expect(calls).toHaveLength(3);
expect(calls.map(call => call.values)).toEqual([
['shared-content', 'project-1', 'team-1', true, 'cursor'],
['shared-content', 'project-1', 'team-1', true, 'codex'],
['missing-content', 'project-1', 'team-1', true, 'cursor'],
]);
});
it('distinguishes omitted platformSource from explicit null for contentSessionId lookup', async () => {
const calls: Array<{ values?: unknown[] }> = [];
const pool = {
async query(_text: string, values?: unknown[]) {
calls.push({ values });
const key = JSON.stringify(values);
const rowsByKey = new Map<string, Array<{ id: string }>>([
[JSON.stringify(['shared-content', 'project-1', 'team-1', true, null]), [{ id: 'legacy-session' }]],
[JSON.stringify(['shared-content', 'project-1', 'team-1', false, null]), [{ id: 'latest-any-platform-session' }]],
]);
return {
command: 'SELECT',
rowCount: rowsByKey.get(key)?.length ?? 0,
oid: 0,
fields: [],
rows: rowsByKey.get(key) ?? [],
};
},
} as unknown as PostgresPool;
const routes = new ServerV1PostgresRoutes({
pool,
queueManager: new DisabledServerQueueManager('unit test'),
});
const inputs = [
createInput({ platformSource: null, payload: { index: 1 } }),
createInput({ platformSource: null, payload: { index: 2 } }),
];
await (routes as unknown as {
applyContentSessionLinks(
inputs: CreatePostgresAgentEventInput[],
rawBodies: unknown[],
teamId: string,
): Promise<void>;
}).applyContentSessionLinks(
inputs,
[
{ platformSource: null },
{},
],
'team-1',
);
expect(inputs.map(input => input.serverSessionId ?? null)).toEqual([
'legacy-session',
'latest-any-platform-session',
]);
expect(calls.map(call => call.values)).toEqual([
['shared-content', 'project-1', 'team-1', true, null],
['shared-content', 'project-1', 'team-1', false, null],
]);
});
// /v1/memories path (#2634 gave /v1/events this affordance; memories lacked it,
// so every in-session memory write landed with server_session_id NULL).
describe('resolveMemorySessionLink', () => {
const routesWith = (pool: unknown) =>
new ServerV1PostgresRoutes({
pool: pool as PostgresPool,
queueManager: new DisabledServerQueueManager('unit test'),
}) as unknown as {
resolveMemorySessionLink(input: {
serverSessionId: string | null;
contentSessionId: string | null;
projectId: string;
teamId: string;
platformSource?: string | null;
}): Promise<string | null>;
};
const okPool = (rows: Array<{ id: string }>) => {
const calls: unknown[][] = [];
return {
pool: {
async query(_text: string, values?: unknown[]) {
calls.push(values ?? []);
return { command: 'SELECT', rowCount: rows.length, oid: 0, fields: [], rows };
},
},
calls,
};
};
it('prefers an explicit serverSessionId and does not query', async () => {
const { pool, calls } = okPool([{ id: 'should-not-be-used' }]);
const linked = await routesWith(pool).resolveMemorySessionLink({
serverSessionId: 'explicit-session',
contentSessionId: 'content-1',
projectId: 'project-1',
teamId: 'team-1',
});
expect(linked).toBe('explicit-session');
expect(calls).toHaveLength(0);
});
it('resolves contentSessionId when serverSessionId is absent', async () => {
const { pool } = okPool([{ id: 'resolved-session' }]);
const linked = await routesWith(pool).resolveMemorySessionLink({
serverSessionId: null,
contentSessionId: 'content-1',
projectId: 'project-1',
teamId: 'team-1',
});
expect(linked).toBe('resolved-session');
});
it('stores unlinked when neither id is supplied', async () => {
const { pool, calls } = okPool([]);
const linked = await routesWith(pool).resolveMemorySessionLink({
serverSessionId: null,
contentSessionId: null,
projectId: 'project-1',
teamId: 'team-1',
});
expect(linked).toBeNull();
expect(calls).toHaveLength(0);
});
it('carries the platform scope into the lookup', async () => {
const calls: unknown[][] = [];
const pool = {
async query(_text: string, values?: unknown[]) {
calls.push(values ?? []);
return { command: 'SELECT', rowCount: 1, oid: 0, fields: [], rows: [{ id: 'scoped-session' }] };
},
};
const linked = await routesWith(pool).resolveMemorySessionLink({
serverSessionId: null,
contentSessionId: 'shared-content',
projectId: 'project-1',
teamId: 'team-1',
platformSource: 'cursor',
});
expect(linked).toBe('scoped-session');
// without the scope the lookup returns whichever session started last, so a
// memory from one platform can be attached to another platform's session
expect(calls[0]).toContain('cursor');
});
it('distinguishes an omitted platformSource from an explicit null', async () => {
const seen: unknown[][] = [];
const pool = {
async query(_text: string, values?: unknown[]) {
seen.push(values ?? []);
return { command: 'SELECT', rowCount: 0, oid: 0, fields: [], rows: [] };
},
};
const routes = routesWith(pool);
await routes.resolveMemorySessionLink({
serverSessionId: null, contentSessionId: 'c', projectId: 'p', teamId: 't',
});
await routes.resolveMemorySessionLink({
serverSessionId: null, contentSessionId: 'c', projectId: 'p', teamId: 't', platformSource: null,
});
expect(seen).toHaveLength(2);
expect(JSON.stringify(seen[0])).not.toBe(JSON.stringify(seen[1]));
});
it('never fails the write when the lookup throws', async () => {
const pool = {
async query() {
throw new Error('connection reset');
},
};
const linked = await routesWith(pool).resolveMemorySessionLink({
serverSessionId: null,
contentSessionId: 'content-1',
projectId: 'project-1',
teamId: 'team-1',
});
expect(linked).toBeNull();
});
});
});
function createInput(overrides: Partial<CreatePostgresAgentEventInput> = {}): CreatePostgresAgentEventInput {
return {
projectId: 'project-1',
teamId: 'team-1',
contentSessionId: 'shared-content',
sourceAdapter: 'api',
eventType: 'tool_use',
platformSource: null,
payload: {},
occurredAt: new Date('2026-06-29T19:00:00.000Z'),
...overrides,
};
}