Skip to content

refactor(server): fetch boot context from ContexGin HTTP API#358

Open
dimakis wants to merge 1 commit into
mainfrom
feat/contexgin-http-boot
Open

refactor(server): fetch boot context from ContexGin HTTP API#358
dimakis wants to merge 1 commit into
mainfrom
feat/contexgin-http-boot

Conversation

@dimakis
Copy link
Copy Markdown
Owner

@dimakis dimakis commented May 22, 2026

Summary

  • Replace inline import('contexgin') library call with HTTP fetch to running ContexGin server (GET /api/agents/:name/context)
  • The old approach used a hardcoded 8k token budget fallback and read the agent recipe from the worktree path (often stale/missing). The server already compiles correctly with the recipe's 12k budget.
  • Extract fetchBootContext() as a testable function, reducing ~140 lines of library import + recipe parsing + response mapping to a single HTTP call

Root cause

chat.ts imported contexgin as a Node library and called the legacy compile() function with DEFAULT_TOKEN_BUDGET = 8000. It attempted to read tokenBudget from .agents/mitzo-conversational.yaml in cwd (the worktree), which often didn't have the file or had a stale version. Meanwhile, the running ContexGin server at CONTEXGIN_URL was already compiling correctly with 12k. Mitzo was bypassing its own infrastructure.

Test plan

  • 7 new tests: success path, unreachable server, non-200 response, missing boot field, empty sources, non-string source filtering, env var URL
  • Existing contexgin-smoke, context-baseline, and prompt-compare tests still pass (21/21)
  • Full test suite: no new failures (2 pre-existing on main)
  • Manual: start a Mitzo session, verify boot context shows >8k budget in source explorer

🤖 Generated with Claude Code

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 <noreply@anthropic.com>
Copy link
Copy Markdown
Owner Author

@dimakis dimakis left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Centaur Review

Found 5 issue(s) (2 warning).

server/chat.ts

Clean refactor from library import to HTTP API. Main concern is a missing .catch() on the fire-and-forget promise (unhandled rejection risk), and a UI regression where the included/trimmed section detail and budget-vs-actual distinction are silently lost from BootContextPill.

  • 🟡 bugs (L835): Missing .catch() on fire-and-forget promise chain. If transport.send() throws after the async gap (e.g., transport closed between fetch and send), this produces an unhandled promise rejection. The adjacent capturePromptComparison call correctly uses .catch(() => {}). Fix: fetchBootContext('mitzo-conversational').then((msg) => send(transport, msg)).catch(() => {}); [fixable]
  • 🟡 regressions (L160): included and trimmed are now always empty arrays. The old code populated these from compiled.included/compiled.trimmed with section-level detail (source, heading, token count, content). The frontend's BootContextPill renders expandable 'Included (N)' and 'N sections trimmed' sections from these fields — those UI sections will never appear now. If intentional (ContexGin manages compilation internally), these fields on BootContextMessage are effectively dead code and should be removed from the interface to avoid confusion.
  • 🔵 regressions (L160): tokenBudget is now always equal to tokenCount (both set to bootTokens). The old code distinguished configured budget (from agent recipe YAML, default 8000) from actual usage. The frontend displays these as 'X.Xk / X.Xk' (count / budget) which is now always identical, making the budget half of the label redundant. In the fallback case, budget changed from 8000 to 0, so the pill shows '0 / 0' instead of '0 / 8.0k'.

server/__tests__/boot-context.test.ts

Clean refactor from library import to HTTP API. Main concern is a missing .catch() on the fire-and-forget promise (unhandled rejection risk), and a UI regression where the included/trimmed section detail and budget-vs-actual distinction are silently lost from BootContextPill.

  • 🔵 missing_tests: Missing test for malformed JSON response — when res.json() throws (e.g., body is not valid JSON despite ok: true), the outer catch block should return FALLBACK_BOOT_CONTEXT. This edge case is handled correctly by the implementation but not verified by tests. [fixable]
  • 🔵 unsafe_assumptions (L167): Env var cleanup in the last test is not wrapped in try/finally. If an assertion fails before line 180, process.env.CONTEXGIN_URL leaks its test value into subsequent tests. Use try/finally or Vitest's vi.stubEnv() for safer cleanup. [fixable]

Comment thread server/chat.ts
}
})();
// Fire-and-forget: fetch boot context from running ContexGin server
fetchBootContext('mitzo-conversational').then((msg) => send(transport, msg));
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 bugs: Missing .catch() on fire-and-forget promise chain. If transport.send() throws after the async gap (e.g., transport closed between fetch and send), this produces an unhandled promise rejection. The adjacent capturePromptComparison call correctly uses .catch(() => {}). Fix: fetchBootContext('mitzo-conversational').then((msg) => send(transport, msg)).catch(() => {}); [fixable]

Comment thread server/chat.ts
source: 'contexgin',
sourceCount: sources.length,
tokenCount: bootTokens,
tokenBudget: bootTokens, // server compiles to its own budget — report actual
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 regressions: included and trimmed are now always empty arrays. The old code populated these from compiled.included/compiled.trimmed with section-level detail (source, heading, token count, content). The frontend's BootContextPill renders expandable 'Included (N)' and 'N sections trimmed' sections from these fields — those UI sections will never appear now. If intentional (ContexGin manages compilation internally), these fields on BootContextMessage are effectively dead code and should be removed from the interface to avoid confusion.

Comment thread server/chat.ts
source: 'contexgin',
sourceCount: sources.length,
tokenCount: bootTokens,
tokenBudget: bootTokens, // server compiles to its own budget — report actual
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 regressions: tokenBudget is now always equal to tokenCount (both set to bootTokens). The old code distinguished configured budget (from agent recipe YAML, default 8000) from actual usage. The frontend displays these as 'X.Xk / X.Xk' (count / budget) which is now always identical, making the budget half of the label redundant. In the fallback case, budget changed from 8000 to 0, so the pill shows '0 / 0' instead of '0 / 8.0k'.

expect(result.sourceCount).toBe(2);
});

it('uses default URL from env when not provided', async () => {
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 unsafe_assumptions: Env var cleanup in the last test is not wrapped in try/finally. If an assertion fails before line 180, process.env.CONTEXGIN_URL leaks its test value into subsequent tests. Use try/finally or Vitest's vi.stubEnv() for safer cleanup. [fixable]

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant