From ccc60a903d819639bc6d88f7ee6e82c2f59cda17 Mon Sep 17 00:00:00 2001 From: dimakis Date: Fri, 22 May 2026 20:15:33 +0100 Subject: [PATCH] refactor(server): fetch boot context from ContexGin HTTP API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the inline `import('contexgin')` library call with an HTTP fetch to the running ContexGin server (`GET /api/agents/:name/context`). The old approach imported contexgin as a library, read the agent recipe from the worktree (which could be stale or missing), used the legacy `compile()` pipeline, and fell back to a hardcoded 8k token budget. The running server already compiles correctly with the recipe's 12k budget — this change makes Mitzo use it. - Extract `fetchBootContext()` as a testable, exported function - Use `CONTEXGIN_URL` env var (already configured in .env) - 5s timeout with graceful local-fallback on any error - Remove ~140 lines of library import, recipe parsing, and response mapping code - Add 7 focused tests covering success, failure, and edge cases Co-Authored-By: Claude Opus 4.6 --- server/__tests__/boot-context.test.ts | 186 +++++++++++++++++++++ server/chat.ts | 226 ++++++++++---------------- 2 files changed, 271 insertions(+), 141 deletions(-) create mode 100644 server/__tests__/boot-context.test.ts diff --git a/server/__tests__/boot-context.test.ts b/server/__tests__/boot-context.test.ts new file mode 100644 index 00000000..048470cf --- /dev/null +++ b/server/__tests__/boot-context.test.ts @@ -0,0 +1,186 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import type { BootContextMessage } from '../chat.js'; + +// Mock fetch globally +const mockFetch = vi.fn(); +vi.stubGlobal('fetch', mockFetch); + +// Mock logger to suppress output +vi.mock('../logger.js', () => ({ + createLogger: () => ({ + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + }), +})); + +// Mock the event store to avoid SQLite initialization +class FakeEventStore { + recordEvent = vi.fn(); + getEvents = vi.fn().mockReturnValue([]); + getSession = vi.fn().mockReturnValue(null); + upsertSession = vi.fn(); +} +vi.mock('../event-store.js', () => ({ + EventStore: FakeEventStore, +})); + +// Mock repo-config +vi.mock('../repo-config.js', () => ({ + loadRepoConfig: vi.fn(() => ({ + contextBlocks: {}, + quickActions: [], + venvPaths: [], + resolvedVenvPaths: [], + allowedPaths: [], + roots: [], + toolTierOverrides: {}, + inboxPath: '', + resolvedInboxPath: '', + repos: {}, + isolation: true, + })), +})); + +const { fetchBootContext } = await import('../chat.js'); + +beforeEach(() => { + vi.clearAllMocks(); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe('fetchBootContext', () => { + const CONTEXGIN_URL = 'http://localhost:4195'; + + it('returns contexgin boot context on successful response', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + agent: 'mitzo-conversational', + boot: { + content: '# Boot payload\nContext here.', + tokens: 11297, + sources: ['CONSTITUTION.md', 'memory/Profile/Principles.md'], + }, + }), + }); + + const result = await fetchBootContext('mitzo-conversational', CONTEXGIN_URL); + + expect(mockFetch).toHaveBeenCalledOnce(); + expect(mockFetch).toHaveBeenCalledWith( + `${CONTEXGIN_URL}/api/agents/mitzo-conversational/context`, + { signal: expect.any(AbortSignal) }, + ); + + expect(result).toEqual({ + type: 'boot_context', + source: 'contexgin', + sourceCount: 2, + tokenCount: 11297, + tokenBudget: 11297, + sources: [ + { path: 'CONSTITUTION.md', kind: 'reference' }, + { path: 'memory/Profile/Principles.md', kind: 'reference' }, + ], + included: [], + trimmed: [], + fullMarkdown: '# Boot payload\nContext here.', + }); + }); + + it('returns local-fallback when ContexGin is unreachable', async () => { + mockFetch.mockRejectedValueOnce(new Error('ECONNREFUSED')); + + const result = await fetchBootContext('mitzo-conversational', CONTEXGIN_URL); + + expect(result.source).toBe('local-fallback'); + expect(result.sourceCount).toBe(0); + expect(result.tokenCount).toBe(0); + }); + + it('returns local-fallback on non-200 response', async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 404, + text: async () => '{"error":"Agent not found"}', + }); + + const result = await fetchBootContext('nonexistent-agent', CONTEXGIN_URL); + + expect(result.source).toBe('local-fallback'); + expect(result.sourceCount).toBe(0); + }); + + it('returns local-fallback when response lacks boot field', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ agent: 'test', identity: {} }), + }); + + const result = await fetchBootContext('mitzo-conversational', CONTEXGIN_URL); + + expect(result.source).toBe('local-fallback'); + }); + + it('handles empty sources array', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + boot: { content: 'minimal', tokens: 100, sources: [] }, + }), + }); + + const result = await fetchBootContext('mitzo-conversational', CONTEXGIN_URL); + + expect(result.source).toBe('contexgin'); + expect(result.sourceCount).toBe(0); + expect(result.sources).toEqual([]); + expect(result.tokenCount).toBe(100); + }); + + it('filters non-string sources gracefully', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + boot: { + content: 'payload', + tokens: 500, + sources: ['valid.md', null, 42, 'also-valid.md'], + }, + }), + }); + + const result = await fetchBootContext('mitzo-conversational', CONTEXGIN_URL); + + expect(result.sources).toEqual([ + { path: 'valid.md', kind: 'reference' }, + { path: 'also-valid.md', kind: 'reference' }, + ]); + expect(result.sourceCount).toBe(2); + }); + + it('uses default URL from env when not provided', async () => { + const origUrl = process.env.CONTEXGIN_URL; + process.env.CONTEXGIN_URL = 'http://test-host:9999'; + + mockFetch.mockRejectedValueOnce(new Error('timeout')); + + await fetchBootContext('mitzo-conversational'); + + expect(mockFetch).toHaveBeenCalledWith( + 'http://test-host:9999/api/agents/mitzo-conversational/context', + expect.any(Object), + ); + + if (origUrl !== undefined) { + process.env.CONTEXGIN_URL = origUrl; + } else { + delete process.env.CONTEXGIN_URL; + } + }); +}); diff --git a/server/chat.ts b/server/chat.ts index e970bbb0..91c47d2e 100644 --- a/server/chat.ts +++ b/server/chat.ts @@ -87,6 +87,89 @@ export const eventStore = initEventStore(); export type { MitzoMode } from './session-registry.js'; +// ── Boot context via ContexGin HTTP API ────────────────────────── +export interface BootContextMessage { + type: 'boot_context'; + source: 'contexgin' | 'local-fallback'; + sourceCount: number; + tokenCount: number; + tokenBudget: number; + sources: Array<{ path: string; kind: string }>; + included: Array<{ source: string; heading: string; tokens: number; content: string }>; + trimmed: Array<{ source: string; heading: string; tokens: number; content: string }>; + fullMarkdown?: string; +} + +const FALLBACK_BOOT_CONTEXT: BootContextMessage = { + type: 'boot_context', + source: 'local-fallback', + sourceCount: 0, + tokenCount: 0, + tokenBudget: 0, + sources: [], + included: [], + trimmed: [], +}; + +/** + * Fetch boot context from the running ContexGin server. + * Returns a BootContextMessage ready to send to the client. + * Never throws — returns a local-fallback message on any error. + */ +export async function fetchBootContext( + agentName: string, + contexginUrl: string = process.env.CONTEXGIN_URL || 'http://localhost:8321', +): Promise { + try { + const url = `${contexginUrl}/api/agents/${agentName}/context`; + const res = await fetch(url, { + signal: AbortSignal.timeout(5000), + }); + + if (!res.ok) { + const body = await res.text().catch(() => ''); + log.warn('ContexGin agent context request failed', { + status: res.status, + body: body.slice(0, 200), + }); + return { ...FALLBACK_BOOT_CONTEXT }; + } + + const data = (await res.json()) as Record; + const boot = data.boot as Record | undefined; + + if (!boot) { + log.warn('ContexGin response missing boot field', { keys: Object.keys(data) }); + return { ...FALLBACK_BOOT_CONTEXT }; + } + + const bootTokens = typeof boot.tokens === 'number' ? boot.tokens : 0; + const rawSources = Array.isArray(boot.sources) ? boot.sources : []; + + const sources: Array<{ path: string; kind: string }> = rawSources + .filter((s): s is string => typeof s === 'string') + .map((s) => ({ path: s, kind: 'reference' })); + + const fullMarkdown = typeof boot.content === 'string' ? (boot.content as string) : undefined; + + return { + type: 'boot_context', + source: 'contexgin', + sourceCount: sources.length, + tokenCount: bootTokens, + tokenBudget: bootTokens, // server compiles to its own budget — report actual + sources, + included: [], + trimmed: [], + fullMarkdown, + }; + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : String(err); + log.info('ContexGin not reachable, boot context unavailable', { error: msg }); + return { ...FALLBACK_BOOT_CONTEXT }; + } +} + let mcpServers: Record = {}; try { mcpServers = loadMcpServers(); @@ -748,147 +831,8 @@ async function _startChatInner( buildWorktreeSystemPrompt(repoWorktrees) + buildTaskPromptForSession(clientId); - // Fire-and-forget: emit boot context metadata to client + capture prompt comparison - const DEFAULT_TOKEN_BUDGET = 8000; - (async () => { - // Step 1: dynamically import contexgin (optional dependency) - let compileModule: { - compile: (opts: { workspaceRoot: string; tokenBudget: number }) => Promise; - loadAgentDefinition?: (path: string) => Promise; - }; - try { - compileModule = await import('contexgin'); - } catch (importErr: unknown) { - const msg = importErr instanceof Error ? importErr.message : String(importErr); - log.info('contexgin not available, using fallback', { error: msg }); - send(transport, { - type: 'boot_context', - source: 'local-fallback', - sourceCount: 0, - tokenCount: 0, - tokenBudget: DEFAULT_TOKEN_BUDGET, - sources: [], - included: [], - trimmed: [], - }); - return; - } - - // Step 1b: read token budget from agent recipe if available - let tokenBudget = DEFAULT_TOKEN_BUDGET; - try { - if (compileModule.loadAgentDefinition) { - const recipePath = join(cwd, '.agents', 'mitzo-conversational.yaml'); - const def = (await compileModule.loadAgentDefinition(recipePath)) as Record< - string, - unknown - >; - const ctx = def.context as Record | undefined; - const boot = ctx?.boot as Record | undefined; - if (typeof boot?.tokenBudget === 'number') { - tokenBudget = boot.tokenBudget as number; - } - } - } catch { - // Recipe not found or invalid — use default - } - - // Step 2: compile — runtime errors propagate (not swallowed as import failure) - try { - const compiled = await compileModule.compile({ - workspaceRoot: cwd, - tokenBudget, - }); - - // Validate the compiled object shape - if (!compiled || typeof compiled !== 'object') { - log.warn('contexgin compile() returned unexpected shape', { compiled }); - send(transport, { - type: 'boot_context', - source: 'local-fallback', - sourceCount: 0, - tokenCount: 0, - tokenBudget: tokenBudget, - sources: [], - included: [], - trimmed: [], - }); - return; - } - - const obj = compiled as Record; - const rawSources = Array.isArray(obj.sources) ? obj.sources : []; - const rawIncluded = Array.isArray(obj.included) ? obj.included : []; - const rawTrimmed = Array.isArray(obj.trimmed) ? obj.trimmed : []; - const bootTokens = typeof obj.bootTokens === 'number' ? obj.bootTokens : 0; - - // Extract rich source metadata (path + kind) - const sources: Array<{ path: string; kind: string }> = []; - for (const s of rawSources) { - if (s && typeof s === 'object') { - const so = s as Record; - if (typeof so.relativePath === 'string') { - sources.push({ - path: so.relativePath as string, - kind: typeof so.kind === 'string' ? (so.kind as string) : 'reference', - }); - } - } - } - - // Helper to extract section metadata from ExtractedSection objects - const extractSections = ( - raw: unknown[], - ): Array<{ source: string; heading: string; tokens: number; content: string }> => { - const result: Array<{ source: string; heading: string; tokens: number; content: string }> = - []; - for (const t of raw) { - if (t && typeof t === 'object') { - const to = t as Record; - const src = to.source as Record | undefined; - const headingPath = Array.isArray(to.headingPath) ? to.headingPath : []; - result.push({ - source: - src && typeof src.relativePath === 'string' ? (src.relativePath as string) : '', - heading: headingPath.filter((h: unknown) => typeof h === 'string').join(' > '), - tokens: typeof to.tokenEstimate === 'number' ? (to.tokenEstimate as number) : 0, - content: typeof to.content === 'string' ? (to.content as string) : '', - }); - } - } - return result; - }; - - const included = extractSections(rawIncluded); - const trimmed = extractSections(rawTrimmed); - const fullMarkdown = typeof obj.bootPayload === 'string' ? obj.bootPayload : undefined; - - send(transport, { - type: 'boot_context', - source: 'contexgin', - sourceCount: sources.length, - tokenCount: bootTokens, - tokenBudget: tokenBudget, - sources, - included, - trimmed, - fullMarkdown, - }); - } catch (err: unknown) { - const msg = err instanceof Error ? err.message : String(err); - log.warn('boot context compilation failed', { error: msg }); - send(transport, { - type: 'boot_context', - source: 'local-fallback', - sourceCount: 0, - tokenCount: 0, - tokenBudget: tokenBudget, - sources: [], - included: [], - trimmed: [], - }); - } - })(); + // Fire-and-forget: fetch boot context from running ContexGin server + fetchBootContext('mitzo-conversational').then((msg) => send(transport, msg)); capturePromptComparison(wtId, cwd, systemPromptAppend, repoWorktrees).catch(() => {}); // Resolve SDK session UUID for resume — worktree IDs are not valid SDK session IDs