526 lines
18 KiB
TypeScript
526 lines
18 KiB
TypeScript
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();
|
|
});
|
|
});
|