Governed substrate for autonomous agents: scoped identity (passports), audited actions, MCP workspace. Infra IPs and secrets redacted for public release.
1794 lines
63 KiB
JavaScript
1794 lines
63 KiB
JavaScript
/**
|
|
* ADR-093: Tests for Shared Agent Execution Services
|
|
* @see backend/services/chat/agent-execution-shared.js
|
|
*/
|
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
|
|
// Mock database functions before imports
|
|
vi.mock('../../../database/connection.js', () => ({
|
|
dbGet: vi.fn(),
|
|
dbAll: vi.fn(),
|
|
dbRun: vi.fn(),
|
|
isPostgres: vi.fn(() => false),
|
|
safeJsonParse: vi.fn((str, def) => {
|
|
if (typeof str === 'object') return str;
|
|
try { return JSON.parse(str); } catch { return def; }
|
|
}),
|
|
}));
|
|
|
|
vi.mock('../../../utils/logger.js', () => ({
|
|
apiLogger: {
|
|
debug: vi.fn(),
|
|
info: vi.fn(),
|
|
warn: vi.fn(),
|
|
error: vi.fn(),
|
|
},
|
|
}));
|
|
|
|
// Mock pipeline-config for ADR-077 ticket context tests
|
|
vi.mock('../../pipeline-config.js', () => ({
|
|
getPipelineConfig: vi.fn((spaceId) => {
|
|
if (spaceId === 11 || spaceId === undefined) {
|
|
return {
|
|
TICKETS_TABLE_ID: 1708,
|
|
STATE: {
|
|
BACKLOG: 24275,
|
|
ASSIGNED: 43436,
|
|
IN_PROGRESS: 24276,
|
|
REVIEW: 24277,
|
|
CONTROL: 43437,
|
|
REJECTED: 43438,
|
|
DONE: 24278,
|
|
},
|
|
AGENT_USERS: {
|
|
ORCHESTRATOR: 18,
|
|
DEV_RALPH: 19,
|
|
DEVELOPER: 20,
|
|
FRONTEND: 21,
|
|
ARCHITECT: 24,
|
|
},
|
|
};
|
|
}
|
|
if (spaceId === 36) {
|
|
return {
|
|
TICKETS_TABLE_ID: 3207,
|
|
STATE: {
|
|
BACKLOG: 57081,
|
|
IN_PROGRESS: 57083,
|
|
REVIEW: 57084,
|
|
DONE: 57086,
|
|
},
|
|
AGENT_USERS: {},
|
|
};
|
|
}
|
|
throw new Error(`No pipeline config for space ${spaceId}`);
|
|
}),
|
|
getRegisteredSpaceIds: vi.fn(() => [11, 36]),
|
|
}));
|
|
|
|
import {
|
|
resolveAgentProvider,
|
|
buildAgentSystemPrompt,
|
|
loadConversationHistory,
|
|
loadNewMessagesSince,
|
|
fetchBoundRowContext,
|
|
fetchAgentSkills,
|
|
detectProvider,
|
|
getHistoryLimit,
|
|
DEFAULT_MAX_HISTORY,
|
|
fetchLatestPlan,
|
|
formatPlanAsContext,
|
|
handleManagePlan,
|
|
isTicketsTable,
|
|
buildTicketContext,
|
|
buildHandoffProtocol,
|
|
buildDelegationInstructions,
|
|
} from '../agent-execution-shared.js';
|
|
import { dbGet, dbAll, dbRun } from '../../../database/connection.js';
|
|
|
|
describe('ADR-093: Shared Agent Execution Services', () => {
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
// ─── resolveAgentProvider ───────────────────────────────────
|
|
|
|
describe('resolveAgentProvider()', () => {
|
|
|
|
it('should resolve operator and API key from operator_id', async () => {
|
|
dbGet.mockResolvedValueOnce({
|
|
data: JSON.stringify({ api_key: 'sk-test-123', provider: 'openai', name: 'OpenAI' }),
|
|
});
|
|
|
|
const result = await resolveAgentProvider({ operator_id: 42, model: 'gpt-4' });
|
|
|
|
expect(result.apiKey).toBe('sk-test-123');
|
|
expect(result.provider).toBe('openai');
|
|
expect(result.model).toBe('gpt-4');
|
|
expect(result.isLocal).toBe(false);
|
|
});
|
|
|
|
it('should fallback to AI API Keys table when operator has no api_key', async () => {
|
|
// Operator row without api_key
|
|
dbGet.mockResolvedValueOnce({
|
|
data: JSON.stringify({ provider: 'anthropic', name: 'Anthropic' }),
|
|
});
|
|
// API Keys table fallback
|
|
dbGet.mockResolvedValueOnce({
|
|
data: JSON.stringify({ api_key: 'sk-ant-fallback', status: 'active' }),
|
|
});
|
|
|
|
const result = await resolveAgentProvider({ operator_id: 42, model: 'claude-sonnet-4' });
|
|
|
|
expect(result.apiKey).toBe('sk-ant-fallback');
|
|
expect(result.provider).toBe('anthropic');
|
|
});
|
|
|
|
it('should detect local providers and skip API key fallback', async () => {
|
|
dbGet.mockResolvedValueOnce({
|
|
data: JSON.stringify({ provider: 'claude-code', name: 'Claude Code CLI' }),
|
|
});
|
|
|
|
const result = await resolveAgentProvider({ operator_id: 42 });
|
|
|
|
expect(result.isLocal).toBe(true);
|
|
expect(result.provider).toBe('claude-code');
|
|
// Should NOT fall back to env var or space search
|
|
});
|
|
|
|
it('should fallback to env var when no API key found', async () => {
|
|
// No operator_id
|
|
process.env.OPENAI_API_KEY = 'sk-env-fallback';
|
|
|
|
const result = await resolveAgentProvider({ model: 'gpt-4' });
|
|
|
|
expect(result.apiKey).toBe('sk-env-fallback');
|
|
expect(result.provider).toBe('openai');
|
|
|
|
delete process.env.OPENAI_API_KEY;
|
|
});
|
|
|
|
it('should resolve model from Models table when model is numeric', async () => {
|
|
dbGet.mockResolvedValueOnce({
|
|
data: JSON.stringify({ api_key: 'sk-test', provider: 'openai' }),
|
|
});
|
|
// Model lookup
|
|
dbGet.mockResolvedValueOnce(null); // no API Keys fallback needed
|
|
// Skipped because apiKey already found, but modelRowId triggers Models lookup
|
|
|
|
const result = await resolveAgentProvider({ operator_id: 42, model: 'gpt-4-turbo' });
|
|
|
|
expect(result.model).toBe('gpt-4-turbo');
|
|
});
|
|
|
|
it('should use default model when none specified', async () => {
|
|
process.env.OPENAI_API_KEY = 'sk-test';
|
|
const result = await resolveAgentProvider({});
|
|
expect(result.model).toBe('gpt-4o');
|
|
delete process.env.OPENAI_API_KEY;
|
|
});
|
|
});
|
|
|
|
// ─── detectProvider ─────────────────────────────────────────
|
|
|
|
describe('detectProvider()', () => {
|
|
|
|
it('should detect claude-code provider', () => {
|
|
const result = detectProvider('claude-code', 'claude-sonnet-4');
|
|
expect(result.isClaudeCode).toBe(true);
|
|
expect(result.isCopilot).toBe(false);
|
|
expect(result.isAnthropic).toBe(false);
|
|
});
|
|
|
|
it('should detect copilot provider', () => {
|
|
const result = detectProvider('copilot', 'copilot-model');
|
|
expect(result.isCopilot).toBe(true);
|
|
expect(result.isClaudeCode).toBe(false);
|
|
expect(result.isAnthropic).toBe(false);
|
|
});
|
|
|
|
it('should detect anthropic by provider name', () => {
|
|
const result = detectProvider('anthropic', 'claude-sonnet-4');
|
|
expect(result.isAnthropic).toBe(true);
|
|
});
|
|
|
|
it('should detect anthropic by model name containing claude', () => {
|
|
const result = detectProvider('openai', 'claude-sonnet-4-20250514');
|
|
expect(result.isAnthropic).toBe(true);
|
|
});
|
|
|
|
it('should default to openai when no special provider', () => {
|
|
const result = detectProvider('openai', 'gpt-4-turbo');
|
|
expect(result.isClaudeCode).toBe(false);
|
|
expect(result.isCopilot).toBe(false);
|
|
expect(result.isAnthropic).toBe(false);
|
|
});
|
|
});
|
|
|
|
// ─── buildAgentSystemPrompt ─────────────────────────────────
|
|
|
|
describe('buildAgentSystemPrompt()', () => {
|
|
|
|
it('should build prompt from crm_instructions + main_instructions', async () => {
|
|
const result = await buildAgentSystemPrompt({
|
|
crm_instructions: 'CRM knowledge here',
|
|
main_instructions: 'Agent persona here',
|
|
});
|
|
|
|
expect(result).toContain('CRM knowledge here');
|
|
expect(result).toContain('Agent persona here');
|
|
expect(result).toContain('---');
|
|
});
|
|
|
|
it('should fallback to system_prompt when crm/main not set', async () => {
|
|
const result = await buildAgentSystemPrompt({
|
|
system_prompt: 'Legacy prompt',
|
|
});
|
|
|
|
expect(result).toContain('Legacy prompt');
|
|
});
|
|
|
|
it('should fallback to instructions field', async () => {
|
|
const result = await buildAgentSystemPrompt({
|
|
instructions: 'Instruction prompt',
|
|
});
|
|
|
|
expect(result).toContain('Instruction prompt');
|
|
});
|
|
|
|
it('should fallback to default when nothing set', async () => {
|
|
const result = await buildAgentSystemPrompt({});
|
|
expect(result).toContain('helpful assistant');
|
|
});
|
|
|
|
describe('api-key mode', () => {
|
|
it('should NOT inject space_id', async () => {
|
|
const result = await buildAgentSystemPrompt(
|
|
{ main_instructions: 'Test' },
|
|
{ spaceId: 11, conversationId: 42 },
|
|
'api-key'
|
|
);
|
|
|
|
expect(result).not.toContain('space_id');
|
|
expect(result).toContain('conversation_id is 42');
|
|
});
|
|
|
|
it('should NOT include bound row context', async () => {
|
|
const result = await buildAgentSystemPrompt(
|
|
{ main_instructions: 'Test' },
|
|
{
|
|
spaceId: 11,
|
|
conversationId: 42,
|
|
boundRow: { table_id: 100, row_id: 200, table_name: 'Tasks', data: { title: 'Test' } },
|
|
},
|
|
'api-key'
|
|
);
|
|
|
|
expect(result).not.toContain('Linked row');
|
|
expect(result).not.toContain('Tasks');
|
|
});
|
|
});
|
|
|
|
describe('account mode', () => {
|
|
it('should inject space_id', async () => {
|
|
const result = await buildAgentSystemPrompt(
|
|
{ main_instructions: 'Test' },
|
|
{ spaceId: 11, conversationId: 42 },
|
|
'account'
|
|
);
|
|
|
|
expect(result).toContain('space_id: 11');
|
|
expect(result).toContain('conversation_id is 42');
|
|
});
|
|
|
|
it('should include bound row context in ask mode (full data)', async () => {
|
|
const result = await buildAgentSystemPrompt(
|
|
{ main_instructions: 'Test' },
|
|
{
|
|
spaceId: 11,
|
|
conversationId: 42,
|
|
boundRow: { table_id: 100, row_id: 200, table_name: 'Tasks', data: { title: 'Test Task' } },
|
|
agentMode: 'ask',
|
|
},
|
|
'account'
|
|
);
|
|
|
|
expect(result).toContain('Linked Row');
|
|
expect(result).toContain('"Test Task"');
|
|
expect(result).toContain('table_id: 100');
|
|
});
|
|
|
|
it('should include bound row context in agent mode (reference only)', async () => {
|
|
const result = await buildAgentSystemPrompt(
|
|
{ main_instructions: 'Test' },
|
|
{
|
|
spaceId: 11,
|
|
conversationId: 42,
|
|
boundRow: { table_id: 100, row_id: 200, table_name: 'Tasks', data: { title: 'Test Task' } },
|
|
agentMode: 'agent',
|
|
},
|
|
'account'
|
|
);
|
|
|
|
expect(result).toContain('Linked row');
|
|
expect(result).toContain('table_id: 100');
|
|
expect(result).not.toContain('"Test Task"');
|
|
});
|
|
|
|
it('should include tool introspection hints', async () => {
|
|
const result = await buildAgentSystemPrompt(
|
|
{ main_instructions: 'Test' },
|
|
{ spaceId: 11, conversationId: 42 },
|
|
'account'
|
|
);
|
|
|
|
expect(result).toContain('view_conversation_steps');
|
|
});
|
|
});
|
|
|
|
it('both modes should include crm_instructions', async () => {
|
|
const agentConfig = {
|
|
crm_instructions: 'CRM knowledge',
|
|
main_instructions: 'Agent role',
|
|
};
|
|
|
|
const apiKeyResult = await buildAgentSystemPrompt(agentConfig, {}, 'api-key');
|
|
const accountResult = await buildAgentSystemPrompt(agentConfig, { spaceId: 11 }, 'account');
|
|
|
|
expect(apiKeyResult).toContain('CRM knowledge');
|
|
expect(accountResult).toContain('CRM knowledge');
|
|
expect(apiKeyResult).toContain('Agent role');
|
|
expect(accountResult).toContain('Agent role');
|
|
});
|
|
|
|
// ─── T-147809: Group Chat Awareness ──────────────────────
|
|
describe('Group Chat Awareness (T-147809)', () => {
|
|
it('injects awareness block listing human owner + agent participants', async () => {
|
|
dbAll.mockResolvedValueOnce([
|
|
{ user_id: 1, role: 'owner', user_type: 'human', name: 'GERATRON', managed_by_agent_row_id: null, agent_row_data: null },
|
|
{ user_id: 18, role: 'member', user_type: 'agent', name: 'orchestrator', managed_by_agent_row_id: 100,
|
|
agent_row_data: JSON.stringify({ slug: 'orchestrator', name: 'Orchestrator' }) },
|
|
{ user_id: 19, role: 'member', user_type: 'agent', name: 'developer-ralph', managed_by_agent_row_id: 101,
|
|
agent_row_data: JSON.stringify({ slug: 'developer-ralph', name: 'Developer Ralph' }) },
|
|
]);
|
|
|
|
const result = await buildAgentSystemPrompt(
|
|
{ main_instructions: 'Test' },
|
|
{ spaceId: 11, conversationId: 3149 },
|
|
'account'
|
|
);
|
|
|
|
expect(result).toContain('## Group Chat Awareness');
|
|
expect(result).toContain('conversation_id: 3149');
|
|
expect(result).toContain('GERATRON');
|
|
expect(result).toContain('@orchestrator (Orchestrator)');
|
|
expect(result).toContain('@developer-ralph (Developer Ralph)');
|
|
expect(result).toContain('not yours');
|
|
expect(result).toContain('stay silent rather than duplicate');
|
|
});
|
|
|
|
it('places the awareness block BEFORE the [CONTEXT] block', async () => {
|
|
dbAll.mockResolvedValueOnce([
|
|
{ user_id: 1, role: 'owner', user_type: 'human', name: 'Owner', managed_by_agent_row_id: null, agent_row_data: null },
|
|
{ user_id: 2, role: 'member', user_type: 'agent', name: 'a', managed_by_agent_row_id: 50,
|
|
agent_row_data: JSON.stringify({ slug: 'a', name: 'Agent A' }) },
|
|
]);
|
|
|
|
const result = await buildAgentSystemPrompt(
|
|
{ main_instructions: 'Test' },
|
|
{ spaceId: 11, conversationId: 42 },
|
|
'account'
|
|
);
|
|
|
|
const awarenessIdx = result.indexOf('## Group Chat Awareness');
|
|
const contextIdx = result.indexOf('[CONTEXT]');
|
|
expect(awarenessIdx).toBeGreaterThan(-1);
|
|
expect(contextIdx).toBeGreaterThan(-1);
|
|
expect(awarenessIdx).toBeLessThan(contextIdx);
|
|
});
|
|
|
|
it('omits the awareness block when conversation has no participants', async () => {
|
|
dbAll.mockResolvedValueOnce([]);
|
|
|
|
const result = await buildAgentSystemPrompt(
|
|
{ main_instructions: 'Test' },
|
|
{ spaceId: 11, conversationId: 42 },
|
|
'account'
|
|
);
|
|
|
|
expect(result).not.toContain('## Group Chat Awareness');
|
|
});
|
|
|
|
it('omits the awareness block when no conversationId is provided', async () => {
|
|
const result = await buildAgentSystemPrompt(
|
|
{ main_instructions: 'Test' },
|
|
{ spaceId: 11 },
|
|
'account'
|
|
);
|
|
|
|
expect(result).not.toContain('## Group Chat Awareness');
|
|
// The awareness query should NOT have been issued at all
|
|
expect(dbAll).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('falls back gracefully on DB error (does not break prompt assembly)', async () => {
|
|
dbAll.mockRejectedValueOnce(new Error('connection refused'));
|
|
|
|
const result = await buildAgentSystemPrompt(
|
|
{ main_instructions: 'Test' },
|
|
{ spaceId: 11, conversationId: 42 },
|
|
'account'
|
|
);
|
|
|
|
expect(result).toContain('Test');
|
|
expect(result).toContain('[CONTEXT]');
|
|
expect(result).not.toContain('## Group Chat Awareness');
|
|
});
|
|
|
|
it('also injects awareness in api-key mode when conversationId is set', async () => {
|
|
dbAll.mockResolvedValueOnce([
|
|
{ user_id: 1, role: 'owner', user_type: 'human', name: 'Owner', managed_by_agent_row_id: null, agent_row_data: null },
|
|
]);
|
|
|
|
const result = await buildAgentSystemPrompt(
|
|
{ main_instructions: 'Test' },
|
|
{ conversationId: 42 },
|
|
'api-key'
|
|
);
|
|
|
|
expect(result).toContain('## Group Chat Awareness');
|
|
expect(result.indexOf('## Group Chat Awareness')).toBeLessThan(result.indexOf('[CONTEXT]'));
|
|
});
|
|
|
|
it('renders human-only conversation with no "Other participants"', async () => {
|
|
dbAll.mockResolvedValueOnce([
|
|
{ user_id: 1, role: 'owner', user_type: 'human', name: 'Solo', managed_by_agent_row_id: null, agent_row_data: null },
|
|
]);
|
|
|
|
const result = await buildAgentSystemPrompt(
|
|
{ main_instructions: 'Test' },
|
|
{ spaceId: 11, conversationId: 42 },
|
|
'account'
|
|
);
|
|
|
|
expect(result).toContain('Human owner:** Solo');
|
|
expect(result).toContain('only the owner is in this conversation');
|
|
});
|
|
});
|
|
});
|
|
|
|
// ─── getHistoryLimit ────────────────────────────────────────
|
|
|
|
describe('getHistoryLimit()', () => {
|
|
|
|
it('should return DEFAULT_MAX_HISTORY when no config', () => {
|
|
expect(getHistoryLimit({})).toBe(DEFAULT_MAX_HISTORY);
|
|
expect(DEFAULT_MAX_HISTORY).toBe(50);
|
|
});
|
|
|
|
it('should read from agentConfig.max_history first', () => {
|
|
expect(getHistoryLimit({ max_history: 100 })).toBe(100);
|
|
});
|
|
|
|
it('should read from context_settings.max_history', () => {
|
|
expect(getHistoryLimit({
|
|
context_settings: JSON.stringify({ max_history: 75 }),
|
|
})).toBe(75);
|
|
});
|
|
|
|
it('should handle object context_settings', () => {
|
|
expect(getHistoryLimit({
|
|
context_settings: { max_history: 30 },
|
|
})).toBe(30);
|
|
});
|
|
|
|
it('should handle invalid values gracefully', () => {
|
|
expect(getHistoryLimit({ max_history: 'not-a-number' })).toBe(DEFAULT_MAX_HISTORY);
|
|
expect(getHistoryLimit({ max_history: -5 })).toBe(DEFAULT_MAX_HISTORY);
|
|
expect(getHistoryLimit({ max_history: 0 })).toBe(DEFAULT_MAX_HISTORY);
|
|
});
|
|
});
|
|
|
|
// ─── loadConversationHistory ────────────────────────────────
|
|
|
|
describe('loadConversationHistory()', () => {
|
|
|
|
it('should load messages from DB and format for AI API', async () => {
|
|
// DB returns in DESC order (newest first), function reverses to chronological
|
|
dbAll.mockResolvedValueOnce([
|
|
{ id: 3, content: 'Thanks', role: 'user', sender_id: 1, sender_name: 'Alice', content_type: 'text' },
|
|
{ id: 2, content: 'Hi there', role: 'assistant', sender_id: 2, content_type: 'text' },
|
|
{ id: 1, content: 'Hello', role: 'user', sender_id: 1, sender_name: 'Alice', content_type: 'text' },
|
|
]);
|
|
|
|
const result = await loadConversationHistory(42, { context_settings: { max_history: 50 } });
|
|
|
|
expect(result).toHaveLength(3);
|
|
expect(result[0]).toEqual({ role: 'user', content: '[Alice]: Hello' });
|
|
expect(result[1]).toEqual({ role: 'assistant', content: 'Hi there' });
|
|
expect(result[2]).toEqual({ role: 'user', content: '[Alice]: Thanks' });
|
|
});
|
|
|
|
it('should respect configurable max_history', async () => {
|
|
dbAll.mockResolvedValueOnce([]);
|
|
|
|
await loadConversationHistory(42, { max_history: 100 });
|
|
|
|
// ADR-110: LIMIT is now the last parameter (after content_type params)
|
|
const callArgs = dbAll.mock.calls[0][1];
|
|
const lastParam = callArgs[callArgs.length - 1];
|
|
expect(lastParam).toBe(100);
|
|
});
|
|
|
|
it('should use DEFAULT_MAX_HISTORY when not configured', async () => {
|
|
dbAll.mockResolvedValueOnce([]);
|
|
|
|
await loadConversationHistory(42, {});
|
|
|
|
// ADR-110: LIMIT is now the last parameter (after content_type params)
|
|
const callArgs = dbAll.mock.calls[0][1];
|
|
const lastParam = callArgs[callArgs.length - 1];
|
|
expect(lastParam).toBe(50);
|
|
});
|
|
|
|
it('should use agentUserId to determine assistant role', async () => {
|
|
dbAll.mockResolvedValueOnce([
|
|
{ id: 1, content: 'Agent response', role: 'user', sender_id: 99, sender_name: 'Bot', content_type: 'text' },
|
|
]);
|
|
|
|
const result = await loadConversationHistory(42, {}, 99);
|
|
|
|
expect(result[0].role).toBe('assistant');
|
|
});
|
|
|
|
it('should return messages in chronological order (reversed from DB DESC)', async () => {
|
|
dbAll.mockResolvedValueOnce([
|
|
{ id: 3, content: 'Third', role: 'user', sender_id: 1, content_type: 'text' },
|
|
{ id: 2, content: 'Second', role: 'user', sender_id: 1, content_type: 'text' },
|
|
{ id: 1, content: 'First', role: 'user', sender_id: 1, content_type: 'text' },
|
|
]);
|
|
|
|
const result = await loadConversationHistory(42);
|
|
|
|
// DB returns DESC order, function reverses to ASC
|
|
expect(result[0].content).toBe('First');
|
|
expect(result[2].content).toBe('Third');
|
|
});
|
|
});
|
|
|
|
// ─── loadNewMessagesSince (T-148527 WP-A) ──────────────────
|
|
|
|
describe('loadNewMessagesSince() — T-148527 WP-A', () => {
|
|
|
|
beforeEach(() => {
|
|
dbAll.mockReset();
|
|
});
|
|
|
|
it('returns rows when conversation has user messages newer than cursor', async () => {
|
|
dbAll.mockResolvedValueOnce([
|
|
{ id: 10, sender_id: 1, content: 'follow-up question', created_at: '2026-05-11T05:00:01Z', sender_name: 'GERATRON' },
|
|
{ id: 11, sender_id: 1, content: 'and also this', created_at: '2026-05-11T05:00:02Z', sender_name: 'GERATRON' },
|
|
]);
|
|
|
|
const result = await loadNewMessagesSince(42, '2026-05-11T05:00:00Z', 99);
|
|
|
|
expect(result).toHaveLength(2);
|
|
expect(result[0].content).toBe('follow-up question');
|
|
expect(result[1].sender_name).toBe('GERATRON');
|
|
// sqlite branch passes agentUserId twice (positional)
|
|
const callArgs = dbAll.mock.calls[0][1];
|
|
expect(callArgs).toContain(42);
|
|
expect(callArgs).toContain('2026-05-11T05:00:00Z');
|
|
expect(callArgs).toContain(99);
|
|
});
|
|
|
|
it('returns [] when cursor is missing', async () => {
|
|
const result = await loadNewMessagesSince(42, null, 99);
|
|
expect(result).toEqual([]);
|
|
expect(dbAll).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('returns [] when conversationId is missing', async () => {
|
|
const result = await loadNewMessagesSince(null, '2026-05-11T05:00:00Z', 99);
|
|
expect(result).toEqual([]);
|
|
expect(dbAll).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('returns [] gracefully on DB error (non-blocking)', async () => {
|
|
dbAll.mockRejectedValueOnce(new Error('connection refused'));
|
|
const result = await loadNewMessagesSince(42, '2026-05-11T05:00:00Z', 99);
|
|
expect(result).toEqual([]);
|
|
});
|
|
|
|
it('returns [] when no fresh messages match the cursor', async () => {
|
|
dbAll.mockResolvedValueOnce([]);
|
|
const result = await loadNewMessagesSince(42, '2026-05-11T05:00:00Z', null);
|
|
expect(result).toEqual([]);
|
|
});
|
|
|
|
it('works without agentUserId (returns all user-role messages)', async () => {
|
|
dbAll.mockResolvedValueOnce([
|
|
{ id: 5, sender_id: 1, content: 'hello', created_at: '2026-05-11T05:00:01Z', sender_name: 'Alice' },
|
|
]);
|
|
const result = await loadNewMessagesSince(42, '2026-05-11T05:00:00Z');
|
|
expect(result).toHaveLength(1);
|
|
});
|
|
});
|
|
|
|
// ─── fetchBoundRowContext ───────────────────────────────────
|
|
|
|
describe('fetchBoundRowContext()', () => {
|
|
|
|
beforeEach(() => {
|
|
dbGet.mockReset();
|
|
});
|
|
|
|
it('should return null when no bound row on conversation', async () => {
|
|
dbGet.mockResolvedValueOnce({ bound_table_id: null, bound_row_id: null });
|
|
|
|
const result = await fetchBoundRowContext(42);
|
|
expect(result).toBeNull();
|
|
});
|
|
|
|
it('should return bound row context when present', async () => {
|
|
dbGet
|
|
.mockResolvedValueOnce({ bound_table_id: 100, bound_row_id: 200 })
|
|
.mockResolvedValueOnce({
|
|
id: 200,
|
|
data: JSON.stringify({ title: 'Test Task', status: 'open' }),
|
|
table_name: 'Tasks',
|
|
});
|
|
|
|
const result = await fetchBoundRowContext(42);
|
|
|
|
expect(result).toEqual({
|
|
table_id: 100,
|
|
row_id: 200,
|
|
table_name: 'Tasks',
|
|
data: { title: 'Test Task', status: 'open' },
|
|
});
|
|
});
|
|
|
|
it('should return null when bound row not found in table', async () => {
|
|
dbGet
|
|
.mockResolvedValueOnce({ bound_table_id: 100, bound_row_id: 999 })
|
|
.mockResolvedValueOnce(null); // row not found
|
|
|
|
const result = await fetchBoundRowContext(42);
|
|
expect(result).toBeNull();
|
|
});
|
|
});
|
|
|
|
// ─── Integration: Dual Prompt Model ─────────────────────────
|
|
|
|
describe('Dual Prompt Model (D6/D9)', () => {
|
|
|
|
const agentConfig = {
|
|
crm_instructions: '## CRM Knowledge\nYou know how to use the CRM API.',
|
|
main_instructions: '## Agent Persona\nYou are a developer assistant.',
|
|
};
|
|
|
|
it('/ai/run (API-key mode): both instructions, no context injection', async () => {
|
|
const prompt = await buildAgentSystemPrompt(agentConfig, {
|
|
conversationId: 42,
|
|
}, 'api-key');
|
|
|
|
expect(prompt).toContain('CRM Knowledge');
|
|
expect(prompt).toContain('Agent Persona');
|
|
expect(prompt).toContain('conversation_id is 42');
|
|
expect(prompt).not.toContain('space_id');
|
|
expect(prompt).not.toContain('view_conversation_steps');
|
|
});
|
|
|
|
it('/ai/chat (Account mode): both instructions + full context', async () => {
|
|
const prompt = await buildAgentSystemPrompt(agentConfig, {
|
|
spaceId: 11,
|
|
conversationId: 42,
|
|
boundRow: { table_id: 100, row_id: 200, table_name: 'Tasks', data: { title: 'Deploy app' } },
|
|
agentMode: 'ask',
|
|
}, 'account');
|
|
|
|
expect(prompt).toContain('CRM Knowledge');
|
|
expect(prompt).toContain('Agent Persona');
|
|
expect(prompt).toContain('space_id: 11');
|
|
expect(prompt).toContain('conversation_id is 42');
|
|
expect(prompt).toContain('view_conversation_steps');
|
|
expect(prompt).toContain('Linked Row');
|
|
expect(prompt).toContain('"Deploy app"');
|
|
});
|
|
});
|
|
|
|
// ─── S05: Runtime Skill Injection ───────────────────────────
|
|
|
|
describe('S05: Runtime Skill Injection', () => {
|
|
|
|
// ── buildAgentSystemPrompt() with skills ──────────────────
|
|
|
|
describe('buildAgentSystemPrompt() with context.skills', () => {
|
|
|
|
it('should inject skill content into prompt when context.skills provided', async () => {
|
|
const result = await buildAgentSystemPrompt(
|
|
{ main_instructions: 'Agent persona' },
|
|
{
|
|
spaceId: 11,
|
|
skills: [
|
|
{ name: 'react', display_name: 'React Best Practices', skill_content: '## React\nUse hooks.' },
|
|
{ name: 'tdd', display_name: 'TDD Workflow', skill_content: '## TDD\nRed-Green-Refactor.' },
|
|
],
|
|
},
|
|
'account'
|
|
);
|
|
|
|
expect(result).toContain('React Best Practices');
|
|
expect(result).toContain('Use hooks.');
|
|
expect(result).toContain('TDD Workflow');
|
|
expect(result).toContain('Red-Green-Refactor.');
|
|
});
|
|
|
|
it('should place skills section between base prompt and context', async () => {
|
|
const result = await buildAgentSystemPrompt(
|
|
{ main_instructions: 'BASE_PROMPT' },
|
|
{
|
|
spaceId: 11,
|
|
conversationId: 99,
|
|
skills: [{ name: 'react', display_name: 'React', skill_content: 'SKILL_CONTENT' }],
|
|
},
|
|
'account'
|
|
);
|
|
|
|
const basePos = result.indexOf('BASE_PROMPT');
|
|
const skillPos = result.indexOf('SKILL_CONTENT');
|
|
const contextPos = result.indexOf('[CONTEXT]');
|
|
|
|
expect(basePos).toBeLessThan(skillPos);
|
|
expect(skillPos).toBeLessThan(contextPos);
|
|
});
|
|
|
|
it('should skip skills with empty or missing skill_content', async () => {
|
|
const result = await buildAgentSystemPrompt(
|
|
{ main_instructions: 'Test' },
|
|
{
|
|
skills: [
|
|
{ name: 'empty-skill', display_name: 'Empty', skill_content: '' },
|
|
{ name: 'null-skill', display_name: 'Null' },
|
|
{ name: 'good-skill', display_name: 'Good Skill', skill_content: 'GOOD_CONTENT' },
|
|
],
|
|
},
|
|
'account'
|
|
);
|
|
|
|
expect(result).toContain('GOOD_CONTENT');
|
|
expect(result).not.toContain('empty-skill');
|
|
expect(result).not.toContain('null-skill');
|
|
});
|
|
|
|
it('should not add skills section when context.skills is empty or absent', async () => {
|
|
const withEmpty = await buildAgentSystemPrompt(
|
|
{ main_instructions: 'Test' },
|
|
{ spaceId: 11, skills: [] },
|
|
'account'
|
|
);
|
|
const withAbsent = await buildAgentSystemPrompt(
|
|
{ main_instructions: 'Test' },
|
|
{ spaceId: 11 },
|
|
'account'
|
|
);
|
|
|
|
expect(withEmpty).not.toContain('## Injected Skills');
|
|
expect(withAbsent).not.toContain('## Injected Skills');
|
|
});
|
|
|
|
it('should inject skills in both api-key and account modes', async () => {
|
|
const skills = [{ name: 'docker', display_name: 'Docker', skill_content: 'DOCKER_CONTENT' }];
|
|
|
|
const apiKeyResult = await buildAgentSystemPrompt(
|
|
{ main_instructions: 'Test' },
|
|
{ conversationId: 1, skills },
|
|
'api-key'
|
|
);
|
|
const accountResult = await buildAgentSystemPrompt(
|
|
{ main_instructions: 'Test' },
|
|
{ spaceId: 11, skills },
|
|
'account'
|
|
);
|
|
|
|
expect(apiKeyResult).toContain('DOCKER_CONTENT');
|
|
expect(accountResult).toContain('DOCKER_CONTENT');
|
|
});
|
|
});
|
|
|
|
// ── fetchAgentSkills() ────────────────────────────────────
|
|
|
|
describe('fetchAgentSkills()', () => {
|
|
|
|
beforeEach(() => {
|
|
dbGet.mockReset();
|
|
dbAll.mockReset();
|
|
});
|
|
|
|
it('should return empty array when skills_registry table does not exist', async () => {
|
|
dbGet.mockResolvedValueOnce(null); // no skills_registry table found
|
|
|
|
const result = await fetchAgentSkills(31113, 11);
|
|
|
|
expect(result).toEqual([]);
|
|
});
|
|
|
|
it('should return published skills linked to agent via agent_ids', async () => {
|
|
// skills_registry table lookup
|
|
dbGet.mockResolvedValueOnce({ id: 500 });
|
|
|
|
// skill_installations table lookup (optional, return null = not found)
|
|
dbGet.mockResolvedValueOnce(null);
|
|
|
|
// skills query result
|
|
dbAll.mockResolvedValueOnce([
|
|
{
|
|
data: JSON.stringify({
|
|
name: 'react',
|
|
display_name: 'React Best Practices',
|
|
skill_content: '## React\nUse functional components.',
|
|
status: 'published',
|
|
agent_ids: [31113],
|
|
}),
|
|
},
|
|
]);
|
|
|
|
const result = await fetchAgentSkills(31113, 11);
|
|
|
|
expect(result).toHaveLength(1);
|
|
expect(result[0].name).toBe('react');
|
|
expect(result[0].display_name).toBe('React Best Practices');
|
|
expect(result[0].skill_content).toContain('Use functional components.');
|
|
});
|
|
|
|
it('should also return space-level skills from skill_installations', async () => {
|
|
// skills_registry table lookup
|
|
dbGet.mockResolvedValueOnce({ id: 500 });
|
|
|
|
// skill_installations table lookup
|
|
dbGet.mockResolvedValueOnce({ id: 501 });
|
|
|
|
// agent-level skills (none)
|
|
dbAll.mockResolvedValueOnce([]);
|
|
|
|
// space-level installed skills
|
|
dbAll.mockResolvedValueOnce([
|
|
{
|
|
data: JSON.stringify({
|
|
name: 'typescript',
|
|
display_name: 'TypeScript Patterns',
|
|
skill_content: '## TypeScript\nUse strict mode.',
|
|
status: 'published',
|
|
}),
|
|
},
|
|
]);
|
|
|
|
const result = await fetchAgentSkills(31113, 11);
|
|
|
|
expect(result).toHaveLength(1);
|
|
expect(result[0].name).toBe('typescript');
|
|
});
|
|
|
|
it('should deduplicate skills appearing in both agent and space levels', async () => {
|
|
dbGet.mockResolvedValueOnce({ id: 500 }); // skills_registry
|
|
dbGet.mockResolvedValueOnce({ id: 501 }); // skill_installations
|
|
|
|
// Agent-level skill: react
|
|
dbAll.mockResolvedValueOnce([
|
|
{
|
|
data: JSON.stringify({
|
|
name: 'react',
|
|
display_name: 'React',
|
|
skill_content: 'React content',
|
|
status: 'published',
|
|
}),
|
|
},
|
|
]);
|
|
|
|
// Space-level skill: react (duplicate) + new one
|
|
dbAll.mockResolvedValueOnce([
|
|
{
|
|
data: JSON.stringify({
|
|
name: 'react',
|
|
display_name: 'React',
|
|
skill_content: 'React content',
|
|
status: 'published',
|
|
}),
|
|
},
|
|
{
|
|
data: JSON.stringify({
|
|
name: 'docker',
|
|
display_name: 'Docker',
|
|
skill_content: 'Docker content',
|
|
status: 'published',
|
|
}),
|
|
},
|
|
]);
|
|
|
|
const result = await fetchAgentSkills(31113, 11);
|
|
|
|
expect(result).toHaveLength(2);
|
|
const names = result.map(s => s.name);
|
|
expect(names).toContain('react');
|
|
expect(names).toContain('docker');
|
|
});
|
|
|
|
it('should skip skills with no skill_content', async () => {
|
|
dbGet.mockResolvedValueOnce({ id: 500 }); // skills_registry
|
|
dbGet.mockResolvedValueOnce(null); // no skill_installations
|
|
|
|
dbAll.mockResolvedValueOnce([
|
|
{
|
|
data: JSON.stringify({
|
|
name: 'empty-skill',
|
|
display_name: 'Empty',
|
|
skill_content: '',
|
|
status: 'published',
|
|
}),
|
|
},
|
|
{
|
|
data: JSON.stringify({
|
|
name: 'good-skill',
|
|
display_name: 'Good',
|
|
skill_content: '## Content here',
|
|
status: 'published',
|
|
}),
|
|
},
|
|
]);
|
|
|
|
const result = await fetchAgentSkills(31113, 11);
|
|
|
|
expect(result).toHaveLength(1);
|
|
expect(result[0].name).toBe('good-skill');
|
|
});
|
|
|
|
it('should return empty array when agentId is null or undefined', async () => {
|
|
const result1 = await fetchAgentSkills(null, 11);
|
|
const result2 = await fetchAgentSkills(undefined, 11);
|
|
|
|
expect(result1).toEqual([]);
|
|
expect(result2).toEqual([]);
|
|
});
|
|
});
|
|
});
|
|
|
|
// ─── ADR-113: Planning Context ────────────────────────────────
|
|
|
|
describe('ADR-113: Planning Context', () => {
|
|
|
|
// ── formatPlanAsContext() ──────────────────────────────────
|
|
|
|
describe('formatPlanAsContext()', () => {
|
|
|
|
it('should format plan data as compact checklist', () => {
|
|
const planData = {
|
|
tasks: [
|
|
{ id: 1, title: 'Create database migration', status: 'completed', note: 'added users table' },
|
|
{ id: 2, title: 'Write API endpoint', status: 'in_progress' },
|
|
{ id: 3, title: 'Add input validation', status: 'pending' },
|
|
{ id: 4, title: 'Update API docs', status: 'blocked', note: 'waiting for schema' },
|
|
],
|
|
};
|
|
|
|
const result = formatPlanAsContext(planData);
|
|
|
|
expect(result).toContain('## Current Plan (1/4 complete)');
|
|
expect(result).toContain('- [x] 1. Create database migration (added users table)');
|
|
expect(result).toContain('- [→] 2. Write API endpoint');
|
|
expect(result).toContain('- [ ] 3. Add input validation');
|
|
expect(result).toContain('- [!] 4. Update API docs (waiting for schema)');
|
|
});
|
|
|
|
it('should return empty string when no tasks', () => {
|
|
expect(formatPlanAsContext(null)).toBe('');
|
|
expect(formatPlanAsContext({})).toBe('');
|
|
expect(formatPlanAsContext({ tasks: [] })).toBe('');
|
|
});
|
|
|
|
it('should count completed tasks correctly', () => {
|
|
const planData = {
|
|
tasks: [
|
|
{ id: 1, title: 'Task 1', status: 'completed' },
|
|
{ id: 2, title: 'Task 2', status: 'completed' },
|
|
{ id: 3, title: 'Task 3', status: 'pending' },
|
|
],
|
|
};
|
|
|
|
const result = formatPlanAsContext(planData);
|
|
expect(result).toContain('(2/3 complete)');
|
|
});
|
|
|
|
it('should handle unknown status as pending', () => {
|
|
const planData = {
|
|
tasks: [
|
|
{ id: 1, title: 'Task 1', status: 'unknown_status' },
|
|
],
|
|
};
|
|
|
|
const result = formatPlanAsContext(planData);
|
|
expect(result).toContain('- [ ] 1. Task 1');
|
|
});
|
|
});
|
|
|
|
// ── fetchLatestPlan() ─────────────────────────────────────
|
|
|
|
describe('fetchLatestPlan()', () => {
|
|
|
|
beforeEach(() => {
|
|
dbGet.mockReset();
|
|
});
|
|
|
|
it('should return parsed plan data when plan message exists', async () => {
|
|
const planContent = JSON.stringify({
|
|
tasks: [
|
|
{ id: 1, title: 'Step 1', status: 'completed' },
|
|
{ id: 2, title: 'Step 2', status: 'in_progress' },
|
|
],
|
|
});
|
|
|
|
dbGet.mockResolvedValueOnce({ content: planContent, updated_at: '2026-03-05' });
|
|
|
|
const result = await fetchLatestPlan(42);
|
|
|
|
expect(result).not.toBeNull();
|
|
expect(result.tasks).toHaveLength(2);
|
|
expect(result.tasks[0].title).toBe('Step 1');
|
|
});
|
|
|
|
it('should return null when no plan message exists', async () => {
|
|
dbGet.mockResolvedValueOnce(null);
|
|
|
|
const result = await fetchLatestPlan(42);
|
|
expect(result).toBeNull();
|
|
});
|
|
|
|
it('should return null when plan content is invalid JSON', async () => {
|
|
dbGet.mockResolvedValueOnce({ content: 'not-json', updated_at: '2026-03-05' });
|
|
|
|
const result = await fetchLatestPlan(42);
|
|
expect(result).toBeNull();
|
|
});
|
|
|
|
it('should return null gracefully on DB error', async () => {
|
|
dbGet.mockRejectedValueOnce(new Error('DB connection failed'));
|
|
|
|
const result = await fetchLatestPlan(42);
|
|
expect(result).toBeNull();
|
|
});
|
|
});
|
|
|
|
// ── buildAgentSystemPrompt() with planning ───────────────
|
|
|
|
describe('buildAgentSystemPrompt() with planning', () => {
|
|
|
|
beforeEach(() => {
|
|
dbGet.mockReset();
|
|
});
|
|
|
|
it('should inject planning instructions when planning.enabled is true', async () => {
|
|
const result = await buildAgentSystemPrompt(
|
|
{ main_instructions: 'Test', planning: { enabled: true } },
|
|
{},
|
|
'account'
|
|
);
|
|
|
|
expect(result).toContain('## Planning');
|
|
expect(result).toContain('manage_plan');
|
|
expect(result).toContain('3 or more steps');
|
|
expect(result).toContain('fewer than 3 steps');
|
|
});
|
|
|
|
it('should default the planning tool to manage_plan (native agent-loop)', async () => {
|
|
const result = await buildAgentSystemPrompt(
|
|
{ main_instructions: 'Test', planning: { enabled: true } },
|
|
{},
|
|
'account'
|
|
);
|
|
|
|
// Native loop registers manage_plan (loop.js). Default MUST stay manage_plan
|
|
// so non-claude-code agents are told to call a tool they actually have.
|
|
expect(result).toContain('`manage_plan` tool');
|
|
expect(result).not.toContain('TodoWrite');
|
|
});
|
|
|
|
it('should use TodoWrite when context.isClaudeCode is true (claude-code host)', async () => {
|
|
// Ticket #81861 / ADR-113 follow-up: claude-code CLI agents have TodoWrite,
|
|
// not manage_plan (which is bridged back to plan messages in AgentJobService).
|
|
const result = await buildAgentSystemPrompt(
|
|
{ main_instructions: 'Test', planning: { enabled: true } },
|
|
{ isClaudeCode: true },
|
|
'account'
|
|
);
|
|
|
|
expect(result).toContain('## Planning');
|
|
expect(result).toContain('`TodoWrite` tool');
|
|
expect(result).not.toContain('manage_plan');
|
|
});
|
|
|
|
it('should keep manage_plan when context.isClaudeCode is falsy (native loop default)', async () => {
|
|
const result = await buildAgentSystemPrompt(
|
|
{ main_instructions: 'Test', planning: { enabled: true } },
|
|
{ isClaudeCode: false },
|
|
'account'
|
|
);
|
|
|
|
expect(result).toContain('`manage_plan` tool');
|
|
expect(result).not.toContain('TodoWrite');
|
|
});
|
|
|
|
it('should use custom auto_plan_threshold when provided', async () => {
|
|
const result = await buildAgentSystemPrompt(
|
|
{ main_instructions: 'Test', planning: { enabled: true, auto_plan_threshold: 5 } },
|
|
{},
|
|
'account'
|
|
);
|
|
|
|
expect(result).toContain('5 or more steps');
|
|
expect(result).toContain('fewer than 5 steps');
|
|
expect(result).not.toContain('3 or more steps');
|
|
});
|
|
|
|
it('should fall back to default threshold of 3 for invalid auto_plan_threshold', async () => {
|
|
const result = await buildAgentSystemPrompt(
|
|
{ main_instructions: 'Test', planning: { enabled: true, auto_plan_threshold: -1 } },
|
|
{},
|
|
'account'
|
|
);
|
|
|
|
expect(result).toContain('3 or more steps');
|
|
});
|
|
|
|
it('should NOT inject planning instructions when planning.enabled is false', async () => {
|
|
const result = await buildAgentSystemPrompt(
|
|
{ main_instructions: 'Test', planning: { enabled: false } },
|
|
{},
|
|
'account'
|
|
);
|
|
|
|
expect(result).not.toContain('## Planning');
|
|
expect(result).not.toContain('manage_plan');
|
|
});
|
|
|
|
it('should NOT inject planning instructions when planning config is absent', async () => {
|
|
const result = await buildAgentSystemPrompt(
|
|
{ main_instructions: 'Test' },
|
|
{},
|
|
'account'
|
|
);
|
|
|
|
expect(result).not.toContain('## Planning');
|
|
});
|
|
|
|
it('should inject plan context when plan exists and inject_in_context is not false', async () => {
|
|
const planContent = JSON.stringify({
|
|
tasks: [
|
|
{ id: 1, title: 'Step 1', status: 'completed', note: 'done' },
|
|
{ id: 2, title: 'Step 2', status: 'in_progress' },
|
|
],
|
|
});
|
|
|
|
dbGet.mockResolvedValueOnce({ content: planContent, updated_at: '2026-03-05' });
|
|
|
|
const result = await buildAgentSystemPrompt(
|
|
{ main_instructions: 'Test', planning: { enabled: true, inject_in_context: true } },
|
|
{ conversationId: 42 },
|
|
'account'
|
|
);
|
|
|
|
expect(result).toContain('## Current Plan (1/2 complete)');
|
|
expect(result).toContain('[x] 1. Step 1 (done)');
|
|
expect(result).toContain('[→] 2. Step 2');
|
|
});
|
|
|
|
it('should NOT inject plan context when inject_in_context is false', async () => {
|
|
const result = await buildAgentSystemPrompt(
|
|
{ main_instructions: 'Test', planning: { enabled: true, inject_in_context: false } },
|
|
{ conversationId: 42 },
|
|
'account'
|
|
);
|
|
|
|
expect(result).not.toContain('## Current Plan');
|
|
// dbGet should not have been called for plan
|
|
expect(dbGet).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should NOT inject plan context when no conversationId', async () => {
|
|
const result = await buildAgentSystemPrompt(
|
|
{ main_instructions: 'Test', planning: { enabled: true } },
|
|
{},
|
|
'account'
|
|
);
|
|
|
|
expect(result).not.toContain('## Current Plan');
|
|
});
|
|
|
|
it('should handle missing plan gracefully (no plan message in conversation)', async () => {
|
|
dbGet.mockResolvedValueOnce(null);
|
|
|
|
const result = await buildAgentSystemPrompt(
|
|
{ main_instructions: 'Test', planning: { enabled: true } },
|
|
{ conversationId: 42 },
|
|
'account'
|
|
);
|
|
|
|
expect(result).toContain('## Planning');
|
|
expect(result).not.toContain('## Current Plan');
|
|
});
|
|
});
|
|
});
|
|
|
|
// ─── ADR-113: handleManagePlan (shared handler) ──────────────
|
|
|
|
describe('ADR-113: handleManagePlan()', () => {
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
// ── Validation ──────────────────────────────────────────────
|
|
|
|
describe('validation', () => {
|
|
|
|
it('should reject when tasks is not an array', async () => {
|
|
const result = await handleManagePlan({ tasks: 'not-array' }, 42, 'TestAgent');
|
|
expect(result).toEqual({ error: 'tasks must be a non-empty array' });
|
|
});
|
|
|
|
it('should reject when tasks is an empty array', async () => {
|
|
const result = await handleManagePlan({ tasks: [] }, 42, 'TestAgent');
|
|
expect(result).toEqual({ error: 'tasks must be a non-empty array' });
|
|
});
|
|
|
|
it('should reject when tasks is missing', async () => {
|
|
const result = await handleManagePlan({}, 42, 'TestAgent');
|
|
expect(result).toEqual({ error: 'tasks must be a non-empty array' });
|
|
});
|
|
|
|
it('should reject when tasks exceeds 20 items', async () => {
|
|
const tasks = Array.from({ length: 21 }, (_, i) => ({
|
|
id: i + 1, title: `Task ${i + 1}`, status: 'pending',
|
|
}));
|
|
const result = await handleManagePlan({ tasks }, 42, 'TestAgent');
|
|
expect(result).toEqual({ error: 'Maximum 20 tasks allowed in a plan' });
|
|
});
|
|
|
|
it('should reject when task id is not a number', async () => {
|
|
const result = await handleManagePlan({
|
|
tasks: [{ id: 'abc', title: 'Test', status: 'pending' }],
|
|
}, 42, 'TestAgent');
|
|
expect(result.error).toContain('Task id must be a number');
|
|
});
|
|
|
|
it('should reject when task title is missing', async () => {
|
|
const result = await handleManagePlan({
|
|
tasks: [{ id: 1, title: '', status: 'pending' }],
|
|
}, 42, 'TestAgent');
|
|
expect(result.error).toContain('Task title must be a non-empty string');
|
|
});
|
|
|
|
it('should reject when task status is invalid', async () => {
|
|
const result = await handleManagePlan({
|
|
tasks: [{ id: 1, title: 'Test', status: 'invalid_status' }],
|
|
}, 42, 'TestAgent');
|
|
expect(result.error).toContain('Invalid status "invalid_status"');
|
|
expect(result.error).toContain('pending');
|
|
expect(result.error).toContain('in_progress');
|
|
expect(result.error).toContain('completed');
|
|
expect(result.error).toContain('blocked');
|
|
});
|
|
});
|
|
|
|
// ── DB persistence (with conversationId) ────────────────────
|
|
|
|
describe('persistence with conversationId', () => {
|
|
|
|
it('should create a new plan message when none exists', async () => {
|
|
dbGet.mockResolvedValueOnce(null); // no existing plan
|
|
dbRun.mockResolvedValueOnce({ lastInsertRowid: 100 }); // INSERT
|
|
dbRun.mockResolvedValueOnce({}); // conversation updated_at
|
|
|
|
const result = await handleManagePlan({
|
|
tasks: [
|
|
{ id: 1, title: 'Step 1', status: 'completed' },
|
|
{ id: 2, title: 'Step 2', status: 'in_progress' },
|
|
{ id: 3, title: 'Step 3', status: 'pending' },
|
|
],
|
|
}, 42, 'TestAgent');
|
|
|
|
// Verify plan was inserted
|
|
expect(dbRun).toHaveBeenCalled();
|
|
const insertCall = dbRun.mock.calls[0];
|
|
expect(insertCall[0]).toContain('INSERT INTO messages');
|
|
expect(insertCall[0]).toContain('content_type');
|
|
|
|
// Verify content includes sanitized tasks
|
|
const contentArg = insertCall[1].find(arg =>
|
|
typeof arg === 'string' && arg.includes('"tasks"')
|
|
);
|
|
expect(contentArg).toBeDefined();
|
|
const parsed = JSON.parse(contentArg);
|
|
expect(parsed.tasks).toHaveLength(3);
|
|
expect(parsed.tasks[0].title).toBe('Step 1');
|
|
|
|
// Verify result string includes progress
|
|
expect(result).toContain('1/3 completed');
|
|
expect(result).toContain('1 in progress');
|
|
expect(result).toContain('1 pending');
|
|
});
|
|
|
|
it('should update existing plan message in-place', async () => {
|
|
dbGet.mockResolvedValueOnce({ id: 77 }); // existing plan
|
|
dbRun.mockResolvedValueOnce({}); // UPDATE
|
|
|
|
const result = await handleManagePlan({
|
|
tasks: [
|
|
{ id: 1, title: 'Step 1', status: 'completed' },
|
|
{ id: 2, title: 'Step 2', status: 'completed' },
|
|
],
|
|
}, 42, 'TestAgent');
|
|
|
|
// Verify existing plan was updated in-place
|
|
expect(dbRun).toHaveBeenCalledTimes(1);
|
|
const updateCall = dbRun.mock.calls[0];
|
|
expect(updateCall[0]).toContain('UPDATE messages');
|
|
expect(updateCall[1]).toContain(77); // existing plan id
|
|
|
|
// Verify result string
|
|
expect(result).toContain('2/2 completed');
|
|
});
|
|
|
|
it('should trim task titles and include optional note', async () => {
|
|
dbGet.mockResolvedValueOnce(null); // no existing plan
|
|
dbRun.mockResolvedValueOnce({ lastInsertRowid: 100 });
|
|
dbRun.mockResolvedValueOnce({});
|
|
|
|
await handleManagePlan({
|
|
tasks: [
|
|
{ id: 1, title: ' Spaced Title ', status: 'blocked', note: 'waiting for review' },
|
|
],
|
|
}, 42, 'TestAgent');
|
|
|
|
const insertCall = dbRun.mock.calls[0];
|
|
const contentArg = insertCall[1].find(arg =>
|
|
typeof arg === 'string' && arg.includes('"tasks"')
|
|
);
|
|
const parsed = JSON.parse(contentArg);
|
|
expect(parsed.tasks[0].title).toBe('Spaced Title');
|
|
expect(parsed.tasks[0].note).toBe('waiting for review');
|
|
});
|
|
|
|
it('should include agent name in metadata', async () => {
|
|
dbGet.mockResolvedValueOnce(null);
|
|
dbRun.mockResolvedValueOnce({ lastInsertRowid: 100 });
|
|
dbRun.mockResolvedValueOnce({});
|
|
|
|
await handleManagePlan({
|
|
tasks: [{ id: 1, title: 'Test', status: 'pending' }],
|
|
}, 42, 'MyAgent');
|
|
|
|
const insertCall = dbRun.mock.calls[0];
|
|
const metadataArg = insertCall[1].find(arg =>
|
|
typeof arg === 'string' && arg.includes('"agent"')
|
|
);
|
|
const parsed = JSON.parse(metadataArg);
|
|
expect(parsed.agent).toBe('MyAgent');
|
|
expect(parsed.tool).toBe('manage_plan');
|
|
expect(parsed.version).toBe(1);
|
|
});
|
|
});
|
|
|
|
// ── Validation-only mode (no conversationId) ────────────────
|
|
|
|
describe('validation-only mode (no conversationId)', () => {
|
|
|
|
it('should validate and return result without DB persistence', async () => {
|
|
const result = await handleManagePlan({
|
|
tasks: [
|
|
{ id: 1, title: 'Step 1', status: 'completed' },
|
|
{ id: 2, title: 'Step 2', status: 'pending' },
|
|
],
|
|
}, null, 'TestAgent');
|
|
|
|
// Should NOT call DB
|
|
expect(dbGet).not.toHaveBeenCalled();
|
|
expect(dbRun).not.toHaveBeenCalled();
|
|
|
|
// Should still return valid progress
|
|
expect(result).toContain('1/2 completed');
|
|
expect(result).toContain('1 pending');
|
|
});
|
|
});
|
|
|
|
// ── Progress summary format ─────────────────────────────────
|
|
|
|
describe('progress summary', () => {
|
|
|
|
it('should include all status counts in summary', async () => {
|
|
dbGet.mockResolvedValueOnce(null);
|
|
dbRun.mockResolvedValueOnce({ lastInsertRowid: 100 });
|
|
dbRun.mockResolvedValueOnce({});
|
|
|
|
const result = await handleManagePlan({
|
|
tasks: [
|
|
{ id: 1, title: 'T1', status: 'completed' },
|
|
{ id: 2, title: 'T2', status: 'completed' },
|
|
{ id: 3, title: 'T3', status: 'in_progress' },
|
|
{ id: 4, title: 'T4', status: 'pending' },
|
|
{ id: 5, title: 'T5', status: 'blocked' },
|
|
],
|
|
}, 42, 'TestAgent');
|
|
|
|
expect(result).toContain('Plan updated:');
|
|
expect(result).toContain('2/5 completed');
|
|
expect(result).toContain('1 in progress');
|
|
expect(result).toContain('1 pending');
|
|
expect(result).toContain('1 blocked');
|
|
});
|
|
|
|
it('should handle all tasks completed', async () => {
|
|
dbGet.mockResolvedValueOnce(null);
|
|
dbRun.mockResolvedValueOnce({ lastInsertRowid: 100 });
|
|
dbRun.mockResolvedValueOnce({});
|
|
|
|
const result = await handleManagePlan({
|
|
tasks: [
|
|
{ id: 1, title: 'T1', status: 'completed' },
|
|
{ id: 2, title: 'T2', status: 'completed' },
|
|
],
|
|
}, 42, 'TestAgent');
|
|
|
|
expect(result).toContain('2/2 completed');
|
|
});
|
|
});
|
|
|
|
// ── Error handling ──────────────────────────────────────────
|
|
|
|
describe('error handling', () => {
|
|
|
|
it('should handle null args gracefully', async () => {
|
|
const result = await handleManagePlan(null, 42, 'TestAgent');
|
|
expect(result).toEqual({ error: 'tasks must be a non-empty array' });
|
|
});
|
|
|
|
it('should handle undefined args gracefully', async () => {
|
|
const result = await handleManagePlan(undefined, 42, 'TestAgent');
|
|
expect(result).toEqual({ error: 'tasks must be a non-empty array' });
|
|
});
|
|
});
|
|
});
|
|
|
|
// ─── ADR-077 Task 5: Ticket Context & Handoff Protocol ──────
|
|
|
|
describe('ADR-077 Task 5: isTicketsTable()', () => {
|
|
|
|
it('should return isTicket=true for Space 11 Tickets table (1708)', () => {
|
|
const result = isTicketsTable(1708);
|
|
expect(result.isTicket).toBe(true);
|
|
expect(result.spaceId).toBe(11);
|
|
expect(result.config).toBeDefined();
|
|
expect(result.config.TICKETS_TABLE_ID).toBe(1708);
|
|
});
|
|
|
|
it('should return isTicket=true for Space 36 Tickets table (3207)', () => {
|
|
const result = isTicketsTable(3207);
|
|
expect(result.isTicket).toBe(true);
|
|
expect(result.spaceId).toBe(36);
|
|
});
|
|
|
|
it('should return isTicket=false for non-ticket table', () => {
|
|
const result = isTicketsTable(9999);
|
|
expect(result.isTicket).toBe(false);
|
|
expect(result.spaceId).toBeNull();
|
|
expect(result.config).toBeNull();
|
|
});
|
|
|
|
it('should return isTicket=false for null/undefined', () => {
|
|
expect(isTicketsTable(null).isTicket).toBe(false);
|
|
expect(isTicketsTable(undefined).isTicket).toBe(false);
|
|
expect(isTicketsTable(0).isTicket).toBe(false);
|
|
});
|
|
|
|
it('should handle string table_id by converting to number', () => {
|
|
// table_id might come as string from DB
|
|
const result = isTicketsTable(1708);
|
|
expect(result.isTicket).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('ADR-077 Task 5: buildTicketContext()', () => {
|
|
|
|
const mockConfig = {
|
|
TICKETS_TABLE_ID: 1708,
|
|
STATE: { BACKLOG: 24275, IN_PROGRESS: 24276, DONE: 24278 },
|
|
};
|
|
|
|
it('should build rich ticket context with all fields', () => {
|
|
const boundRow = {
|
|
table_id: 1708,
|
|
row_id: 12345,
|
|
table_name: 'Tickets',
|
|
data: {
|
|
what: 'Implement feature X',
|
|
why: 'Users need feature X for productivity',
|
|
type: 'Backend',
|
|
priority: 'High',
|
|
state: 'Backlog',
|
|
assigned_to: 'Developer Ralph',
|
|
adr_ref: 'ADR-077',
|
|
chain_id: 'chain-abc-123',
|
|
acceptance_criteria: '- [ ] AC1: Thing works\n- [ ] AC2: Tests pass',
|
|
},
|
|
};
|
|
|
|
const result = buildTicketContext(boundRow, mockConfig);
|
|
|
|
expect(result).toContain('## Linked Ticket (table_id: 1708, row_id: 12345)');
|
|
expect(result).toContain('**Title**: Implement feature X');
|
|
expect(result).toContain('**Why**: Users need feature X');
|
|
expect(result).toContain('**Type**: Backend');
|
|
expect(result).toContain('**Priority**: High');
|
|
expect(result).toContain('**State**: Backlog');
|
|
expect(result).toContain('**Assigned to**: Developer Ralph');
|
|
expect(result).toContain('**ADR Reference**: ADR-077');
|
|
expect(result).toContain('**Chain ID**: chain-abc-123');
|
|
expect(result).toContain('**Acceptance Criteria**:');
|
|
expect(result).toContain('- [ ] AC1: Thing works');
|
|
});
|
|
|
|
it('should handle minimal ticket data (only what)', () => {
|
|
const boundRow = {
|
|
table_id: 1708,
|
|
row_id: 99,
|
|
table_name: 'Tickets',
|
|
data: { what: 'Simple task' },
|
|
};
|
|
|
|
const result = buildTicketContext(boundRow, mockConfig);
|
|
|
|
expect(result).toContain('## Linked Ticket');
|
|
expect(result).toContain('**Title**: Simple task');
|
|
expect(result).not.toContain('**Why**');
|
|
expect(result).not.toContain('**Acceptance Criteria**');
|
|
});
|
|
|
|
it('should return empty string for null/undefined data', () => {
|
|
expect(buildTicketContext(null, mockConfig)).toBe('');
|
|
expect(buildTicketContext({ data: null }, mockConfig)).toBe('');
|
|
expect(buildTicketContext(undefined, mockConfig)).toBe('');
|
|
});
|
|
|
|
it('should include test_steps when present', () => {
|
|
const boundRow = {
|
|
table_id: 1708,
|
|
row_id: 100,
|
|
table_name: 'Tickets',
|
|
data: {
|
|
what: 'Test task',
|
|
test_steps: '1. Run npm test\n2. Check coverage',
|
|
},
|
|
};
|
|
|
|
const result = buildTicketContext(boundRow, mockConfig);
|
|
expect(result).toContain('**Test Steps**:');
|
|
expect(result).toContain('1. Run npm test');
|
|
});
|
|
|
|
it('should include date fields when present', () => {
|
|
const boundRow = {
|
|
table_id: 1708,
|
|
row_id: 101,
|
|
table_name: 'Tickets',
|
|
data: {
|
|
what: 'Date task',
|
|
due_date: '2026-04-01',
|
|
scheduled_date: '2026-03-15',
|
|
},
|
|
};
|
|
|
|
const result = buildTicketContext(boundRow, mockConfig);
|
|
expect(result).toContain('**Due date**: 2026-04-01');
|
|
expect(result).toContain('**Scheduled date**: 2026-03-15');
|
|
});
|
|
});
|
|
|
|
describe('ADR-077 Task 5: buildHandoffProtocol()', () => {
|
|
|
|
const mockConfig = {
|
|
STATE: {
|
|
BACKLOG: 24275,
|
|
IN_PROGRESS: 24276,
|
|
REVIEW: 24277,
|
|
DONE: 24278,
|
|
},
|
|
};
|
|
|
|
it('should return handoff protocol with state transitions', () => {
|
|
const result = buildHandoffProtocol(mockConfig);
|
|
|
|
expect(result).toContain('## Handoff Protocol');
|
|
expect(result).toContain('backlog(24275)');
|
|
expect(result).toContain('in_progress(24276)');
|
|
expect(result).toContain('review(24277)');
|
|
expect(result).toContain('done(24278)');
|
|
});
|
|
|
|
it('should include agent delegation instructions using <<@slug>> invocation syntax', () => {
|
|
const result = buildHandoffProtocol(mockConfig);
|
|
|
|
// ADR-116: delegation uses <<@slug>> invocation tokens, not plain @slug references
|
|
expect(result).toContain('<<@orchestrator>>');
|
|
expect(result).toContain('<<@architect>>');
|
|
expect(result).toContain('<<@developer-ralph>>');
|
|
expect(result).toContain('<<@frontend>>');
|
|
// Should explain the plain @slug vs <<@slug>> distinction
|
|
expect(result).toContain('plain `@slug`');
|
|
});
|
|
|
|
it('should include state update instructions', () => {
|
|
const result = buildHandoffProtocol(mockConfig);
|
|
|
|
expect(result).toContain('update_row');
|
|
expect(result).toContain('Pick up');
|
|
expect(result).toContain('Submit');
|
|
expect(result).toContain('Delegate');
|
|
});
|
|
|
|
it('should include chain context guidance', () => {
|
|
const result = buildHandoffProtocol(mockConfig);
|
|
expect(result).toContain('Chain context');
|
|
});
|
|
});
|
|
|
|
describe('ADR-116: buildDelegationInstructions()', () => {
|
|
|
|
it('should include the invocation section header', () => {
|
|
const result = buildDelegationInstructions();
|
|
expect(result).toContain('## Agent Invocation & Reference Syntax');
|
|
});
|
|
|
|
it('should document <<@slug>> as invocation trigger', () => {
|
|
const result = buildDelegationInstructions();
|
|
expect(result).toContain('<<@slug>>');
|
|
expect(result).toContain('INVOKE');
|
|
});
|
|
|
|
it('should document <</slug>> as slash invocation trigger', () => {
|
|
const result = buildDelegationInstructions();
|
|
expect(result).toContain('<</slug>>');
|
|
});
|
|
|
|
it('should document plain @slug as reference (no trigger)', () => {
|
|
const result = buildDelegationInstructions();
|
|
expect(result).toContain('@slug');
|
|
expect(result).toContain('REFERENCE');
|
|
});
|
|
|
|
it('should document plain /slug as reference (no trigger)', () => {
|
|
const result = buildDelegationInstructions();
|
|
expect(result).toContain('/slug');
|
|
});
|
|
|
|
it('should state the rule about when to use each form', () => {
|
|
const result = buildDelegationInstructions();
|
|
expect(result).toContain('Rule');
|
|
});
|
|
});
|
|
|
|
describe('ADR-077 Task 5: buildAgentSystemPrompt() with ticket context', () => {
|
|
|
|
it('should inject ticket context when bound row is a Tickets table row', async () => {
|
|
const agentConfig = {
|
|
main_instructions: 'You are a helpful developer agent.',
|
|
};
|
|
const context = {
|
|
spaceId: 11,
|
|
conversationId: 42,
|
|
boundRow: {
|
|
table_id: 1708,
|
|
row_id: 12345,
|
|
table_name: 'Tickets',
|
|
data: {
|
|
what: 'Build the widget',
|
|
why: 'User needs widgets',
|
|
state: 'Backlog',
|
|
assigned_to: 'Developer Ralph',
|
|
acceptance_criteria: '- [ ] Widget renders\n- [ ] Tests pass',
|
|
},
|
|
},
|
|
agentMode: 'agent',
|
|
};
|
|
|
|
const result = await buildAgentSystemPrompt(agentConfig, context, 'account');
|
|
|
|
// Should have ticket context
|
|
expect(result).toContain('## Linked Ticket');
|
|
expect(result).toContain('**Title**: Build the widget');
|
|
expect(result).toContain('**Acceptance Criteria**');
|
|
expect(result).toContain('- [ ] Widget renders');
|
|
|
|
// Should have handoff protocol (because agentMode = 'agent')
|
|
expect(result).toContain('## Handoff Protocol');
|
|
expect(result).toContain('@developer-ralph');
|
|
});
|
|
|
|
it('should inject ticket context but NOT handoff protocol in non-agent mode', async () => {
|
|
const agentConfig = {
|
|
main_instructions: 'You are an assistant.',
|
|
};
|
|
const context = {
|
|
spaceId: 11,
|
|
conversationId: 42,
|
|
boundRow: {
|
|
table_id: 1708,
|
|
row_id: 12345,
|
|
table_name: 'Tickets',
|
|
data: {
|
|
what: 'Build feature',
|
|
state: 'Backlog',
|
|
},
|
|
},
|
|
agentMode: 'ask',
|
|
};
|
|
|
|
const result = await buildAgentSystemPrompt(agentConfig, context, 'account');
|
|
|
|
// Ticket context should still be there
|
|
expect(result).toContain('## Linked Ticket');
|
|
expect(result).toContain('**Title**: Build feature');
|
|
|
|
// But NO handoff protocol
|
|
expect(result).not.toContain('## Handoff Protocol');
|
|
});
|
|
|
|
it('should fall back to generic bound row context for non-ticket tables', async () => {
|
|
const agentConfig = {
|
|
main_instructions: 'You are an assistant.',
|
|
};
|
|
const context = {
|
|
spaceId: 11,
|
|
conversationId: 42,
|
|
boundRow: {
|
|
table_id: 9999,
|
|
row_id: 100,
|
|
table_name: 'Documents',
|
|
data: { title: 'Some doc' },
|
|
},
|
|
agentMode: 'agent',
|
|
};
|
|
|
|
const result = await buildAgentSystemPrompt(agentConfig, context, 'account');
|
|
|
|
// Should NOT have ticket context
|
|
expect(result).not.toContain('## Linked Ticket');
|
|
expect(result).not.toContain('## Handoff Protocol');
|
|
|
|
// Should have generic bound row reference
|
|
expect(result).toContain('Linked row: table "Documents" (table_id: 9999, row_id: 100)');
|
|
});
|
|
|
|
it('should fall back to JSON bound row context for non-ticket tables in ask mode', async () => {
|
|
const agentConfig = {
|
|
main_instructions: 'You are an assistant.',
|
|
};
|
|
const context = {
|
|
spaceId: 11,
|
|
conversationId: 42,
|
|
boundRow: {
|
|
table_id: 9999,
|
|
row_id: 100,
|
|
table_name: 'Documents',
|
|
data: { title: 'Some doc' },
|
|
},
|
|
agentMode: 'ask',
|
|
};
|
|
|
|
const result = await buildAgentSystemPrompt(agentConfig, context, 'account');
|
|
|
|
// Should have JSON context
|
|
expect(result).toContain('--- Linked Row ---');
|
|
expect(result).toContain('"title": "Some doc"');
|
|
});
|
|
});
|
|
});
|