godcrm/backend/services/chat/__tests__/agent-execution-shared.test.js
GOD CRM Release f89e074dd1
Some checks failed
CI / Lint / Typecheck / Test / Build (push) Has been cancelled
CI / PostgreSQL Integration Tests (push) Has been cancelled
GOD CRM — public scrubbed snapshot
Governed substrate for autonomous agents: scoped identity (passports),
audited actions, MCP workspace. Infra IPs and secrets redacted for public release.
2026-08-10 04:01:45 +03:00

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"');
});
});
});