Skip to content

Agent harness adoption, Part A: TypeScript setup, Model/Tool wrappers, event mapping - #41

Merged
qduc merged 3 commits into
mainfrom
claude/relaxed-bohr-dd7rlv
Nov 8, 2026
Merged

qduc merged 3 commits into
mainfrom
claude/relaxed-bohr-dd7rlv

Conversation

@qduc

@qduc qduc commented Nov 8, 2026

Copy link
Copy Markdown
Owner

Part A of #40. ChatForge's providers and tools now implement the agent harness contracts, and harness turn events have a tested mapping onto today's SSE stream and persistence. Nothing runs on the harness yet: Part B needs the published package. Today's request path is unchanged apart from the Anthropic fix below.

Commits

1. fix(anthropic): prompt-cache blocks were breaking the system prompt. Can be cherry-picked on its own.
On main, prompt caching rewrites the system message into content blocks, and the Messages adapter then joined them as strings. The result was system: "[object Object]" on every Anthropic request with a system prompt. The adapter also rebuilt text and image parts without their cache_control, so no cache breakpoint ever reached Anthropic. System content with blocks now becomes Anthropic system blocks, and parts keep cache_control. Regression test in messages_adapter.test.js.

2. build(backend): TypeScript for new code.

  • Checked first: the packaged desktop app can run .ts. Electron 43.8.7 (Node 24.22.1) imports a .ts entry point, including .ts→.ts and .ts→.js imports, both unpacked and from inside app.asar. This used a minimal test app, not a full ChatForge package build.
  • backend/tsconfig.json has allowJs, checkJs: false, noEmit, strict, erasableSyntaxOnly, allowImportingTsExtensions, verbatimModuleSyntax and nodenext.
  • A typecheck script, run by ci.sh. Jest strips .ts with @swc/jest, and ESLint parses .ts with typescript-eslint.
  • Electron packaging leaves out .ts tests and tsconfig.json. Existing JavaScript is untouched.

3. feat(harness): wrappers and event mapping, in backend/src/lib/harness/:

  • contracts.ts: a temporary local copy of the package's Model, Tool, Item and TurnEvent types. Delete it when the package lands.
  • chatMessages.ts: converts between Item[] and Chat Completions messages without loss. Messages that Item can't represent travel as provider_input.
  • providerModel.ts and models.ts: one Model per provider (OpenAI, Anthropic, Gemini, llama.cpp) on a shared streaming core.
    • Prompt-cache breakpoints are placed inside the wrappers.
    • previous_response_id is sent only to Responses-API providers. If the provider rejects it, the request is retried with the full history.
    • Provider retries show up as retrying events.
    • No module-level state, so concurrent turns share nothing.
  • tools.ts: maps createTool directly onto Tool (parameters, validate, handler, no approval). The user id reaches tools as ctx.app.userId.
  • turnEventSink.ts: lands each TurnEvent in SSE and persistence the way handleToolsStreaming does now. Withdrawn deltas are removed from checkpoints through the new SimplifiedPersistence.truncateContent.
  • createOpenAIRequest now accepts a provider instance instead of resolving one from the DB on every request.
  • docs/agent-harness-event-mapping.md: the full mapping, the deliberate differences from the legacy loop, the gaps to report back to the harness, and a Part B checklist.

Behavior changes once Part B switches over

  • previous_response_id is no longer sent to plain Chat Completions providers. The legacy loop sends a chatcmpl-… id from the second iteration on.
  • A tool that throws is saved with status error. The legacy path saves it as success.
  • Withdrawn text sends a new content_withdrawn SSE event. The frontend doesn't handle it yet; that's on the Part B checklist.

For the harness (Term2)

  • ChatForge needs neither the run budget/cost accounting nor the Codex/OpenAI-specific fields.
  • The harness has no way to say "this response was invalid, ask again", which the legacy reasoning-only retry depends on.
  • ModelEvent has no channel for provider extras while the response is streaming.
  • Usage has no field for reasoning tokens.
  • TurnSpec.limits requires both caps, but ChatForge only has an iteration cap.

Testing

  • npm run typecheck and npm run lint are clean.
  • The full backend suite passes: 1287 tests, up from 1253; all existing tests pass unchanged. It ran locally on Node 22.23, which also strips types; CI uses Node 24.
  • Each provider wrapper is tested by streaming a recorded upstream response through the real provider class and adapter, with only fetch mocked.

Generated by Claude Code

Prompt caching rewrites string content into text blocks carrying
cache_control. The Messages adapter then joined system contents as strings,
sending `system: "[object Object]"` on every Anthropic request with a system
prompt, and rebuilt text and image parts without their cache_control, so no
breakpoint ever reached Anthropic.

System content with blocks now becomes Anthropic system blocks, and text and
image parts keep their cache_control.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JqbJB9ca1EWFbCcpWy52oY
Node 24 runs .ts files directly by stripping types, so there is no build
step: Docker and Electron keep running source files. Checked first that the
packaged desktop app can do this: Electron 43.8.7 (Node 24.22.1) imports a
.ts entry point, and .ts -> .ts and .ts -> .js imports, both unpacked and
from inside app.asar.

- tsconfig.json: allowJs, checkJs off, noEmit, strict, erasableSyntaxOnly,
  allowImportingTsExtensions, verbatimModuleSyntax, nodenext.
- `npm run typecheck`, run by ci.sh.
- Jest strips .ts with @swc/jest; JavaScript stays untransformed ESM.
- ESLint parses .ts with typescript-eslint.
- Electron packaging leaves out .ts tests and tsconfig.json.

Existing JavaScript is unchanged.

Refs #40

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JqbJB9ca1EWFbCcpWy52oY
Part A of #40: ChatForge's providers and tools now implement the agent
harness contracts, and harness turn events have a mapping onto today's SSE
stream and persistence. Nothing runs on the harness yet; Part B needs the
published package.

- contracts.ts mirrors the package's Model, Tool, Item and TurnEvent types
  until the package is installed.
- chatMessages.ts converts Item history to and from Chat Completions
  messages without loss; messages Item can't represent travel as
  provider_input.
- One Model wrapper per provider (OpenAI, Anthropic, Gemini, llama.cpp) on a
  shared streaming core that maps provider chunks to ModelEvents. Prompt-cache
  breakpoints are placed inside the wrappers. Continuation is sent only to
  Responses-API providers, and a rejected one is retried self-contained.
- toHarnessTool maps createTool directly (parameters, validate, handler, no
  approval); the user id reaches tools as ctx.app.userId.
- TurnEventSink lands each TurnEvent in the SSE stream and persistence as
  handleToolsStreaming does today. Withdrawn deltas are taken back out of the
  checkpoint with the new SimplifiedPersistence.truncateContent.
- createOpenAIRequest accepts a provider instance instead of resolving one
  from the database per request.

Each wrapper is checked by streaming a recorded upstream response through the
real provider and adapter. docs/agent-harness-event-mapping.md has the
mapping, the deliberate differences from the legacy loop, and the gaps to
report back to the harness.

Refs #40

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JqbJB9ca1EWFbCcpWy52oY
@qduc
qduc merged commit 07509e9 into main Nov 8, 2026
4 checks passed
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.

2 participants