GPU_GUARD_MONOREPO/packages/backend/test/netaclaw_session.test.ts

526 lines
18 KiB
TypeScript
Raw Permalink Normal View History

2026-05-20 21:39:12 +08:00
jest.mock('uuid', () => ({
v4: jest.fn(() => 'session-id'),
}));
import { NetaClawSessionService } from '../src/modules/netaclaw/gateway/session.js';
import { NetaClawGateway } from '../src/modules/netaclaw/gateway/server.js';
import { FileSessionTreeProvider } from '../src/modules/netaclaw/session-tree/file_provider.js';
import { MySqlSessionTreeProvider } from '../src/modules/netaclaw/session-tree/mysql_provider.js';
describe('NetaClawSessionService.deleteAllSessions', () => {
let service: NetaClawSessionService;
beforeEach(() => {
service = new NetaClawSessionService();
});
it('deletes all subagent sessions, messages, and sessions without calling delete({}) when userId is omitted', async () => {
const sessionRows = [{ sessionId: 's1' }, { sessionId: 's2' }];
const sessionDeleteBuilder = {
delete: jest.fn().mockReturnThis(),
from: jest.fn().mockReturnThis(),
execute: jest.fn().mockResolvedValue(undefined),
};
service.sessionRepo = {
find: jest.fn().mockResolvedValue(sessionRows),
delete: jest.fn().mockRejectedValue(new Error('delete({}) should not be called')),
createQueryBuilder: jest.fn().mockReturnValue(sessionDeleteBuilder),
} as any;
service.messageRepo = {
delete: jest.fn().mockResolvedValue(undefined),
} as any;
service.subagentSessionRepo = {
delete: jest.fn().mockResolvedValue(undefined),
} as any;
service.agentSessionRepo = {
find: jest.fn().mockResolvedValue([]),
delete: jest.fn().mockResolvedValue(undefined),
createQueryBuilder: jest.fn().mockReturnValue({
delete: jest.fn().mockReturnThis(),
from: jest.fn().mockReturnThis(),
execute: jest.fn().mockResolvedValue(undefined),
}),
} as any;
service.agentSessionEntryRepo = {
delete: jest.fn().mockResolvedValue(undefined),
} as any;
await service.deleteAllSessions();
expect(service.sessionRepo.find).toHaveBeenCalledWith({ where: {} });
expect(service.subagentSessionRepo.delete).toHaveBeenCalledTimes(2);
expect(service.subagentSessionRepo.delete).toHaveBeenNthCalledWith(1, { sessionId: 's1' });
expect(service.subagentSessionRepo.delete).toHaveBeenNthCalledWith(2, { sessionId: 's2' });
expect(service.messageRepo.delete).toHaveBeenCalledTimes(2);
expect(service.messageRepo.delete).toHaveBeenNthCalledWith(1, { sessionId: 's1' });
expect(service.messageRepo.delete).toHaveBeenNthCalledWith(2, { sessionId: 's2' });
expect(service.agentSessionEntryRepo.delete).toHaveBeenCalledTimes(2);
expect(service.agentSessionEntryRepo.delete).toHaveBeenNthCalledWith(1, { sessionId: 's1' });
expect(service.agentSessionEntryRepo.delete).toHaveBeenNthCalledWith(2, { sessionId: 's2' });
expect(service.agentSessionRepo.delete).toHaveBeenCalledTimes(2);
expect(service.agentSessionRepo.delete).toHaveBeenNthCalledWith(1, { sessionId: 's1' });
expect(service.agentSessionRepo.delete).toHaveBeenNthCalledWith(2, { sessionId: 's2' });
expect(service.agentSessionRepo.find).toHaveBeenCalledWith();
expect(service.agentSessionRepo.createQueryBuilder).toHaveBeenCalledWith();
expect(service.sessionRepo.delete).not.toHaveBeenCalled();
expect(service.sessionRepo.createQueryBuilder).toHaveBeenCalledWith();
expect(sessionDeleteBuilder.delete).toHaveBeenCalled();
expect(sessionDeleteBuilder.from).toHaveBeenCalled();
expect(sessionDeleteBuilder.execute).toHaveBeenCalled();
});
it('keeps user-scoped deletes on repository criteria when userId is provided', async () => {
const sessionRows = [{ sessionId: 's1' }];
service.sessionRepo = {
find: jest.fn().mockResolvedValue(sessionRows),
delete: jest.fn().mockResolvedValue(undefined),
createQueryBuilder: jest.fn(),
} as any;
service.messageRepo = {
delete: jest.fn().mockResolvedValue(undefined),
} as any;
service.subagentSessionRepo = {
delete: jest.fn().mockResolvedValue(undefined),
} as any;
service.agentSessionRepo = {
find: jest.fn().mockResolvedValue([]),
delete: jest.fn().mockResolvedValue(undefined),
createQueryBuilder: jest.fn(),
} as any;
service.agentSessionEntryRepo = {
delete: jest.fn().mockResolvedValue(undefined),
} as any;
await service.deleteAllSessions('user-1');
expect(service.sessionRepo.find).toHaveBeenCalledWith({ where: { userId: 'user-1' } });
expect(service.subagentSessionRepo.delete).toHaveBeenCalledWith({ sessionId: 's1' });
expect(service.messageRepo.delete).toHaveBeenCalledWith({ sessionId: 's1' });
expect(service.agentSessionEntryRepo.delete).toHaveBeenCalledWith({ sessionId: 's1' });
expect(service.agentSessionRepo.find).toHaveBeenCalledWith({ where: { userId: 'user-1' } });
expect(service.agentSessionRepo.delete).toHaveBeenCalledWith({ userId: 'user-1' });
expect(service.sessionRepo.delete).toHaveBeenCalledWith({ userId: 'user-1' });
expect(service.sessionRepo.createQueryBuilder).not.toHaveBeenCalled();
expect(service.agentSessionRepo.createQueryBuilder).not.toHaveBeenCalled();
});
});
describe('NetaClawSessionService.deleteSession', () => {
let service: NetaClawSessionService;
beforeEach(() => {
service = new NetaClawSessionService();
service.agentSessionEntryRepo = {
delete: jest.fn().mockResolvedValue(undefined),
} as any;
service.agentSessionRepo = {
findOneBy: jest.fn().mockResolvedValue(null),
delete: jest.fn().mockResolvedValue(undefined),
} as any;
service.subagentSessionRepo = {
delete: jest.fn().mockResolvedValue(undefined),
} as any;
service.messageRepo = {
delete: jest.fn().mockResolvedValue(undefined),
} as any;
service.sessionRepo = {
delete: jest.fn().mockResolvedValue(undefined),
} as any;
});
it('deletes the active session-tree provider storage for the selected agent before legacy rows', async () => {
const provider = {
deleteSession: jest.fn().mockResolvedValue(undefined),
};
jest.spyOn(service, 'getSessionTreeProviderForAgent').mockResolvedValue(provider as any);
await service.deleteSession('session-tree-1', 7);
expect(service.getSessionTreeProviderForAgent).toHaveBeenCalledWith(7);
expect(provider.deleteSession).toHaveBeenCalledWith('session-tree-1');
expect(service.agentSessionEntryRepo.delete).toHaveBeenCalledWith({ sessionId: 'session-tree-1' });
expect(service.agentSessionRepo.delete).toHaveBeenCalledWith({ sessionId: 'session-tree-1' });
expect(service.subagentSessionRepo.delete).toHaveBeenCalledWith({ sessionId: 'session-tree-1' });
expect(service.messageRepo.delete).toHaveBeenCalledWith({ sessionId: 'session-tree-1' });
expect(service.sessionRepo.delete).toHaveBeenCalledWith({ sessionId: 'session-tree-1' });
});
it('resolves the owning agent from mysql session-tree rows before selecting the provider', async () => {
const provider = {
deleteSession: jest.fn().mockResolvedValue(undefined),
};
(service.agentSessionRepo.findOneBy as jest.Mock).mockResolvedValue({
sessionId: 'mysql-session-1',
agentId: '8',
});
jest.spyOn(service, 'getSessionTreeProviderForAgent').mockResolvedValue(provider as any);
await service.deleteSession('mysql-session-1', 7);
expect(service.agentSessionRepo.findOneBy).toHaveBeenCalledWith({ sessionId: 'mysql-session-1' });
expect(service.getSessionTreeProviderForAgent).toHaveBeenCalledWith(8);
expect(provider.deleteSession).toHaveBeenCalledWith('mysql-session-1');
});
});
describe('NetaClawSessionService history ordering', () => {
let service: NetaClawSessionService;
beforeEach(() => {
service = new NetaClawSessionService();
});
it('orders compacted history by createTime asc and id asc', async () => {
service.messageRepo = {
find: jest.fn().mockResolvedValue([]),
} as any;
await service.loadHistory('session-1');
const [args] = (service.messageRepo.find as jest.Mock).mock.calls[0];
expect(args.where.sessionId).toBe('session-1');
expect(args.where.compactedAt?._type).toBe('isNull');
expect(args.order).toEqual({ createTime: 'ASC', id: 'ASC' });
});
it('orders history-with-id and full message views by createTime asc and id asc', async () => {
service.messageRepo = {
find: jest.fn().mockResolvedValue([]),
} as any;
await service.loadHistoryWithId('session-1');
await service.getMessages('session-1', 'full');
const [firstCallArgs] = (service.messageRepo.find as jest.Mock).mock.calls[0];
const [secondCallArgs] = (service.messageRepo.find as jest.Mock).mock.calls[1];
expect(firstCallArgs.where.sessionId).toBe('session-1');
expect(firstCallArgs.where.compactedAt?._type).toBe('isNull');
expect(firstCallArgs.order).toEqual({ createTime: 'ASC', id: 'ASC' });
expect(secondCallArgs).toEqual({
where: { sessionId: 'session-1' },
order: { createTime: 'ASC', id: 'ASC' },
});
});
});
describe('NetaClawSessionService session-tree provider resolution', () => {
let service: NetaClawSessionService;
beforeEach(() => {
service = new NetaClawSessionService();
service.netaclawConfig = {
dataDir: 'C:/neta-data',
defaultModel: 'anthropic:claude-sonnet-4-20250514',
apiKeys: {},
skillsDir: './skills',
maxToolRounds: 20,
session: {
backend: 'file',
},
} as any;
service.agentRepo = {
findOneBy: jest.fn(),
} as any;
service.agentSessionRepo = {
find: jest.fn(),
findOne: jest.fn(),
findOneBy: jest.fn(),
save: jest.fn(),
delete: jest.fn(),
} as any;
service.agentSessionEntryRepo = {
find: jest.fn(),
findOne: jest.fn(),
findOneBy: jest.fn(),
save: jest.fn(),
delete: jest.fn(),
} as any;
});
it('uses the global file backend by default when the agent has no session config', async () => {
(service.agentRepo.findOneBy as jest.Mock).mockResolvedValue({
id: 7,
name: 'default',
config: {},
});
const provider = await service.getSessionTreeProviderForAgent(7);
expect(provider).toBeInstanceOf(FileSessionTreeProvider);
expect((provider as FileSessionTreeProvider).getSessionFilePath('session-1')).toBe(
'C:\\neta-data\\sessions\\c2Vzc2lvbi0x.jsonl'
);
});
it('defaults to file backend when both agent config and global session config are absent', async () => {
service.netaclawConfig = {
dataDir: 'C:/neta-data',
defaultModel: 'anthropic:claude-sonnet-4-20250514',
apiKeys: {},
skillsDir: './skills',
maxToolRounds: 20,
} as any;
(service.agentRepo.findOneBy as jest.Mock).mockResolvedValue({
id: 11,
name: 'default-no-session-config',
config: {},
});
const provider = await service.getSessionTreeProviderForAgent(11);
expect(provider).toBeInstanceOf(FileSessionTreeProvider);
expect((provider as FileSessionTreeProvider).getSessionFilePath('session-3')).toBe(
'C:\\neta-data\\sessions\\c2Vzc2lvbi0z.jsonl'
);
});
it('lets the agent override the backend to mysql', async () => {
(service.agentRepo.findOneBy as jest.Mock).mockResolvedValue({
id: 8,
name: 'mysql-agent',
config: {
session: {
backend: 'mysql',
},
},
});
const provider = await service.getSessionTreeProviderForAgent(8);
expect(provider).toBeInstanceOf(MySqlSessionTreeProvider);
});
it('lets the agent override the file root dir', async () => {
(service.agentRepo.findOneBy as jest.Mock).mockResolvedValue({
id: 9,
name: 'file-agent',
config: {
session: {
backend: 'file',
file: {
rootDir: 'D:/agent-sessions',
},
},
},
});
const provider = await service.getSessionTreeProviderForAgent(9);
expect(provider).toBeInstanceOf(FileSessionTreeProvider);
expect((provider as FileSessionTreeProvider).getSessionFilePath('session-2')).toBe(
'D:\\agent-sessions\\c2Vzc2lvbi0y.jsonl'
);
});
it('throws when mysql session backend is selected but mysql repositories are unavailable', async () => {
service.agentSessionRepo = undefined as any;
(service.agentRepo.findOneBy as jest.Mock).mockResolvedValue({
id: 10,
name: 'broken-mysql-agent',
config: {
session: {
backend: 'mysql',
},
},
});
await expect(service.getSessionTreeProviderForAgent(10)).rejects.toThrow(
'MySqlSessionTreeProvider requires both sessionRepo and entryRepo'
);
});
});
describe('NetaClawGateway compaction queue draining', () => {
it('keeps queued chats pending during inline auto-compaction when drainPending is false', async () => {
const gateway = new NetaClawGateway() as any;
gateway.logger = {
info: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
};
gateway.ctx = { emit: jest.fn() };
gateway.sessionService = {
loadHistoryWithId: jest.fn().mockResolvedValue([{ id: 1, role: 'user', content: 'hi' }]),
updateSessionMetadata: jest.fn().mockResolvedValue(undefined),
};
gateway.compactionService = {
compact: jest.fn().mockResolvedValue({
success: true,
compactedCount: 1,
keepRecentCount: 1,
summaryMessageId: 99,
summaryContent: 'summary',
tokensBefore: 10,
tokensAfter: 5,
}),
};
gateway.sessionState.set('session-1', {
pendingChat: [{ content: 'queued', agentName: 'default', agentId: 7 }],
});
const handleChatSpy = jest.spyOn(gateway, 'handleChat').mockResolvedValue(undefined);
await gateway.handleCompact(
'session-1',
'auto',
{ compactionThreshold: 70, modelConfig: {} },
false
);
expect(handleChatSpy).not.toHaveBeenCalled();
expect(gateway.sessionState.get('session-1').pendingChat).toEqual([
{ content: 'queued', agentName: 'default', agentId: 7 },
]);
});
});
describe('NetaClawGateway session-tree routing', () => {
it('routes websocket agent chat through executeSessionTreeChat instead of the legacy executeChat path', async () => {
const gateway = new NetaClawGateway() as any;
gateway.logger = {
info: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
};
gateway.ctx = { emit: jest.fn() };
gateway.sessionService = {
getOrCreateSession: jest.fn().mockResolvedValue('session-tree-1'),
updateSessionMetadata: jest.fn().mockResolvedValue(undefined),
};
gateway.agentService = {
info: jest.fn().mockResolvedValue({
id: 7,
name: 'default',
systemPrompt: 'You are the runtime.',
skills: [],
config: {},
modelConfig: {},
subagentConfig: {},
}),
};
gateway.skillLoader = {} as any;
gateway.channelService = {
resolveForAgent: jest.fn(),
};
gateway.toolResolver = {
resolve: jest.fn().mockResolvedValue({
tools: [],
toolNames: [],
toolPromptHints: {},
builtinToolNames: [],
disabledReasons: [],
}),
};
gateway.chatOrchestrator = {
executeSessionTreeChat: jest.fn().mockImplementation(async ({ runner }: any) => {
await runner({
sessionId: 'session-tree-1',
history: [],
assistantEntryId: 'entry_assistant_1',
subagents: {
sessionId: 'session-tree-1',
parentMessageId: 0,
batches: [],
onBatchStart: jest.fn(),
onUpdate: jest.fn(),
onDone: jest.fn(),
},
userMessage: 'hello tree',
});
return {
sessionId: 'session-tree-1',
finalContent: 'final answer',
content: 'final answer',
usage: { inputTokens: 12, outputTokens: 5 },
toolCallCount: 0,
assistantEntryId: 'entry_assistant_1',
subagentBatches: [],
};
}),
executeChat: jest.fn(),
};
gateway.subagentService = {} as any;
gateway.compactionService = {} as any;
await gateway.handleChat('session-tree-1', 'hello tree', 'default', 7);
expect(gateway.chatOrchestrator.executeSessionTreeChat).toHaveBeenCalledWith(
expect.objectContaining({
sessionId: 'session-tree-1',
requestedAgentName: 'default',
message: 'hello tree',
agentId: 7,
runner: expect.any(Function),
})
);
expect(gateway.chatOrchestrator.executeChat).not.toHaveBeenCalled();
});
it('does not run legacy linear-history compaction checks before session-tree websocket chat', async () => {
const gateway = new NetaClawGateway() as any;
gateway.logger = {
info: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
};
gateway.ctx = { emit: jest.fn() };
gateway.sessionService = {
getOrCreateSession: jest.fn().mockResolvedValue('session-tree-1'),
updateSessionMetadata: jest.fn().mockResolvedValue(undefined),
loadHistory: jest.fn().mockRejectedValue(new Error('legacy loadHistory should not be called')),
};
gateway.agentService = {
info: jest.fn().mockResolvedValue({
id: 7,
name: 'default',
systemPrompt: 'You are the runtime.',
skills: [],
config: {},
modelConfig: {},
subagentConfig: {},
compactionThreshold: 1,
}),
};
gateway.skillLoader = {} as any;
gateway.channelService = {
resolveForAgent: jest.fn(),
};
gateway.toolResolver = {
resolve: jest.fn().mockResolvedValue({
tools: [],
toolNames: [],
toolPromptHints: {},
builtinToolNames: [],
disabledReasons: [],
}),
};
gateway.chatOrchestrator = {
executeSessionTreeChat: jest.fn().mockResolvedValue({
sessionId: 'session-tree-1',
finalContent: 'final answer',
content: 'final answer',
usage: undefined,
toolCallCount: 0,
assistantEntryId: 'entry_assistant_1',
subagentBatches: [],
}),
executeChat: jest.fn(),
};
gateway.subagentService = {} as any;
gateway.compactionService = {} as any;
const handleCompactSpy = jest.spyOn(gateway, 'handleCompact');
await gateway.handleChat('session-tree-1', 'hello tree', 'default', 7);
expect(gateway.agentService.info).toHaveBeenCalledTimes(1);
expect(gateway.sessionService.loadHistory).not.toHaveBeenCalled();
expect(handleCompactSpy).not.toHaveBeenCalled();
expect(gateway.chatOrchestrator.executeSessionTreeChat).toHaveBeenCalled();
});
});