import { describe, it, expect, vi } from 'vitest'; import { ChatService, MAX_ITERATIONS, TOOL_NAME_SEPARATOR, type ChatToolDispatcher } from '../src/services/chat.service.js'; import type { AgentService } from '../src/services/agent.service.js'; import type { LlmService } from '../src/services/llm.service.js'; import type { LlmAdapterRegistry } from '../src/services/llm/dispatcher.js'; import type { LlmAdapter, NonStreamingResult, InferContext } from '../src/services/llm/types.js'; import type { IChatRepository } from '../src/repositories/chat.repository.js'; import type { IPromptRepository } from '../src/repositories/prompt.repository.js'; import type { IPersonalityRepository } from '../src/repositories/personality.repository.js'; import type { ChatMessage, ChatThread, Prompt, Personality, PersonalityPrompt } from '@prisma/client'; const NOW = new Date(); function mockChatRepo(): IChatRepository & { _msgs: ChatMessage[]; _threads: ChatThread[] } { const msgs: ChatMessage[] = []; const threads: ChatThread[] = []; let idCounter = 1; return { _msgs: msgs, _threads: threads, createThread: vi.fn(async ({ agentId, ownerId, title }) => { const t: ChatThread = { id: `thread-${String(idCounter++)}`, agentId, ownerId, title: title ?? '', lastTurnAt: NOW, createdAt: NOW, updatedAt: NOW, }; threads.push(t); return t; }), findThread: vi.fn(async (id: string) => threads.find((t) => t.id === id) ?? null), listThreadsByAgent: vi.fn(async (agentId: string) => threads.filter((t) => t.agentId === agentId)), listMessages: vi.fn(async (threadId: string) => msgs.filter((m) => m.threadId === threadId).sort((a, b) => a.turnIndex - b.turnIndex)), appendMessage: vi.fn(async (input) => { const turnIndex = input.turnIndex ?? msgs.filter((m) => m.threadId === input.threadId).length; const m: ChatMessage = { id: `msg-${String(idCounter++)}`, threadId: input.threadId, turnIndex, role: input.role, content: input.content, toolCalls: (input.toolCalls ?? null) as ChatMessage['toolCalls'], toolCallId: input.toolCallId ?? null, status: input.status ?? 'complete', createdAt: NOW, }; msgs.push(m); return m; }), updateStatus: vi.fn(async (id: string, status) => { const m = msgs.find((x) => x.id === id); if (!m) throw new Error('not found'); m.status = status; return m; }), markPendingAsError: vi.fn(async (threadId: string) => { let n = 0; for (const m of msgs) { if (m.threadId === threadId && m.status === 'pending') { m.status = 'error'; n += 1; } } return n; }), touchThread: vi.fn(async () => undefined), nextTurnIndex: vi.fn(async (threadId: string) => msgs.filter((m) => m.threadId === threadId).length), }; } function mockPromptRepo(rows: Prompt[] = []): IPromptRepository { return { findAll: vi.fn(async () => rows), findGlobal: vi.fn(async () => rows.filter((p) => p.projectId === null && p.agentId === null)), findByAgent: vi.fn(async (agentId: string) => rows.filter((p) => p.agentId === agentId)), findById: vi.fn(async (id: string) => rows.find((p) => p.id === id) ?? null), findByNameAndProject: vi.fn(async () => null), findByNameAndAgent: vi.fn(async () => null), create: vi.fn(), update: vi.fn(), delete: vi.fn(), } as unknown as IPromptRepository; } function mockTools(impl: Partial = {}): ChatToolDispatcher { return { listTools: impl.listTools ?? vi.fn(async () => []), callTool: impl.callTool ?? vi.fn(async () => ({ ok: true })), }; } function mockAgents(opts: { defaultPersonality?: { id: string; name: string } | null } = {}): AgentService { return { getByName: vi.fn(async (name: string) => ({ id: `agent-${name}`, name, description: 'desc', systemPrompt: 'You are a helpful agent.', llm: { id: 'llm-1', name: 'qwen3-thinking' }, project: name === 'no-project' ? null : { id: 'proj-1', name: 'mcpctl-dev' }, defaultPersonality: opts.defaultPersonality ?? null, proxyModelName: null, defaultParams: { temperature: 0.5 }, extras: {}, ownerId: 'owner-1', version: 1, createdAt: NOW, updatedAt: NOW, })), } as unknown as AgentService; } function mockLlms(opts: { kind?: 'public' | 'virtual' } = {}): LlmService { return { getByName: vi.fn(async (name: string) => ({ id: 'llm-1', name, type: 'openai', model: 'qwen3-thinking', url: '', tier: 'fast', description: '', apiKeyRef: null, extraConfig: {}, kind: opts.kind ?? 'public', status: 'active', lastHeartbeatAt: null, inactiveSince: null, version: 1, createdAt: NOW, updatedAt: NOW, })), resolveApiKey: vi.fn(async () => 'fake-key'), } as unknown as LlmService; } /** Adapter that yields a scripted sequence of canned responses, one per call. */ function scriptedAdapter(responses: NonStreamingResult[]): LlmAdapter { let i = 0; return { kind: 'scripted', infer: vi.fn(async (_ctx: InferContext) => { const r = responses[i] ?? responses[responses.length - 1]; i += 1; if (r === undefined) throw new Error('no scripted response'); return r; }), stream: async function*(_ctx: InferContext) { yield { data: '[DONE]', done: true }; }, }; } function adapterRegistry(adapter: LlmAdapter): LlmAdapterRegistry { return { get: () => adapter } as unknown as LlmAdapterRegistry; } function chatCompletion(content: string): NonStreamingResult { return { status: 200, body: { id: 'cmpl-1', object: 'chat.completion', choices: [{ index: 0, message: { role: 'assistant', content }, finish_reason: 'stop' }], }, }; } function toolCall(name: string, args: Record): NonStreamingResult { return { status: 200, body: { id: 'cmpl-1', object: 'chat.completion', choices: [{ index: 0, message: { role: 'assistant', content: '', tool_calls: [{ id: `call-${name}`, type: 'function', function: { name, arguments: JSON.stringify(args) }, }], }, finish_reason: 'tool_calls', }], }, }; } describe('ChatService', () => { it('plain text turn — persists user + assistant rows and returns the reply', async () => { const chatRepo = mockChatRepo(); const adapter = scriptedAdapter([chatCompletion('hello back')]); const svc = new ChatService( mockAgents(), mockLlms(), adapterRegistry(adapter), chatRepo, mockPromptRepo(), mockTools(), ); const result = await svc.chat({ agentName: 'reviewer', userMessage: 'hi', ownerId: 'owner-1', }); expect(result.assistant).toBe('hello back'); const stored = chatRepo._msgs.filter((m) => m.threadId === result.threadId); expect(stored.map((m) => m.role)).toEqual(['user', 'assistant']); expect(stored[1]?.status).toBe('complete'); }); it('runs a full tool-use round-trip and ends with a text reply', async () => { const chatRepo = mockChatRepo(); const tools = mockTools({ listTools: vi.fn(async () => [{ name: `grafana${TOOL_NAME_SEPARATOR}query`, description: 'query grafana', parameters: { type: 'object', properties: {} }, }]), callTool: vi.fn(async () => ({ rows: [{ value: 42 }] })), }); const adapter = scriptedAdapter([ toolCall(`grafana${TOOL_NAME_SEPARATOR}query`, { q: 'cpu' }), chatCompletion('the answer is 42'), ]); const svc = new ChatService( mockAgents(), mockLlms(), adapterRegistry(adapter), chatRepo, mockPromptRepo(), tools, ); const result = await svc.chat({ agentName: 'reviewer', userMessage: 'what is cpu?', ownerId: 'owner-1', }); expect(result.assistant).toBe('the answer is 42'); expect(tools.callTool).toHaveBeenCalledWith({ projectId: 'proj-1', serverName: 'grafana', toolName: 'query', args: { q: 'cpu' }, }); const stored = chatRepo._msgs.filter((m) => m.threadId === result.threadId); expect(stored.map((m) => m.role)).toEqual(['user', 'assistant', 'tool', 'assistant']); // No `pending` rows leaked. expect(stored.every((m) => m.status === 'complete')).toBe(true); // Tool turn's toolCallId links back. const toolTurn = stored.find((m) => m.role === 'tool'); expect(toolTurn?.toolCallId).toBe(`call-grafana${TOOL_NAME_SEPARATOR}query`); }); it('caps the loop at MAX_ITERATIONS when the model never settles', async () => { const chatRepo = mockChatRepo(); const tools = mockTools({ listTools: vi.fn(async () => [{ name: `g${TOOL_NAME_SEPARATOR}t`, description: '', parameters: { type: 'object' }, }]), callTool: vi.fn(async () => ({})), }); // Always return a tool_call → the loop never reaches a terminal turn. const adapter = scriptedAdapter([toolCall(`g${TOOL_NAME_SEPARATOR}t`, {})]); const svc = new ChatService( mockAgents(), mockLlms(), adapterRegistry(adapter), chatRepo, mockPromptRepo(), tools, ); await expect(svc.chat({ agentName: 'reviewer', userMessage: 'loop forever', ownerId: 'owner-1', })).rejects.toThrow(new RegExp(`exceeded ${String(MAX_ITERATIONS)}`)); // After failure, no row should remain `pending`. expect(chatRepo._msgs.every((m) => m.status !== 'pending')).toBe(true); }); it('flips pending rows to error when the adapter throws mid-loop', async () => { const chatRepo = mockChatRepo(); const tools = mockTools({ listTools: vi.fn(async () => [{ name: `g${TOOL_NAME_SEPARATOR}t`, description: '', parameters: {}, }]), callTool: vi.fn(async () => ({})), }); const adapter: LlmAdapter = { kind: 'fail-after-one', infer: vi.fn() .mockResolvedValueOnce(toolCall(`g${TOOL_NAME_SEPARATOR}t`, {})) .mockRejectedValueOnce(new Error('upstream blew up')), stream: async function*() { yield { data: '[DONE]', done: true }; }, }; const svc = new ChatService( mockAgents(), mockLlms(), adapterRegistry(adapter), chatRepo, mockPromptRepo(), tools, ); await expect(svc.chat({ agentName: 'reviewer', userMessage: 'go', ownerId: 'owner-1', })).rejects.toThrow('upstream blew up'); expect(chatRepo._msgs.some((m) => m.status === 'error')).toBe(false); expect(chatRepo._msgs.every((m) => m.status !== 'pending')).toBe(true); }); it('merges per-call params over agent.defaultParams (override wins)', async () => { const chatRepo = mockChatRepo(); const adapter = scriptedAdapter([chatCompletion('ok')]); const inferSpy = adapter.infer as ReturnType; const svc = new ChatService( mockAgents(), mockLlms(), adapterRegistry(adapter), chatRepo, mockPromptRepo(), mockTools(), ); await svc.chat({ agentName: 'reviewer', userMessage: 'hi', ownerId: 'owner-1', params: { temperature: 0.9, max_tokens: 256 }, }); const ctx = inferSpy.mock.calls[0][0] as InferContext; expect(ctx.body.temperature).toBe(0.9); expect(ctx.body.max_tokens).toBe(256); }); it('forwards `extra` keys into the body for provider-specific knobs', async () => { const chatRepo = mockChatRepo(); const adapter = scriptedAdapter([chatCompletion('ok')]); const inferSpy = adapter.infer as ReturnType; const svc = new ChatService( mockAgents(), mockLlms(), adapterRegistry(adapter), chatRepo, mockPromptRepo(), mockTools(), ); await svc.chat({ agentName: 'reviewer', userMessage: 'hi', ownerId: 'owner-1', params: { extra: { metadata: { user_id: 'abc' }, repetition_penalty: 1.05 } }, }); const ctx = inferSpy.mock.calls[0][0] as InferContext; expect((ctx.body as Record)['repetition_penalty']).toBe(1.05); expect((ctx.body as Record)['metadata']).toEqual({ user_id: 'abc' }); }); it('builds a system block from agent.systemPrompt + project prompts (priority desc)', async () => { const chatRepo = mockChatRepo(); const adapter = scriptedAdapter([chatCompletion('ok')]); const inferSpy = adapter.infer as ReturnType; const prompts: Prompt[] = [ { id: 'p1', name: 'low', content: 'LOW prompt', projectId: 'proj-1', priority: 1, summary: null, chapters: null, linkTarget: null, version: 1, createdAt: NOW, updatedAt: NOW, }, { id: 'p2', name: 'high', content: 'HIGH prompt', projectId: 'proj-1', priority: 9, summary: null, chapters: null, linkTarget: null, version: 1, createdAt: NOW, updatedAt: NOW, }, ]; const svc = new ChatService( mockAgents(), mockLlms(), adapterRegistry(adapter), chatRepo, mockPromptRepo(prompts), mockTools(), ); await svc.chat({ agentName: 'reviewer', userMessage: 'hi', ownerId: 'owner-1' }); const ctx = inferSpy.mock.calls[0][0] as InferContext; const sys = ctx.body.messages.find((m) => m.role === 'system'); expect(typeof sys?.content).toBe('string'); const text = sys?.content as string; // High-priority prompt comes before low-priority. expect(text.indexOf('HIGH prompt')).toBeLessThan(text.indexOf('LOW prompt')); // Agent's own system prompt leads. expect(text.indexOf('You are a helpful agent.')).toBeLessThan(text.indexOf('HIGH prompt')); }); it('refuses tool calls when the agent has no project attached', async () => { const chatRepo = mockChatRepo(); const adapter = scriptedAdapter([toolCall(`x${TOOL_NAME_SEPARATOR}y`, {})]); const tools = mockTools({ listTools: vi.fn(async () => [{ name: `x${TOOL_NAME_SEPARATOR}y`, description: '', parameters: {} }]), }); const svc = new ChatService( mockAgents(), mockLlms(), adapterRegistry(adapter), chatRepo, mockPromptRepo(), tools, ); await expect(svc.chat({ agentName: 'no-project', userMessage: 'go', ownerId: 'owner-1', })).rejects.toThrow(/Project/); }); it('honours tools_allowlist (filters tools before sending to adapter)', async () => { const chatRepo = mockChatRepo(); const adapter = scriptedAdapter([chatCompletion('ok')]); const inferSpy = adapter.infer as ReturnType; const tools = mockTools({ listTools: vi.fn(async () => [ { name: `s1${TOOL_NAME_SEPARATOR}a`, description: '', parameters: {} }, { name: `s1${TOOL_NAME_SEPARATOR}b`, description: '', parameters: {} }, ]), }); const svc = new ChatService( mockAgents(), mockLlms(), adapterRegistry(adapter), chatRepo, mockPromptRepo(), tools, ); await svc.chat({ agentName: 'reviewer', userMessage: 'hi', ownerId: 'owner-1', params: { tools_allowlist: [`s1${TOOL_NAME_SEPARATOR}a`] }, }); const ctx = inferSpy.mock.calls[0][0] as InferContext; expect(ctx.body.tools).toHaveLength(1); expect(ctx.body.tools?.[0]?.function.name).toBe(`s1${TOOL_NAME_SEPARATOR}a`); }); // Regression: reasoning_content (qwen3-thinking, deepseek-reasoner, o1) // streams as `thinking` chunks, separate from `text`. // Without this, the model's 30-90s reasoning phase looks like dead air to // the REPL — caught by user feedback during the agents-feature shakedown. it('chatStream surfaces reasoning_content deltas as `thinking` chunks', async () => { const chatRepo = mockChatRepo(); // Adapter that yields a sequence of openai-format chunks: 2 reasoning // deltas, then 1 content delta, then [DONE]. const adapter: LlmAdapter = { kind: 'scripted-thinking', infer: vi.fn(), stream: async function*() { yield { data: JSON.stringify({ choices: [{ delta: { reasoning_content: 'Let me think... ' }, finish_reason: null }] }) }; yield { data: JSON.stringify({ choices: [{ delta: { reasoning_content: 'OK, ready.' }, finish_reason: null }] }) }; yield { data: JSON.stringify({ choices: [{ delta: { content: 'DONE' }, finish_reason: 'stop' }] }) }; yield { data: '[DONE]', done: true }; }, }; const svc = new ChatService( mockAgents(), mockLlms(), adapterRegistry(adapter), chatRepo, mockPromptRepo(), mockTools(), ); const chunks: Array<{ type: string; delta?: string }> = []; for await (const chunk of svc.chatStream({ agentName: 'reviewer', userMessage: 'hi', ownerId: 'owner-1', })) { chunks.push({ type: chunk.type, delta: chunk.delta }); } // Expect: 2 thinking + 1 text + 1 final expect(chunks.filter((c) => c.type === 'thinking').map((c) => c.delta)) .toEqual(['Let me think... ', 'OK, ready.']); expect(chunks.filter((c) => c.type === 'text').map((c) => c.delta)).toEqual(['DONE']); expect(chunks.find((c) => c.type === 'final')).toBeDefined(); // Reasoning is NOT persisted to the thread — only assistant content. const assistantTurn = chatRepo._msgs.find((m) => m.role === 'assistant'); expect(assistantTurn?.content).toBe('DONE'); expect(assistantTurn?.content).not.toContain('Let me think'); }); // Regression: provider_specific_fields.reasoning_content shape (LiteLLM // passthrough from vLLM) is also recognized. it('chatStream recognizes LiteLLM provider_specific_fields.reasoning_content', async () => { const chatRepo = mockChatRepo(); const adapter: LlmAdapter = { kind: 'scripted-litellm', infer: vi.fn(), stream: async function*() { yield { data: JSON.stringify({ choices: [{ delta: { provider_specific_fields: { reasoning_content: 'thinking via litellm...' } }, finish_reason: null }] }) }; yield { data: JSON.stringify({ choices: [{ delta: { content: 'ok' }, finish_reason: 'stop' }] }) }; yield { data: '[DONE]', done: true }; }, }; const svc = new ChatService( mockAgents(), mockLlms(), adapterRegistry(adapter), chatRepo, mockPromptRepo(), mockTools(), ); const chunks: Array<{ type: string; delta?: string }> = []; for await (const chunk of svc.chatStream({ agentName: 'reviewer', userMessage: 'hi', ownerId: 'owner-1', })) { chunks.push({ type: chunk.type, delta: chunk.delta }); } expect(chunks.filter((c) => c.type === 'thinking').map((c) => c.delta)) .toEqual(['thinking via litellm...']); }); // Regression: per-agent maxIterations override + clamp. // Found by /gstack-review on 2026-04-25. // Without the clamp, a hostile agent definition with `extras.maxIterations:1000000` // could spin the loop into a near-infinite tool-call burn. it('per-agent extras.maxIterations clamps below default and refuses absurd values', async () => { const chatRepo = mockChatRepo(); const tools = mockTools({ listTools: vi.fn(async () => [{ name: `g${TOOL_NAME_SEPARATOR}t`, description: '', parameters: {}, }]), callTool: vi.fn(async () => ({})), }); // Agent with maxIterations=2 — only 2 tool-call rounds allowed before bail. const agentsLowCap = { getByName: vi.fn(async () => ({ id: 'agent-low', name: 'low', description: '', systemPrompt: '', llm: { id: 'llm-1', name: 'qwen3-thinking' }, project: { id: 'proj-1', name: 'mcpctl-dev' }, proxyModelName: null, defaultParams: {}, extras: { maxIterations: 2 }, ownerId: 'owner-1', version: 1, createdAt: NOW, updatedAt: NOW, })), } as unknown as AgentService; const adapter = scriptedAdapter([toolCall(`g${TOOL_NAME_SEPARATOR}t`, {})]); const svc = new ChatService( agentsLowCap, mockLlms(), adapterRegistry(adapter), chatRepo, mockPromptRepo(), tools, ); await expect(svc.chat({ agentName: 'low', userMessage: 'spin', ownerId: 'owner-1', })).rejects.toThrow(/exceeded 2 iterations/); // Hostile agent with maxIterations=1000000 — must clamp to 50, not iterate forever. const agentsHostile = { getByName: vi.fn(async () => ({ id: 'agent-bad', name: 'bad', description: '', systemPrompt: '', llm: { id: 'llm-1', name: 'qwen3-thinking' }, project: { id: 'proj-1', name: 'mcpctl-dev' }, proxyModelName: null, defaultParams: {}, extras: { maxIterations: 1_000_000 }, ownerId: 'owner-1', version: 1, createdAt: NOW, updatedAt: NOW, })), } as unknown as AgentService; const adapter2 = scriptedAdapter([toolCall(`g${TOOL_NAME_SEPARATOR}t`, {})]); const chatRepo2 = mockChatRepo(); const svc2 = new ChatService( agentsHostile, mockLlms(), adapterRegistry(adapter2), chatRepo2, mockPromptRepo(), tools, ); await expect(svc2.chat({ agentName: 'bad', userMessage: 'spin', ownerId: 'owner-1', })).rejects.toThrow(/exceeded 50 iterations/); }); // Regression: thread message reads must enforce ownership. // Found by /gstack-review on 2026-04-25. // Without this, any caller with `view:agents` could read another user's thread // by guessing/learning the threadId (CUIDs leak through SSE chunks + tool _meta). it('listMessages refuses a thread owned by another user (404, not 403, to avoid id-enumeration)', async () => { const chatRepo = mockChatRepo(); // Pre-seed a thread owned by 'alice' await chatRepo.createThread({ agentId: 'agent-x', ownerId: 'alice' }); const aliceThread = chatRepo._threads[0]!; await chatRepo.appendMessage({ threadId: aliceThread.id, role: 'user', content: 'private to alice', }); const svc = new ChatService( mockAgents(), mockLlms(), adapterRegistry(scriptedAdapter([chatCompletion('ok')])), chatRepo, mockPromptRepo(), mockTools(), ); // Bob requests Alice's thread by id — must 404. await expect(svc.listMessages(aliceThread.id, 'bob')) .rejects.toThrow(/not found/i); // Alice gets her own messages. const aliceMessages = await svc.listMessages(aliceThread.id, 'alice'); expect(aliceMessages.map((m) => m.content)).toEqual(['private to alice']); // Genuinely missing thread — same 404 shape (no oracle leak). await expect(svc.listMessages('cnonexistent000000000000000', 'alice')) .rejects.toThrow(/not found/i); }); // ── Agent-direct prompts + personality overlay (Stage 3 system block) ── it('injects agent-direct prompts BETWEEN agent.systemPrompt and project prompts', async () => { const chatRepo = mockChatRepo(); const adapter = scriptedAdapter([chatCompletion('ok')]); const inferSpy = adapter.infer as ReturnType; const prompts: Prompt[] = [ // Project prompt { id: 'p-proj', name: 'proj', content: 'PROJECT_TEXT', projectId: 'proj-1', agentId: null, priority: 5, summary: null, chapters: null, linkTarget: null, version: 1, createdAt: NOW, updatedAt: NOW, }, // Agent-direct prompt { id: 'p-direct', name: 'direct', content: 'AGENT_DIRECT_TEXT', projectId: null, agentId: 'agent-reviewer', priority: 5, summary: null, chapters: null, linkTarget: null, version: 1, createdAt: NOW, updatedAt: NOW, }, ]; const svc = new ChatService( mockAgents(), mockLlms(), adapterRegistry(adapter), chatRepo, mockPromptRepo(prompts), mockTools(), ); await svc.chat({ agentName: 'reviewer', userMessage: 'hi', ownerId: 'owner-1' }); const sys = (inferSpy.mock.calls[0][0] as InferContext).body.messages.find((m) => m.role === 'system'); const text = sys?.content as string; expect(text.indexOf('You are a helpful agent.')).toBeLessThan(text.indexOf('AGENT_DIRECT_TEXT')); expect(text.indexOf('AGENT_DIRECT_TEXT')).toBeLessThan(text.indexOf('PROJECT_TEXT')); }); it('appends personality-bound prompts after project prompts when --personality is passed', async () => { const chatRepo = mockChatRepo(); const adapter = scriptedAdapter([chatCompletion('ok')]); const inferSpy = adapter.infer as ReturnType; const projectPrompt: Prompt = { id: 'p-proj', name: 'proj', content: 'PROJECT_TEXT', projectId: 'proj-1', agentId: null, priority: 5, summary: null, chapters: null, linkTarget: null, version: 1, createdAt: NOW, updatedAt: NOW, }; const personalityPrompt: Prompt = { id: 'p-pers', name: 'pers', content: 'PERSONALITY_TEXT', projectId: null, agentId: null, priority: 5, summary: null, chapters: null, linkTarget: null, version: 1, createdAt: NOW, updatedAt: NOW, }; const personalities = mockPersonalityRepo({ 'pers-grumpy': { personality: makePersonality({ id: 'pers-grumpy', name: 'grumpy', agentId: 'agent-reviewer' }), bindings: [{ promptId: personalityPrompt.id, priority: 5 }], }, }, [projectPrompt, personalityPrompt]); const svc = new ChatService( mockAgents(), mockLlms(), adapterRegistry(adapter), chatRepo, mockPromptRepo([projectPrompt, personalityPrompt]), mockTools(), personalities, ); await svc.chat({ agentName: 'reviewer', userMessage: 'hi', ownerId: 'owner-1', personalityName: 'grumpy', }); const sys = (inferSpy.mock.calls[0][0] as InferContext).body.messages.find((m) => m.role === 'system'); const text = sys?.content as string; expect(text.indexOf('PROJECT_TEXT')).toBeLessThan(text.indexOf('PERSONALITY_TEXT')); }); it('falls back to agent.defaultPersonality when --personality is omitted', async () => { const chatRepo = mockChatRepo(); const adapter = scriptedAdapter([chatCompletion('ok')]); const inferSpy = adapter.infer as ReturnType; const personalityPrompt: Prompt = { id: 'p-pers', name: 'pers', content: 'DEFAULT_PERSONALITY_TEXT', projectId: null, agentId: null, priority: 5, summary: null, chapters: null, linkTarget: null, version: 1, createdAt: NOW, updatedAt: NOW, }; const personalities = mockPersonalityRepo({ 'pers-default': { personality: makePersonality({ id: 'pers-default', name: 'default', agentId: 'agent-reviewer' }), bindings: [{ promptId: personalityPrompt.id, priority: 5 }], }, }, [personalityPrompt]); const svc = new ChatService( mockAgents({ defaultPersonality: { id: 'pers-default', name: 'default' } }), mockLlms(), adapterRegistry(adapter), chatRepo, mockPromptRepo([personalityPrompt]), mockTools(), personalities, ); await svc.chat({ agentName: 'reviewer', userMessage: 'hi', ownerId: 'owner-1' }); const sys = (inferSpy.mock.calls[0][0] as InferContext).body.messages.find((m) => m.role === 'system'); expect(sys?.content as string).toContain('DEFAULT_PERSONALITY_TEXT'); }); it('throws when --personality references a name the agent does not own', async () => { const chatRepo = mockChatRepo(); const adapter = scriptedAdapter([chatCompletion('ok')]); const personalities = mockPersonalityRepo({}); const svc = new ChatService( mockAgents(), mockLlms(), adapterRegistry(adapter), chatRepo, mockPromptRepo(), mockTools(), personalities, ); await expect(svc.chat({ agentName: 'reviewer', userMessage: 'hi', ownerId: 'owner-1', personalityName: 'ghost', })).rejects.toThrow(/Personality not found/); }); it('preserves today\'s system block when no personality and no agent-direct prompts exist', async () => { // Regression guard: backwards-compatible by construction. const chatRepo = mockChatRepo(); const adapter = scriptedAdapter([chatCompletion('ok')]); const inferSpy = adapter.infer as ReturnType; const projectPrompt: Prompt = { id: 'p-proj', name: 'proj', content: 'ONLY_PROJECT_TEXT', projectId: 'proj-1', agentId: null, priority: 5, summary: null, chapters: null, linkTarget: null, version: 1, createdAt: NOW, updatedAt: NOW, }; const svc = new ChatService( mockAgents(), mockLlms(), adapterRegistry(adapter), chatRepo, mockPromptRepo([projectPrompt]), mockTools(), ); await svc.chat({ agentName: 'reviewer', userMessage: 'hi', ownerId: 'owner-1' }); const sys = (inferSpy.mock.calls[0][0] as InferContext).body.messages.find((m) => m.role === 'system'); const text = sys?.content as string; expect(text).toContain('You are a helpful agent.'); expect(text).toContain('ONLY_PROJECT_TEXT'); }); }); // ── Helpers for personality-overlay tests ── function makePersonality(overrides: Partial = {}): Personality { return { id: `pers-${Math.random().toString(36).slice(2, 8)}`, name: 'p', description: '', agentId: 'agent-reviewer', priority: 5, createdAt: NOW, updatedAt: NOW, ...overrides, }; } interface MockPersonalityFixture { personality: Personality; bindings: Array<{ promptId: string; priority: number }>; } function mockPersonalityRepo( fixtures: Record, prompts: Prompt[] = [], ): IPersonalityRepository { const byId = new Map(Object.entries(fixtures)); const promptsById = new Map(prompts.map((p) => [p.id, p])); return { findAll: vi.fn(async () => [...byId.values()].map((f) => f.personality)), findByAgent: vi.fn(async (agentId: string) => [...byId.values()].filter((f) => f.personality.agentId === agentId).map((f) => f.personality)), findById: vi.fn(async (id: string) => byId.get(id)?.personality ?? null), findByNameAndAgent: vi.fn(async (name: string, agentId: string) => { for (const f of byId.values()) { if (f.personality.name === name && f.personality.agentId === agentId) { return f.personality; } } return null; }), create: vi.fn(), update: vi.fn(), delete: vi.fn(), listPrompts: vi.fn(async (personalityId: string) => { const fixture = byId.get(personalityId); if (!fixture) return []; return fixture.bindings.map((b) => ({ id: `bind-${b.promptId}`, personalityId, promptId: b.promptId, priority: b.priority, createdAt: NOW, prompt: promptsById.get(b.promptId) ?? ({ id: b.promptId, name: 'p', content: '', projectId: null, agentId: null, priority: b.priority, summary: null, chapters: null, linkTarget: null, version: 1, createdAt: NOW, updatedAt: NOW, } as Prompt), })); }), attachPrompt: vi.fn(), detachPrompt: vi.fn(), findBinding: vi.fn(async () => null), }; }