feat(api): server-side agent loop + conversations/messages schema #339

Merged
james merged 1 commit from 338-agent-loop into main 2026-06-29 01:04:49 +00:00
Owner

Implements the server-side agent loop (#338, ADR-0029 §2/§4/§6), non-streaming — the piece that makes the built-in agent actually run, wiring the #51 registry + commit path and the #337 provider adapters over normalized persistence.

What's in it

  • Migration 025_conversations_messages (normalized per ADR-0029 §4) + entities + repositories. provider/model snapshotted onto the conversation from the user's #334 config at create time.
  • runTurn / resumeTurn (lib/agent/loop.ts) with an injectable LlmClient (defaults to getLlmClientForUser; tests inject a fake — no live calls). Each round builds the request from buildSystemPrompt() + history + llmToolDefsFromRegistry() and calls client.generate. Read tools execute inline → tool result message; write tools propose-then-pause (conversation → awaiting_confirmation, proposal persisted, DB unmutated); MAX_TOOL_ROUNDS = 8 guard.
  • Resume: the loop never self-confirms — the user commits/rejects via the existing #51 POST /api/agent/proposals/{id}/{commit,reject}; resumeTurn then feeds the proposal's terminal outcome back as the write tool's result and continues. The provider never sees the internal proposalId.
  • Shared dispatch: the read-vs-write tool dispatch is extracted to lib/agent/tool-dispatch.ts and lib/mcp/server.ts is refactored to call it, so the MCP and built-in surfaces dispatch identically (the 14-test MCP suite is unchanged).
  • 5 JSON routes (POST /api/conversations, .../{id}/messages, .../{id}/resume, list, get) + zod DTOs + OpenAPI. 409 llm_not_configured if the user hasn't set a provider (#334). Session or PAT → 401; cross-user conversation → 404.

Safety

  • Per-user scoped end to end — ctx.userId comes only from the resolved identity, never the body/model; tools carry no user_id; cross-user conversations 404 (don't leak). LlmError/AgentError → clean RFC 7807 problems; the provider key never leaks.

Verification (full pre-ship set)

  • typecheck + lint clean; semgrep (full CI pack set) — 0 findings; openapi:check up to date · coverage 116 pairs; @carol/api-client regen + typecheck clean.
  • Full suite on BOTH engines: 1183 passed / 0 skipped (ephemeral Postgres — migration 025 + the conversations/messages repos proven on Postgres). New tests: loop (no-tool / read-round / write-pause-DB-unmutated / resume-after-commit / resume-after-reject / resume-precondition-409 / max-rounds / cross-user-404), HTTP (create→send→get / write-pause→commit→resume / cross-user 404 / 401 / no-config 409), and both DB schemas.

Streaming is the next ticket — SSE needs an adapter stream() that #337 deferred; this loop is non-streaming (JSON request/response). The chat UI follows that.

Closes #338

🤖 Generated with Claude Code

Implements the server-side **agent loop** (#338, ADR-0029 §2/§4/§6), **non-streaming** — the piece that makes the built-in agent actually run, wiring the #51 registry + commit path and the #337 provider adapters over normalized persistence. ## What's in it - **Migration `025_conversations_messages`** (normalized per ADR-0029 §4) + entities + repositories. `provider`/`model` snapshotted onto the conversation from the user's #334 config at create time. - **`runTurn` / `resumeTurn`** (`lib/agent/loop.ts`) with an **injectable `LlmClient`** (defaults to `getLlmClientForUser`; tests inject a fake — no live calls). Each round builds the request from `buildSystemPrompt()` + history + `llmToolDefsFromRegistry()` and calls `client.generate`. Read tools execute inline → `tool` result message; **write tools propose-then-pause** (conversation → `awaiting_confirmation`, proposal persisted, **DB unmutated**); `MAX_TOOL_ROUNDS = 8` guard. - **Resume**: the loop never self-confirms — the user commits/rejects via the existing #51 `POST /api/agent/proposals/{id}/{commit,reject}`; `resumeTurn` then feeds the proposal's terminal outcome back as the write tool's result and continues. The provider never sees the internal `proposalId`. - **Shared dispatch**: the read-vs-write tool dispatch is extracted to `lib/agent/tool-dispatch.ts` and `lib/mcp/server.ts` is **refactored to call it**, so the MCP and built-in surfaces dispatch identically (the 14-test MCP suite is unchanged). - **5 JSON routes** (`POST /api/conversations`, `.../{id}/messages`, `.../{id}/resume`, list, get) + zod DTOs + OpenAPI. `409 llm_not_configured` if the user hasn't set a provider (#334). Session or PAT → 401; **cross-user conversation → 404**. ## Safety - Per-user scoped end to end — `ctx.userId` comes only from the resolved identity, never the body/model; tools carry no `user_id`; cross-user conversations 404 (don't leak). `LlmError`/`AgentError` → clean RFC 7807 problems; the provider key never leaks. ## Verification (full pre-ship set) - typecheck + lint clean; **semgrep (full CI pack set) — 0 findings**; `openapi:check` up to date · coverage 116 pairs; `@carol/api-client` regen + typecheck clean. - **Full suite on BOTH engines: 1183 passed / 0 skipped** (ephemeral Postgres — migration 025 + the conversations/messages repos proven on Postgres). New tests: loop (no-tool / read-round / write-pause-DB-unmutated / resume-after-commit / resume-after-reject / resume-precondition-409 / max-rounds / cross-user-404), HTTP (create→send→get / write-pause→commit→resume / cross-user 404 / 401 / no-config 409), and both DB schemas. **Streaming is the next ticket** — SSE needs an adapter `stream()` that #337 deferred; this loop is non-streaming (JSON request/response). The chat UI follows that. Closes #338 🤖 Generated with [Claude Code](https://claude.com/claude-code)
feat(api): server-side agent loop + conversations/messages schema
Some checks failed
Commits / Conventional Commits (pull_request) Successful in 11s
PR / Static analysis (pull_request) Successful in 54s
PR / OSV-Scanner (pull_request) Successful in 18s
PR / Trivy (image) (pull_request) Successful in 1m30s
PR / OpenAPI (pull_request) Successful in 3m57s
PR / Client (web export smoke) (pull_request) Successful in 4m9s
PR / Lint (pull_request) Successful in 4m23s
PR / pnpm audit (pull_request) Successful in 4m20s
PR / Typecheck (pull_request) Successful in 4m44s
PR / Package age policy (soft) (pull_request) Successful in 47s
PR / Test (sqlite) (pull_request) Successful in 4m54s
Secrets / gitleaks (pull_request) Successful in 45s
PR / Build (pull_request) Successful in 5m17s
PR / Test (postgres) (pull_request) Failing after 5m21s
PR / Coverage (soft) (pull_request) Successful in 3m13s
PR / E2E (Playwright) (pull_request) Successful in 6m47s
e844c968f9
Implements the server-side agent loop (#338, ADR-0029 §2/§4/§6),
non-streaming. runTurn/resumeTurn over normalized conversations/messages
(migration 025), wiring the #51 tool registry + commit path and the #337
provider adapters with an injectable LlmClient (no live calls in tests).

Read tools execute inline and append a tool result; write tools
propose-then-pause (conversation → awaiting_confirmation, proposal
persisted, DB unmutated) and resume after the user commits/rejects via
the existing #51 /api/agent/proposals/{id}/{commit,reject} endpoints.
MAX_TOOL_ROUNDS guards against runaway loops. The read-vs-write dispatch
is extracted to lib/agent/tool-dispatch.ts and shared with the MCP
endpoint (lib/mcp/server.ts refactored to call it — MCP suite unchanged),
so both surfaces dispatch identically.

Adds 5 JSON routes (POST /api/conversations, .../{id}/messages,
.../{id}/resume, list, get) + zod DTOs + OpenAPI; provider/model are
snapshotted from the user's #334 config (409 llm_not_configured if
unset). Per-user scoped — a conversation owned by another user returns
404. Both-engine tests with a fake LLM client.

Closes #338

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

📊 Test coverage

Patch coverage: no testable lines changed.

Overall (app/, lib/, db/, excluding UI per ADR-0019):

Metric Value Soft target
Lines 78.7% ≥ 50%
Branches 70.3% ⚠️ ≥ 75%
Functions 79.9% informational

Soft thresholds per ADR-0019. Coverage is informational and does not block merge.

<!-- coverage-comment --> ## 📊 Test coverage **Patch coverage:** no testable lines changed. **Overall** (`app/`, `lib/`, `db/`, excluding UI per ADR-0019): | Metric | Value | Soft target | |---|---|---| | Lines | 78.7% ✅ | ≥ 50% | | Branches | 70.3% ⚠️ | ≥ 75% | | Functions | 79.9% | informational | Soft thresholds per [ADR-0019](docs/adr/0019-coverage-soft-targets.md). Coverage is informational and does not block merge.
james merged commit 393cba36c6 into main 2026-06-29 01:04:49 +00:00
james deleted branch 338-agent-loop 2026-06-29 01:04:49 +00:00
Sign in to join this conversation.
No description provided.