diff --git a/packages/core/src/db/messages.test.ts b/packages/core/src/db/messages.test.ts index 30cff1879c..b4bcb252b3 100644 --- a/packages/core/src/db/messages.test.ts +++ b/packages/core/src/db/messages.test.ts @@ -3,6 +3,7 @@ import { createQueryResult, mockPostgresDialect } from '../test/mocks/database'; import type { MessageRow } from './messages'; const mockQuery = mock(() => Promise.resolve(createQueryResult([]))); +const mockGetDatabaseType = mock(() => 'postgresql' as const); // Mock the connection module before importing the module under test mock.module('./connection', () => ({ @@ -10,9 +11,22 @@ mock.module('./connection', () => ({ query: mockQuery, }, getDialect: () => mockPostgresDialect, + getDatabaseType: mockGetDatabaseType, })); -import { addMessage, listMessages } from './messages'; +// Mock @archon/paths to avoid lazy logger initialization issues in tests +mock.module('@archon/paths', () => ({ + createLogger: mock(() => ({ + fatal: mock(() => undefined), + error: mock(() => undefined), + warn: mock(() => undefined), + info: mock(() => undefined), + debug: mock(() => undefined), + trace: mock(() => undefined), + })), +})); + +import { addMessage, listMessages, getRecentWorkflowResultMessages } from './messages'; describe('messages', () => { beforeEach(() => { @@ -121,4 +135,76 @@ describe('messages', () => { expect(mockQuery).toHaveBeenCalledWith(expect.any(String), ['conv-456', 50]); }); }); + + describe('getRecentWorkflowResultMessages', () => { + beforeEach(() => { + mockGetDatabaseType.mockClear(); + }); + + test('uses PostgreSQL JSON extraction syntax when dbType is postgresql', async () => { + mockGetDatabaseType.mockReturnValueOnce('postgresql'); + mockQuery.mockResolvedValueOnce(createQueryResult([])); + + await getRecentWorkflowResultMessages('conv-1'); + + const sql = mockQuery.mock.calls[0]?.[0] as string; + expect(sql).toContain("metadata->>'workflowResult'"); + expect(sql).not.toContain('json_extract'); + }); + + test('uses SQLite JSON extraction syntax when dbType is sqlite', async () => { + mockGetDatabaseType.mockReturnValueOnce('sqlite'); + mockQuery.mockResolvedValueOnce(createQueryResult([])); + + await getRecentWorkflowResultMessages('conv-1'); + + const sql = mockQuery.mock.calls[0]?.[0] as string; + expect(sql).toContain("json_extract(metadata, '$.workflowResult')"); + expect(sql).not.toContain("->>'" + 'workflowResult'); + }); + + test('passes correct parameters: conversationId and limit', async () => { + mockGetDatabaseType.mockReturnValueOnce('postgresql'); + mockQuery.mockResolvedValueOnce(createQueryResult([])); + + await getRecentWorkflowResultMessages('conv-42', 5); + + expect(mockQuery).toHaveBeenCalledWith(expect.any(String), ['conv-42', 5]); + }); + + test('default limit is 3', async () => { + mockGetDatabaseType.mockReturnValueOnce('postgresql'); + mockQuery.mockResolvedValueOnce(createQueryResult([])); + + await getRecentWorkflowResultMessages('conv-1'); + + expect(mockQuery).toHaveBeenCalledWith(expect.any(String), ['conv-1', 3]); + }); + + test('returns empty array on query error (non-throwing contract)', async () => { + mockGetDatabaseType.mockReturnValueOnce('postgresql'); + mockQuery.mockRejectedValueOnce(new Error('connection refused')); + + const result = await getRecentWorkflowResultMessages('conv-1'); + + expect(result).toEqual([]); + }); + + test('returns rows from successful query', async () => { + const row: MessageRow = { + id: 'msg-1', + conversation_id: 'conv-1', + role: 'assistant', + content: 'Workflow summary here.', + metadata: '{"workflowResult":{"workflowName":"plan","runId":"run-1"}}', + created_at: '2026-01-01T00:00:00Z', + }; + mockGetDatabaseType.mockReturnValueOnce('postgresql'); + mockQuery.mockResolvedValueOnce(createQueryResult([row])); + + const result = await getRecentWorkflowResultMessages('conv-1'); + + expect(result).toEqual([row]); + }); + }); }); diff --git a/packages/core/src/db/messages.ts b/packages/core/src/db/messages.ts index 87c95fd1e3..6157b8d486 100644 --- a/packages/core/src/db/messages.ts +++ b/packages/core/src/db/messages.ts @@ -1,7 +1,7 @@ /** - * Database operations for conversation messages (Web UI history) + * Database operations for conversation messages (Web UI history and orchestrator prompt enrichment) */ -import { pool, getDialect } from './connection'; +import { pool, getDialect, getDatabaseType } from './connection'; import { createLogger } from '@archon/paths'; /** Lazy-initialized logger (deferred so test mocks can intercept createLogger) */ @@ -16,7 +16,7 @@ export interface MessageRow { conversation_id: string; role: 'user' | 'assistant'; content: string; - metadata: string; // JSON string - parsed by frontend + metadata: string; // JSON string - parsed by frontend and server-side (orchestrator prompt enrichment) created_at: string; } @@ -64,3 +64,34 @@ export async function listMessages( ); return result.rows; } + +/** + * Get recent messages with workflowResult metadata for a conversation. + * Used to inject workflow context into the orchestrator prompt. + * Non-throwing — returns empty array on error. + */ +export async function getRecentWorkflowResultMessages( + conversationId: string, + limit = 3 +): Promise { + const dbType = getDatabaseType(); + const metadataFilter = + dbType === 'postgresql' + ? "(metadata->>'workflowResult') IS NOT NULL" + : "json_extract(metadata, '$.workflowResult') IS NOT NULL"; + try { + const result = await pool.query>( + `SELECT id, content, metadata FROM remote_agent_messages + WHERE conversation_id = $1 + AND ${metadataFilter} + ORDER BY created_at DESC + LIMIT $2`, + [conversationId, limit] + ); + return result.rows as MessageRow[]; + } catch (error) { + const err = error as Error; + getLog().warn({ err, conversationId }, 'db.workflow_result_messages_query_failed'); + return []; + } +} diff --git a/packages/core/src/orchestrator/orchestrator-agent.test.ts b/packages/core/src/orchestrator/orchestrator-agent.test.ts index 70080cc01a..2836e524b2 100644 --- a/packages/core/src/orchestrator/orchestrator-agent.test.ts +++ b/packages/core/src/orchestrator/orchestrator-agent.test.ts @@ -142,6 +142,16 @@ mock.module('./orchestrator', () => ({ mock.module('./prompt-builder', () => ({ buildOrchestratorPrompt: mock(() => 'orchestrator system prompt'), buildProjectScopedPrompt: mock(() => 'project scoped system prompt'), + formatWorkflowContextSection: mock((results: unknown[]) => + results.length > 0 ? '## Recent Workflow Results\n\n...' : '' + ), +})); + +const mockGetRecentWorkflowResultMessages = mock(() => Promise.resolve([])); +mock.module('../db/messages', () => ({ + addMessage: mock(() => Promise.resolve()), + listMessages: mock(() => Promise.resolve([])), + getRecentWorkflowResultMessages: mockGetRecentWorkflowResultMessages, })); mock.module('@archon/isolation', () => ({ @@ -1407,3 +1417,76 @@ describe('discoverAllWorkflows — merge repo workflows over global', () => { expect(mockDiscoverWorkflowsWithConfig).toHaveBeenCalledTimes(2); }); }); + +// ─── handleMessage — workflow context injection ─────────────────────────────── + +describe('handleMessage — workflow context injection', () => { + beforeEach(() => { + mockGetRecentWorkflowResultMessages.mockClear(); + mockGetOrCreateConversation.mockReset(); + mockListCodebases.mockReset(); + mockDiscoverWorkflowsWithConfig.mockReset(); + mockLogger.warn.mockClear(); + + mockGetOrCreateConversation.mockImplementation(() => Promise.resolve(makeConversation())); + mockListCodebases.mockImplementation(() => Promise.resolve([])); + mockDiscoverWorkflowsWithConfig.mockImplementation(() => + Promise.resolve({ workflows: [], errors: [] }) + ); + mockGetRecentWorkflowResultMessages.mockImplementation(() => Promise.resolve([])); + }); + + test('calls getRecentWorkflowResultMessages for the conversation', async () => { + const platform = makePlatform(); + await handleMessage(platform, 'conv-1', 'What happened?'); + + expect(mockGetRecentWorkflowResultMessages).toHaveBeenCalledWith('conv-1', 3); + }); + + test('does not throw when getRecentWorkflowResultMessages returns empty array', async () => { + mockGetRecentWorkflowResultMessages.mockResolvedValueOnce([]); + const platform = makePlatform(); + + await expect(handleMessage(platform, 'conv-1', 'Hello')).resolves.toBeUndefined(); + }); + + test('handles malformed metadata JSON without throwing', async () => { + const badRow = { + id: 'msg-1', + conversation_id: 'conv-1', + role: 'assistant' as const, + content: 'Summary.', + metadata: 'not-valid-json', + created_at: '2026-01-01T00:00:00Z', + }; + mockGetRecentWorkflowResultMessages.mockResolvedValueOnce([badRow]); + const platform = makePlatform(); + + await expect( + handleMessage(platform, 'conv-1', 'What did the workflow do?') + ).resolves.toBeUndefined(); + }); + + test('handles metadata with missing workflowResult key gracefully', async () => { + const rowNoWorkflowResult = { + id: 'msg-2', + conversation_id: 'conv-1', + role: 'assistant' as const, + content: 'Summary.', + metadata: '{"someOtherKey":"value"}', + created_at: '2026-01-01T00:00:00Z', + }; + mockGetRecentWorkflowResultMessages.mockResolvedValueOnce([rowNoWorkflowResult]); + const platform = makePlatform(); + + await expect(handleMessage(platform, 'conv-1', 'Follow-up')).resolves.toBeUndefined(); + }); + + test('continues without workflow context when outer fetch throws', async () => { + mockGetRecentWorkflowResultMessages.mockRejectedValueOnce(new Error('unexpected')); + const platform = makePlatform(); + + // Non-critical path — must not block message handling + await expect(handleMessage(platform, 'conv-1', 'Hello')).resolves.toBeUndefined(); + }); +}); diff --git a/packages/core/src/orchestrator/orchestrator-agent.ts b/packages/core/src/orchestrator/orchestrator-agent.ts index 97d989f47c..3f43595487 100644 --- a/packages/core/src/orchestrator/orchestrator-agent.ts +++ b/packages/core/src/orchestrator/orchestrator-agent.ts @@ -43,7 +43,13 @@ import type { MergedConfig } from '../config/config-types'; import { generateAndSetTitle } from '../services/title-generator'; import { validateAndResolveIsolation, dispatchBackgroundWorkflow } from './orchestrator'; import { IsolationBlockedError } from '@archon/isolation'; -import { buildOrchestratorPrompt, buildProjectScopedPrompt } from './prompt-builder'; +import { + buildOrchestratorPrompt, + buildProjectScopedPrompt, + formatWorkflowContextSection, +} from './prompt-builder'; +import type { WorkflowResultContext } from './prompt-builder'; +import * as messageDb from '../db/messages'; import * as workflowDb from '../db/workflows'; import * as workflowEventDb from '../db/workflow-events'; import type { ApprovalContext } from '@archon/workflows/schemas/workflow-run'; @@ -451,7 +457,8 @@ function buildFullPrompt( message: string, issueContext: string | undefined, threadContext: string | undefined, - attachedFiles?: AttachedFile[] + attachedFiles?: AttachedFile[], + workflowContext?: string ): string { const scopedCodebase = conversation.codebase_id ? codebases.find(c => c.id === conversation.codebase_id) @@ -471,11 +478,14 @@ function buildFullPrompt( .join('\n') : ''; + const workflowContextSuffix = workflowContext ? '\n\n---\n\n' + workflowContext : ''; + if (threadContext) { return ( systemPrompt + '\n\n---\n\n## Thread Context (previous messages)\n\n' + threadContext + + workflowContextSuffix + '\n\n---\n\n## Current Request\n\n' + message + contextSuffix + @@ -483,7 +493,14 @@ function buildFullPrompt( ); } - return systemPrompt + '\n\n---\n\n## User Message\n\n' + message + contextSuffix + fileSuffix; + return ( + systemPrompt + + workflowContextSuffix + + '\n\n---\n\n## User Message\n\n' + + message + + contextSuffix + + fileSuffix + ); } // ─── Main Handler ─────────────────────────────────────────────────────────── @@ -731,6 +748,44 @@ export async function handleMessage( }); } + // Build workflow context for follow-up awareness + let workflowContext: string | undefined; + try { + const recentResultMessages = await messageDb.getRecentWorkflowResultMessages( + conversation.id, + 3 + ); + if (recentResultMessages.length > 0) { + const workflowResults: WorkflowResultContext[] = recentResultMessages.map(msg => { + let workflowName = 'unknown'; + let runId = 'unknown'; + try { + const parsed = + typeof msg.metadata === 'string' ? JSON.parse(msg.metadata) : msg.metadata; + const meta = parsed as { + workflowResult?: { workflowName?: string; runId?: string }; + }; + workflowName = meta.workflowResult?.workflowName ?? 'unknown'; + runId = meta.workflowResult?.runId ?? 'unknown'; + } catch (metaErr) { + // Malformed metadata — use defaults + getLog().warn( + { err: metaErr as Error, conversationId, messageId: msg.id }, + 'orchestrator.workflow_result_metadata_parse_failed' + ); + } + return { workflowName, runId, summary: msg.content }; + }); + workflowContext = formatWorkflowContextSection(workflowResults); + } + } catch (error) { + getLog().warn( + { err: error as Error, conversationId }, + 'orchestrator.workflow_context_fetch_failed' + ); + // Non-critical — continue without context + } + const fullPrompt = buildFullPrompt( conversation, codebases, @@ -738,7 +793,8 @@ export async function handleMessage( message, issueContext, threadContext, - attachedFiles + attachedFiles, + workflowContext ); const cwd = getArchonWorkspacesPath(); diff --git a/packages/core/src/orchestrator/prompt-builder.test.ts b/packages/core/src/orchestrator/prompt-builder.test.ts index 7a734950b1..5927857dfb 100644 --- a/packages/core/src/orchestrator/prompt-builder.test.ts +++ b/packages/core/src/orchestrator/prompt-builder.test.ts @@ -1,5 +1,5 @@ import { describe, test, expect } from 'bun:test'; -import { buildRoutingRulesWithProject } from './prompt-builder'; +import { buildRoutingRulesWithProject, formatWorkflowContextSection } from './prompt-builder'; describe('buildRoutingRulesWithProject', () => { test('routing rules include --prompt in invocation format', () => { @@ -31,3 +31,42 @@ describe('buildRoutingRulesWithProject', () => { expect(rules).toContain('NO knowledge of the conversation history'); }); }); + +describe('formatWorkflowContextSection', () => { + test('returns empty string for empty results array', () => { + expect(formatWorkflowContextSection([])).toBe(''); + }); + + test('includes section header for non-empty results', () => { + const result = formatWorkflowContextSection([ + { workflowName: 'plan', runId: 'run-1', summary: 'Created implementation plan.' }, + ]); + expect(result).toContain('## Recent Workflow Results'); + expect(result).toContain('Use this context to answer follow-up questions'); + }); + + test('formats each result with workflowName and runId', () => { + const result = formatWorkflowContextSection([ + { workflowName: 'implement', runId: 'abc-123', summary: 'Added auth module.' }, + ]); + expect(result).toContain('**implement** (run: abc-123)'); + expect(result).toContain('Added auth module.'); + }); + + test('formats multiple results sequentially', () => { + const results = [ + { workflowName: 'plan', runId: 'run-1', summary: 'Plan done.' }, + { workflowName: 'implement', runId: 'run-2', summary: 'Implement done.' }, + ]; + const result = formatWorkflowContextSection(results); + expect(result).toContain('**plan**'); + expect(result).toContain('**implement**'); + }); + + test('output does not end with trailing whitespace', () => { + const result = formatWorkflowContextSection([ + { workflowName: 'assist', runId: 'r-1', summary: 'Done.' }, + ]); + expect(result).toBe(result.trimEnd()); + }); +}); diff --git a/packages/core/src/orchestrator/prompt-builder.ts b/packages/core/src/orchestrator/prompt-builder.ts index d5f307db5b..07a3a7a709 100644 --- a/packages/core/src/orchestrator/prompt-builder.ts +++ b/packages/core/src/orchestrator/prompt-builder.ts @@ -37,6 +37,34 @@ export function formatWorkflowSection(workflows: readonly WorkflowDefinition[]): return section; } +/** WorkflowResult type for prompt context injection */ +export interface WorkflowResultContext { + workflowName: string; + runId: string; + summary: string; +} + +/** + * Format recent workflow results for injection into the orchestrator prompt. + * Returns empty string when there are no results; buildFullPrompt checks for + * a non-empty string before including the section in the prompt. + */ +export function formatWorkflowContextSection(results: readonly WorkflowResultContext[]): string { + if (results.length === 0) return ''; + + let section = '## Recent Workflow Results\n\n'; + section += + 'The following workflows recently ran in this conversation. ' + + 'Use this context to answer follow-up questions.\n\n'; + + for (const r of results) { + section += `**${r.workflowName}** (run: ${r.runId})\n`; + section += r.summary + '\n\n'; + } + + return section.trimEnd(); +} + /** * Build the routing rules section of the prompt. */