Files
QodeAssist/docs/target-architecture.md
2026-06-11 13:36:23 +02:00

653 lines
28 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# QodeAssist — Target Architecture (v1.0)
Status: design baseline, derived from the fixed use-case inventory below.
Scope: the complete plugin, designed "from scratch" — what the architecture
should be if nothing legacy constrained it. The current code (see
`architecture.md`) already converges on this; §10 lists the remaining deltas.
---
## 1. Use-case inventory (requirements baseline)
Every architectural decision below is justified by one of these. Features not
on this list (Rules system, legacy provider/model/template pickers, Stack A)
are intentionally out of scope.
| # | Use case | What the user gets |
|---|----------|--------------------|
| U1 | **Code completion** | Inline FIM/instruct suggestions via LSP; auto + manual trigger, multiline, smart-context suppression, accept full / word-by-word |
| U2 | **Chat assistant** | 4 placements (sidebar, bottom pane, editor tab, floating window); streaming text + thinking blocks + tool blocks + file-edit blocks (apply/undo); attachments, linked files, @-mentions, open-files sync; token counter; persisted history; one-click summarization; runtime agent + role pickers |
| U3 | **Quick refactor** | Selection + instruction by hotkey; custom-instructions library; separate agent; optional tools; streamed result inserted into the editor |
| U4 | **Tools** | read/create/edit file, search, find, list, build, diagnostics, terminal, todo, load_skill; per-tool enable |
| U5 | **Skills** | discovery from `.qodeassist/skills`, `.claude/skills`, `~/.claude/skills`; auto-injection, explicit `/` picker, always-on |
| U6 | **MCP** | server mode (expose plugin tools, HTTP/SSE + stdio bridge) and client hub (consume external tools in chat/refactor) |
| U7 | **Providers** | 13 `client_api` types over one GenericProvider; secrets store; local-server autostart; model listing |
| U8 | **Agents** | TOML profiles: `extends`, `[body]` table 1:1 with the wire request, Jinja partials, `match` rules, per-agent model override, per-pipeline rosters |
| U9 | **Roles** | JSON roles composed into `system_prompt` via `{{ agent_role(id) }}` |
| U10 | **Bench CLI** | headless agent testing on the same core stack, `.env` secrets |
| U11 | **Configuration UI** | settings pages for everything above; per-project settings; updater + status widget |
---
## 2. Design principles
1. **One stack.** Every LLM byte — completion, chat, compression, refactor,
bench — flows through the same `Session` pipeline. No parallel legacy path.
2. **Hexagonal core.** The runtime (agents, sessions, providers, templates,
prompt rendering) has zero Qt Creator dependencies. The IDE and the bench
CLI are two hosts composing the same core; IDE-specific facts enter only
through ports (document reading, project scanning, secrets, tool hosting).
3. **Configuration is declarative, code is mechanism.** What is sent (request
`[body]`, system prompt, endpoint, model) lives in TOML/JSON/Jinja and is
user-overridable; *how* it is sent (streaming, retries, tool loop, event
routing) lives in C++ and is identical for all providers.
4. **Capability-driven behavior.** Providers and agents declare capabilities
(tools, thinking, images, model listing); features and UI adapt to the
declared set instead of switching on provider names.
5. **Single source of truth for conversation state.** `ConversationHistory`
owns the messages; `ChatModel` and persistence are projections of it, never
independent copies.
6. **Per-feature composition roots, no singletons.** Each feature constructs
and owns its dependencies (`new` + parent); shared services are passed
explicitly (constructor/setter, QML context properties for the chat).
7. **Streaming-first event model.** One typed `ResponseEvent` stream is the
only contract between the core and every consumer. Deltas exist for live
UI (chat); one-shot pipelines (completion, refactor, bench) ignore them,
wait for `finished`, and read the final assistant message from history.
8. **Fail at load, not mid-conversation.** Agent profiles are validated when
loaded (partials resolve, assembled body parses as JSON against a synthetic
context), so a config error never surfaces as a silent runtime drop.
---
## 3. Layered model
```mermaid
flowchart TB
subgraph HOSTS["Hosts — composition roots"]
PLUGIN["Qt Creator plugin<br/>qodeassist.cpp"]
BENCH["bench CLI"]
end
subgraph L5["L5 · Presentation"]
LSP["LSP bridge<br/>inline suggestions"]
QMLUI["ChatView QML<br/>4 placements"]
RW["Refactor widgets"]
SUI["Settings pages"]
end
subgraph L4["L4 · Features"]
FCOMP["CompletionFeature"]
FCHAT["ChatFeature"]
FREF["RefactorFeature"]
end
subgraph L3["L3 · Capabilities"]
CTX["ContextEngine<br/>ports + QtC adapters"]
TOOLS["ToolKit"]
SKILLS["SkillsEngine"]
MCPH["McpHub<br/>client + server"]
end
subgraph L2["L2 · Core runtime — IDE-independent"]
SM["SessionManager"]
SESS["Session"]
AGF["AgentFactory + AgentRouter"]
AG["Agent"]
PROV["GenericProvider"]
TPL["JsonPromptTemplate"]
end
subgraph L1["L1 · Declarative config"]
PCONF["providers/*.toml"]
ACONF["agents/*.toml + partials/*.jinja"]
ROST["rosters / pipelines"]
ROLES["agent_roles/*.json"]
SKCONF["skills/*.md"]
SEC["SecretsStore"]
end
subgraph L0["L0 · Wire — LLMQore"]
CLIENTS["*Client — SSE streaming"]
TOOLFW["Tool framework"]
MCPT["MCP transports"]
end
PLUGIN --> L4
PLUGIN --> SUI
BENCH --> SM
LSP --> FCOMP
QMLUI --> FCHAT
RW --> FREF
FCOMP --> SM
FCHAT --> SM
FREF --> SM
FCOMP --> CTX
FCHAT --> CTX
FREF --> CTX
FCHAT --> SKILLS
FCHAT --> TOOLS
FREF --> TOOLS
TOOLS --> TOOLFW
MCPH --> MCPT
SM --> SESS
SESS --> AG
AGF --> AG
AG --> PROV
AG --> TPL
AGF --> ACONF
AGF --> PCONF
AGF --> SEC
AGF --> ROST
TPL --> ROLES
PROV --> CLIENTS
SKILLS --> SKCONF
```
### Layer contracts
| Layer | Contains | May depend on | Must NOT depend on |
|-------|----------|---------------|--------------------|
| **L0 Wire** | LLMQore clients (one per wire protocol: Claude, OpenAI Chat, OpenAI Responses, Google, Ollama, Mistral, llama.cpp), tool framework, MCP transports | Qt Network | anything above |
| **L1 Config** | `ProviderInstance`, `AgentProfile` (+ loader/validator), rosters, roles, skills, secrets port | toml++, inja | Qt Creator, L2+ |
| **L2 Core** | `Agent`, `AgentFactory`, `AgentRouter`, `Provider`/`GenericProvider`, `JsonPromptTemplate`, `Session`, `SessionManager`, `ConversationHistory`, `SystemPromptBuilder`, `ResponseRouter`, `ToolContributorRegistry` | L0, L1 | Qt Creator, QML, features |
| **L3 Capabilities** | `ContextEngine` (ports + QtC adapters), `ToolKit` (built-in tools), `SkillsEngine`, `McpHub` | L0L2, QtC APIs *only in adapters* | features, UI |
| **L4 Features** | `CompletionFeature`, `ChatFeature` (send/stream, compression, token counting, file edits), `RefactorFeature` | L2, L3 | each other |
| **L5 Presentation** | LSP bridge, ChatView QML, refactor widgets, settings pages | its feature | core internals |
| **Hosts** | plugin shell, bench CLI | everything (composition only) | — |
The hard rule that makes U10 (bench) and testability free: **L0L2 build into
targets with no Qt Creator linkage.** Bench links L0L2 plus a thin CLI host;
the plugin adds L3 adapters, L4, L5.
---
## 4. Core domain model
Rendered copy: [core-class-diagram.svg](core-class-diagram.svg) (regenerate
when the diagram below changes).
```mermaid
classDiagram
direction TB
class SessionManager {
+acquire(agentName) Session
+release(session)
+toolContributors() ToolContributorRegistry
}
class Session {
+send(blocks, toolPolicy)
+cancel()
+history() ConversationHistory
+systemPrompt() SystemPromptBuilder
+event(ResponseEvent)
+finished(id, stopReason)
+failed(id, ErrorInfo)
+cancelled(id)
}
class ConversationHistory {
+messages() vector~Message~
+lastAssistantText() string
+append(Message)
+reset(vector~Message~)
}
class Message {
+role Role
+blocks vector~ContentBlock~
}
class SystemPromptBuilder {
+setLayer(id, text, priority)
+removeLayer(id)
+compose() string
}
class ResponseRouter {
+attach(BaseClient)
+event(ResponseEvent)
}
class Agent {
+config() AgentConfig
+provider() Provider
+promptTemplate() PromptTemplate
}
class AgentFactory {
+create(name) Agent
+configByName(name) AgentConfig
+effectiveModel(name) string
}
class AgentRouter {
+pickAgent(roster, fileCtx) string
}
class Provider {
<<interface>>
+capabilities() Capabilities
+prepareRequest(request, ctx)
+sendRequest(json) RequestID
+cancelRequest(RequestID)
}
class GenericProvider {
-client BaseClient
}
class PromptTemplate {
<<interface>>
+buildFullRequest(request, ctx)
}
class JsonPromptTemplate {
-bodySpec QJsonObject
-env InjaEnvironment
}
class ToolContributorRegistry {
+registerContributor(fn)
+applyTo(ToolsManager)
}
SessionManager o-- Session : pools
SessionManager --> AgentFactory : builds via
SessionManager --> ToolContributorRegistry
Session *-- ConversationHistory
Session *-- SystemPromptBuilder
Session *-- ResponseRouter
Session --> Agent
ConversationHistory o-- Message
Agent *-- Provider
Agent *-- PromptTemplate
AgentFactory ..> Agent : creates
AgentFactory --> AgentRouter
GenericProvider --|> Provider
JsonPromptTemplate --|> PromptTemplate
```
Responsibilities, one line each:
- **Agent** — immutable bundle of *what to call*: resolved config + provider +
compiled prompt template. No request state.
- **Session** — one conversation's runtime: owns history, system-prompt
layers, response routing, the in-flight request, and the tool-execution
loop (tool_use → execute → tool_result → continue). `send(blocks)` is the
*only* entry point: every pipeline appends a user message and dispatches;
there are no per-pipeline send variants. What differs between completion,
chat, and refactor is the agent's template and the consumption mode (deltas
vs final message), never the Session API.
- **SessionManager** — creates/pools sessions per agent; the single place
features go to get one. Pooling (not per-message construction) covers the
"fresh agent + provider + secrets read per request" latency cost. It reuses
only the expensive parts (agent, provider, compiled template, secrets read):
`acquire` hands out a session with cleared history and system-prompt
layers, so one-shot pipelines never see a previous exchange.
- **AgentRouter** — the *only* agent picker. Every pipeline (completion, chat,
compression, refactor) resolves its agent through
`pickAgent(roster, {file, project})`; no feature-local picker logic.
- **GenericProvider** — one class for all 13 client APIs; varies only by
LLMQore client factory + metadata. Request *shape* belongs to the template,
never to the provider.
- **JsonPromptTemplate** — compiles the agent's `[body]` table; renders
Jinja-bearing string values, splices raw JSON, drops empty keys; validated
at load time.
- **SystemPromptBuilder** — ordered named layers (`agent.system`,
`chat.context`, `refactor`, `compression`); features mutate only their own
layer.
- **ResponseRouter / ResponseEvent** — adapts LLMQore client signals into one
typed stream: `TextDelta`, `ThinkingDelta`, `ToolCallStart/End`,
`ToolResult`, `Usage`, `Error`, `MessageStop`.
- **ToolContributorRegistry** — contributors (built-in ToolKit, SkillTool,
McpHub) register once; `SessionManager` applies them to every new session's
`ToolsManager`. This is how MCP tools reach chat *and* refactor (U6) without
feature code knowing about MCP.
---
## 5. Runtime flows
### 5.1 Chat (U2) — the richest path
```mermaid
sequenceDiagram
autonumber
actor U as User
participant V as ChatView QML
participant F as ChatFeature
participant SM as SessionManager
participant S as Session
participant T as JsonPromptTemplate
participant P as GenericProvider
participant C as LLMQore Client
participant R as ResponseRouter
U->>V: message + attachments
V->>F: sendMessage(text, files, images)
F->>SM: acquire(activeAgent)
SM-->>F: Session (pooled)
F->>S: systemPrompt().setLayer("chat.context", project + skills + linked files)
F->>S: send(userBlocks, toolPolicy)
S->>T: buildFullRequest(history, system, ctx)
T-->>S: request JSON (body is 1:1 with the API)
S->>P: sendRequest(json)
P->>C: HTTP POST, SSE stream
loop streaming
C-->>R: chunk / thinking / tool_use / usage
R-->>S: ResponseEvent
S-->>F: event(ResponseEvent)
F-->>V: ChatModel projection update
end
opt tool call requested
S->>S: execute tool via ToolsManager
S->>P: continue with tool_result
end
C-->>R: finalized
R-->>S: MessageStop + Usage
S-->>F: finished()
F->>SM: release(session)
```
State ownership in chat: `Session.history()` is the truth. `ChatModel` is a
QML projection built from history events (`messageAdded`, `messageUpdated`);
`ChatSerializer`/`ChatHistoryStore` persist *history*, and restoring a chat
seeds a new session's history — never the other way around. File-edit blocks,
apply/undo, and the token counter are ChatFeature concerns layered on the
event stream.
### 5.2 Completion (U1)
```
LSP getCompletionsCycling
→ CompletionFeature
agent = AgentRouter.pickAgent(roster.codeCompletion, {file, project})
session = SessionManager.acquire(agent)
ctx = ContextEngine: prefix/suffix + open-files context (policy from
CodeCompletionSettings — editor policy, not agent config)
session.send(blocks{completion context}, tools=off)
on finished → history().lastAssistantText()
→ CodeHandler (output-mode post-processing) → LSP items
```
No special Session method: the completion context travels as the content of
an ordinary user message (a structured block carrying prefix/suffix + file
context), and the template context exposes it as `ctx.prefix` / `ctx.suffix`.
FIM vs instruct is *agent config* (template + body), not feature code: a FIM
agent's body renders `prefix`/`suffix` into FIM fields; an instruct agent's
body renders the same exchange as a chat-shaped request. The feature is
identical for both — and since completion has no incremental UI, it never
touches the delta stream: it waits for `finished` and reads the last message.
### 5.3 Quick refactor (U3)
```
Hotkey → RefactorFeature
agent = AgentRouter.pickAgent(roster.quickRefactor, {file, project})
session = SessionManager.acquire(agent)
session.systemPrompt().setLayer("refactor", tagged selection + output rules)
session.send(blocks{instruction}, toolPolicy)
on finished → history().lastAssistantText()
→ ResponseCleaner → RefactorResult → editor insert (accept/reject)
```
Same consumption mode as completion: the feature listens to
`Session::finished`/`failed` only (events at most drive a progress spinner
and cancel) and reads the result from history — it never connects to raw
client signals. Tool calls during refactor run inside the session's tool
loop; history's last assistant message is whatever the model produced after
the final tool round.
### 5.4 Compression (U2) and bench (U10)
Compression is ChatFeature reusing the same path with
`roster.chatCompression` and a `"compression"` system layer; the summary
starts a new history. Bench is a host: CLI args + `.env` secrets → L1 + L2
composition → `Session.send` → events printed to stdout. Anything bench can't
do without the IDE is, by construction, an L3+ concern.
---
## 6. Configuration model
```mermaid
erDiagram
AGENT_PROFILE ||--o| AGENT_PROFILE : extends
AGENT_PROFILE }o--|| PROVIDER_INSTANCE : provider_instance
AGENT_PROFILE }o--o{ PARTIAL : includes
AGENT_PROFILE }o--o{ ROLE : agent_role
ROSTER }o--o{ AGENT_PROFILE : ranks
MODEL_OVERRIDE |o--|| AGENT_PROFILE : overrides_model
PROVIDER_INSTANCE }o--|| CLIENT_API : client_api
PROVIDER_INSTANCE }o--o| SECRET : api_key_ref
PROVIDER_INSTANCE ||--o| LAUNCH_CONFIG : autostarts
AGENT_PROFILE {
string name
bool abstract
string system_prompt "jinja, composes agent_role()"
json body "request body, 1:1 with API"
string endpoint "may contain MODEL placeholder"
string model "default; override wins"
bool enable_tools "capability hint"
bool enable_thinking "capability hint"
json match "file, path, project patterns"
}
PROVIDER_INSTANCE {
string name
string client_api
string url
string api_key_ref
}
ROLE {
string id
string systemPrompt
}
ROSTER {
string pipeline "completion, chat, compression, refactor"
list agents "ordered candidates"
}
```
Rules of the config layer (full spec: `agent-templates-design.md`):
- `[body]` **is** the request body — field-by-field, deep-mergeable through
`extends`; Jinja-bearing strings render and splice as raw JSON, literals
pass through. No separate sampling/thinking merge machinery.
- `include` resolves only sandboxed partial roots (bundled `:/agents/partials/`,
then user `partials/`); a missing partial is a load-time error.
- Two-level hierarchy: one abstract base per provider family, thin children.
- Per-agent model override lives in `agent_models.json` and is applied by
`AgentFactory`; `${MODEL}` in `endpoint` covers URL-model providers.
- Roles are JSON managed by the Roles settings UI; profiles pull them in with
`{{ agent_role("<id>") }}` — the only system-prompt edit point is the
profile.
- Secrets never appear in TOML; `api_key_ref` resolves through the
`SecretsStore` port (QtC keychain in the plugin, `.env` in bench).
---
## 7. Capabilities layer
**ContextEngine** replaces the monolithic ContextManager with three focused
services behind IDE-agnostic ports:
| Service | Port (L2-visible) | QtC adapter |
|---------|-------------------|-------------|
| `EditorContext` — current doc, selection, prefix/suffix | `IDocumentReader` | TextEditor API |
| `ProjectContext` — root, file listing, ignore filtering (`.qodeassistignore`), open files, changes | `IProjectScanner` | ProjectExplorer API |
| `TokenEstimator` — input estimates, calibrated by server usage | — (pure) | — |
**ToolKit** registers the built-in tools (U4) with the
`ToolContributorRegistry`; each tool declares a permission class (read /
write / execute) so per-tool enablement (settings) and confirmation policy
(terminal commands) live in one place.
**SkillsEngine** (U5): discovery + watching of the three skill roots; exposes
`catalogText()` (names + descriptions for the system prompt),
`alwaysOnBodies()`, and the `load_skill` tool; the `/` picker injects a
skill's body into a single message.
**McpHub** (U6): client side connects configured servers and contributes
their tools through the same registry (tools reach every session uniformly);
server side exposes ToolKit over HTTP/SSE + stdio bridge.
---
## 8. Cross-cutting policies
Architecture is the rules as much as the boxes. These policies bind every
layer and are part of the contract:
### 8.1 Threading
The core runs on the GUI thread; concurrency is the Qt event loop plus async
network I/O — no shared-state threading anywhere in L1L4. Work that can
block (project scans, token estimation over large trees) hides behind L3
ports; an adapter may use worker threads internally but delivers results as
queued signals. Core types are therefore deliberately not thread-safe.
### 8.2 Request lifecycle
A session has at most one in-flight request; `send()` while in flight cancels
the previous request first. Every request terminates in exactly one of three
states — `finished(stopReason)`, `failed(error)`, `cancelled()` — and
cancellation is *not* an error: no consumer may string-match a message to
tell them apart.
### 8.3 Errors
Runtime errors are typed, not strings: `ErrorInfo { category, message,
providerDetail }` with categories `Config | Auth | Network | Provider |
Validation | Tool`. The category drives UI affordances (Auth → open provider
settings, Network → offer retry); free text is for logs only. Load-time
errors (principle 8) surface in the agents settings page, never as a failed
send.
### 8.4 Timeouts and retries
Transfer timeouts are per-pipeline policy (completion short, chat/refactor
from settings), applied by the feature — never baked into agent profiles. A
streaming request is never silently retried after the first byte; automatic
retry with capped backoff is allowed only for connection-phase failures.
Anything beyond that is an explicit user action.
### 8.5 Observability
One `RequestID` correlates feature → session → provider → client → events →
logs. Each layer logs under its own category (`qodeassist.session`,
`qodeassist.provider`, `qodeassist.tools`, …); request bodies are logged only
at debug level, and secrets are redacted unconditionally. `Usage` events are
the single source feeding the token counter, `TokenEstimator` calibration,
and the performance log.
### 8.6 Config compatibility
Agent profiles carry a `schema_version`; the loader migrates old user
configs forward or rejects them with an actionable message — silent
reinterpretation is forbidden. Bundled profiles are read-only resources that
user profiles shadow by name. Persisted chat history is versioned the same
way.
### 8.7 Security
Secrets exist only behind the `SecretsStore` port; they never reach TOML,
logs, or persisted chats. Tool permission classes (read / write / execute)
centralize the confirmation policy. The MCP server is opt-in and binds
loopback by default; skill and partial roots are sandboxed — nothing resolves
outside its declared directory.
### 8.8 Testing
The test pyramid follows the layers:
| Layer | Strategy |
|-------|----------|
| L1 | loader/validator unit tests; golden-file snapshots of every bundled profile's rendered body against a synthetic context — the same check as load-time validation, run in CI |
| L2 | `Session` / `ResponseRouter` replay tests over recorded SSE fixtures per provider; fake `BaseClient`, no network |
| L3 | contract tests against the ports; QtC adapters covered only by plugin integration |
| E2E | bench (U10) against live providers — the same composition the plugin uses |
Layering is enforced mechanically, not by review: each layer is its own
CMake target, and the core targets do not link Qt Creator — a violating
include fails the build.
---
## 9. Module / target layout
```
core/ # no Qt Creator linkage — bench + tests link this
config/ # L1: ProviderInstance, AgentProfile, loaders,
# validators, rosters, roles, secrets port
providers/ # L2: Provider, GenericProvider, ProviderFactory,
# ClaudeCacheControl
prompt/ # L2: JsonPromptTemplate, ContextRenderer, partials
agents/ # L2: Agent, AgentFactory, AgentRouter
session/ # L2: Session, SessionManager, ConversationHistory,
# SystemPromptBuilder, ResponseRouter, events
skills/ # L3 (IDE-free part): SkillsEngine, loaders
ide/ # Qt Creator adapters only
context/ # EditorContext, ProjectContext adapters, ignore
tools/ # built-in ToolKit (build, issues, editor edits…)
mcp/ # McpHub managers
features/
completion/ # LSP bridge + CompletionFeature + CodeHandler
chat/ # ChatFeature: ClientInterface, ChatModel(projection),
# Compressor, TokenCounter, FileEditController,
# serializer/store
refactor/ # RefactorFeature + custom instructions
ui/
ChatView qml/, widgets/, settings pages
hosts/
plugin/ # qodeassist.cpp — composition root, actions, panes
bench/ # CLI composition root
tests/
config/ # loader cases + golden rendered-body snapshots
session/ # SSE replay fixtures per provider, fake client
external/
llmqore/ inja/ tomlplusplus/
```
Dependency direction is strictly downward in the table of §3; `features/*`
never include each other; `ui/*` talks only to its feature; `hosts/*` are the
only places allowed to know about everything.
---
## 10. Deltas from the current working tree
What "from scratch" changes relative to today's code — the migration
checklist to call the architecture done:
1. **Stack A physical teardown** — delete root `providers/*`,
`pluginllmcore/*`, `ConfigurationManager`, legacy provider/model/template
settings pages, and the Stack A registration + MCP loop in
`qodeassist.cpp`. Runtime already has no consumers.
2. **Single history owner** — make `ChatModel` a projection of
`Session::history()` (subscribe to history signals) instead of a parallel
message store with seed-on-send; `ChatCompressor` reads history, not the
model.
3. **Single send path** — delete `Session::sendCompletion(ContextData)`;
the completion context becomes user-message content sent through the one
`send()` (the completion handler already reads its result from history's
last message). Move `QuickRefactorHandler` off raw `BaseClient` signals
(`requestCompleted`/`requestFinalized`/`requestFailed`) onto
`Session::finished`/`failed` + `history().lastAssistantText()`.
4. **Three-state request lifecycle** — add `cancelled` to `Session`; today
`cancel()` emits `failed(id, "Cancelled by user")` and consumers must
string-match to tell cancellation from failure (§8.2).
5. **Typed errors** — replace `lastError` strings and the `failed(QString)`
payload with `ErrorInfo` categories (§8.3).
6. **One agent picker** — fold `pickCompletionAgent` / `pickRefactorAgent`
remnants into `AgentRouter.pickAgent(roster, …)` exclusively; chat picker
filters to the `chatAssistant` roster.
7. **MCP tools on session clients** — register MCP-contributed tools through
`ToolContributorRegistry` so chat/refactor sessions get them (today they
are registered only on dead Stack A providers).
8. **Session pooling**`SessionManager.acquire/release` with a small pool
per agent, replacing per-message agent + provider + secrets construction.
9. **ContextManager split** — extract `EditorContext` / `ProjectContext` /
`TokenEstimator` behind ports; move QtC API use into `ide/context`.
10. **`[body]` model completion** — finish `agent-templates-design.md`
(body-table rendering, sandboxed `include`, load-time validation, model
override + `${MODEL}`, `schema_version` gate), delete sampling/thinking
merge machinery.
11. **Message type unification** — one `Message`/`ContentBlock` shape from
history to QML (roles, text, thinking, tool use/result, images); delete
the parallel `ChatModel::Message` struct.
12. **Test scaffolding** — golden rendered-body snapshots + SSE replay
fixtures (§8.8); CI builds the core targets without Qt Creator so a
layering violation fails the build.
13. **Stale docs cleanup**`project-rules.md` describes the removed Rules
system; mark or delete.