Turn services + agent DI: chat-handler becomes the composition root (stage 4)
The chat turn is now composed, not implemented, by chat-handler.ts:
- src/server/turn-service.ts: one named, error-contained seam per turn
responsibility — buildTeacherContext (W8 read channel), persistTurn
(canonical state write), spawnTurnJobs (async protocol kick-off),
foldTurnIntoGraph (session→graph consolidation; the extraction-pipeline
stage swaps this body behind the same seam). Each takes its deps
explicitly; failures log one [chat <id>] line and never touch the
response.
- chat-handler.ts wires: storage ports (stores), the job runner, the
teacher tools (ctx = chatId + injected ChatStore), and the services.
It no longer contains any logic beyond request validation.
Agent layer now receives dependencies instead of importing them — a new
lint zone enforces it (src/agent never imports src/server or src/store;
verified to fire both ways):
- runner.ts: createJobRunner({ chatStore, jobStore }) factory returning
the spawn hook + the three job runners; the model-override test seam
stays module-level. Bodies otherwise verbatim.
- plan tools: persist through the ctx-injected ChatStore
(TeacherToolContext = { chatId, chatStore }).
- teacher-agent's dependency-free default registry is now the true
client-side set: questionTools + jobTools (jobTools was ctx-parameter-
shaped but never used its ctx — now honestly context-free). Production
always passes createTeacherTools({ chatId, chatStore }); tests that
drive a bare mock model get the client-side set unchanged.
Zero behavior change: tsc clean, 190/190 tests, eslint clean, production
build clean. Tests inject the same fs ports the chat-handler does.
This commit is contained in:
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* The chat-turn services — one named, error-contained seam per turn
|
||||
* responsibility, extracted from the chat-handler so it composes instead of
|
||||
* implementing. Each takes its dependencies explicitly (storage ports, the
|
||||
* job runner) — no module here imports an adapter.
|
||||
*
|
||||
* buildTeacherContext — graph → instructions block (the W8 read channel)
|
||||
* persistTurn — the canonical state write (client + agent messages)
|
||||
* spawnTurnJobs — the async sub-agent protocol kick-off
|
||||
* foldTurnIntoGraph — session → graph consolidation (the write channel;
|
||||
* the extraction-pipeline stage swaps this body for
|
||||
* the extractor/auditor/merger trigger behind the
|
||||
* same seam)
|
||||
*
|
||||
* Logs are the minimal-observability baseline: one line per failure, tagged
|
||||
* [chat <id>] — structured logging lands behind these seams later.
|
||||
*/
|
||||
|
||||
import type { UIMessage } from "ai";
|
||||
import { buildGraphContext } from "../core/graph-context.ts";
|
||||
import { emitChat, getGraph, type GraphDeps } from "../core/graph.ts";
|
||||
import type { ChatStore, GraphStore } from "../core/ports.ts";
|
||||
import type { JobRunner } from "../agent/subagents/runner.ts";
|
||||
|
||||
/**
|
||||
* W8 (vision §1 "the graph as session context"): the learner's knowledge
|
||||
* graph rides along as part of the teacher's instructions. Built per request
|
||||
* — stateless, and self-updating as the graph grows mid-session. A read
|
||||
* failure must never take the lesson down: the block is context, not a
|
||||
* dependency.
|
||||
*/
|
||||
export function buildTeacherContext(graphStore: GraphStore, chatId: string): string | null {
|
||||
try {
|
||||
return buildGraphContext(getGraph(graphStore));
|
||||
} catch (err) {
|
||||
console.error(`[chat ${chatId}] graph context build failed:`, err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The canonical state write: the client owns the message list between turns
|
||||
* (useChat); this persists client messages + the agent response. Persists
|
||||
* even when aborted — the partial transcript (including any pending
|
||||
* question part) is what makes resume work.
|
||||
*/
|
||||
export function persistTurn(chatStore: ChatStore, chatId: string, messages: UIMessage[], isAborted: boolean): void {
|
||||
try {
|
||||
chatStore.saveMessages(chatId, messages);
|
||||
} catch (err) {
|
||||
console.error(`[chat ${chatId}] persist failed:`, err, { isAborted });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The async sub-agent protocol (contracts.ts): scan the last assistant
|
||||
* message for pending job tool parts and fire the background runners —
|
||||
* fire-and-forget, never awaited; the HTTP response is already gone.
|
||||
*/
|
||||
export function spawnTurnJobs(jobRunner: JobRunner, chatId: string, messages: UIMessage[]): void {
|
||||
try {
|
||||
jobRunner.spawnPendingJobs(chatId, messages);
|
||||
} catch (err) {
|
||||
console.error(`[chat ${chatId}] job spawn failed:`, err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* W4: fold this chat into the knowledge-graph ledgers (tree nodes / edges,
|
||||
* quiz outcomes, taught events) — synchronous but tiny (append-only JSONL),
|
||||
* and errors never touch the response. Skipped under tests: scripted chats
|
||||
* are fixtures, not knowledge.
|
||||
*/
|
||||
export function foldTurnIntoGraph(deps: GraphDeps, chatId: string): void {
|
||||
if (process.env.NODE_ENV === "test") return;
|
||||
try {
|
||||
emitChat(chatId, deps);
|
||||
} catch (err) {
|
||||
console.error(`[chat ${chatId}] graph emission failed:`, err);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user