Skip to content

Commit e932ede

Browse files
authored
refactor(core): route REPL and Telegram through the in-process engine seam (#309)
1 parent 0d1ad65 commit e932ede

7 files changed

Lines changed: 158 additions & 9 deletions

File tree

.changeset/engine-seam-wrap.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
---
2+
"@enduragent/core": patch
3+
---
4+
5+
Route the CLI REPL and the Telegram channel through an in-process engine seam
6+
(createCoachEngine) that delegates verbatim to the coaching agent. Pure
7+
wiring refactor: no behavior change, proven by the replay gate's zero-diff
8+
baseline and the untouched channel test suites.
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import type { Config } from "../config.js";
2+
import type { Sport } from "../sport.js";
3+
import type { Memory } from "../memory/store.js";
4+
import type { ResolvedCs } from "../reference/cs-resolution.js";
5+
import { CoachAgent } from "./coach-agent.js";
6+
7+
/**
8+
* The subset of the agent's public surface the in-process channels consume.
9+
* A verbatim transcription of the corresponding CoachAgent signatures; the
10+
* conformance test asserts exact type equality, so the two cannot drift
11+
* silently.
12+
*/
13+
export interface CoachEngineSeam {
14+
chat(
15+
chatId: string,
16+
userMessage: string,
17+
turn?: { resolvedCs?: ResolvedCs | null },
18+
): Promise<string>;
19+
hasSession(chatId: string): boolean;
20+
resetSession(chatId: string): Promise<{ memoryFlushed: boolean }>;
21+
}
22+
23+
/**
24+
* In-process engine handle: the seam plus the one composition-root
25+
* accessor that cannot cross a wire boundary — the Memory instance the
26+
* startup hook mutates before any channel is reachable.
27+
*/
28+
export interface LocalCoachEngine extends CoachEngineSeam {
29+
getMemory(): Memory;
30+
}
31+
32+
/**
33+
* Pure delegation: the engine holds no state beyond the wrapped agent and
34+
* every method forwards verbatim, so reverting to direct CoachAgent
35+
* construction is a mechanical substitution.
36+
*/
37+
export function createCoachEngine(sport: Sport, config: Config): LocalCoachEngine {
38+
const agent = new CoachAgent(sport, config);
39+
return {
40+
chat: (chatId: string, userMessage: string, turn?: { resolvedCs?: ResolvedCs | null }) =>
41+
agent.chat(chatId, userMessage, turn),
42+
hasSession: (chatId: string) => agent.hasSession(chatId),
43+
resetSession: (chatId: string) => agent.resetSession(chatId),
44+
getMemory: () => agent.getMemory(),
45+
};
46+
}

packages/core/src/channels/telegram.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { Bot, InputFile } from "grammy";
22
import { autoRetry } from "@grammyjs/auto-retry";
3-
import type { CoachAgent } from "../agent/coach-agent.js";
3+
import type { CoachEngineSeam } from "../agent/coach-engine.js";
44
import type { BinaryConfig } from "../binary.js";
55
import { classifyAgentError } from "../agent/error-classify.js";
66
import {
@@ -218,7 +218,7 @@ export interface TelegramBotHandle {
218218

219219
export function createTelegramBot(
220220
token: string,
221-
agent: CoachAgent,
221+
agent: CoachEngineSeam,
222222
binary: BinaryConfig,
223223
dataDir: string,
224224
reference?: ReferenceServices,

packages/core/src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,8 @@ export type { IntervalsClient } from "./intervals.js";
105105

106106
// ─── Agent ────────────────────────────────────────────────────────────
107107
export { CoachAgent } from "./agent/coach-agent.js";
108+
export { createCoachEngine } from "./agent/coach-engine.js";
109+
export type { CoachEngineSeam, LocalCoachEngine } from "./agent/coach-engine.js";
108110
export {
109111
TurnBudgetExceededError,
110112
MAX_TURN_MODEL_CALLS,

packages/core/src/run-binary.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -341,13 +341,13 @@ export async function runBinary(
341341
}
342342

343343
const bootStart = Date.now();
344-
const { CoachAgent } = await import("./agent/coach-agent.js");
345-
const agent = new CoachAgent(sport, config);
344+
const { createCoachEngine } = await import("./agent/coach-engine.js");
345+
const engine = createCoachEngine(sport, config);
346346

347347
// Init order: Memory (above) → startup hook → Reference bootstrap → Telegram.
348348
// Reference's internal init sequence is pinned inside `bootstrapReference`
349349
// per ADR-0011 (two-phase scheduler — no timer until first runSync resolves).
350-
await runStartupHook(agent.getMemory(), hooks.onStartup);
350+
await runStartupHook(engine.getMemory(), hooks.onStartup);
351351

352352
const { bootstrapReference } = await import("./reference/runtime.js");
353353
console.log("syncing training data from intervals.icu…");
@@ -383,7 +383,7 @@ export async function runBinary(
383383
const { createTelegramBot, notifyUpdate } = await import("./channels/telegram.js");
384384
const { bot, drainPending } = createTelegramBot(
385385
config.telegram.botToken,
386-
agent,
386+
engine,
387387
binary,
388388
config.dataDir,
389389
reference.services,
@@ -459,7 +459,7 @@ export async function runBinary(
459459
}
460460

461461
try {
462-
const response = await agent.chat("cli", input);
462+
const response = await engine.chat("cli", input);
463463
console.log("\n" + response + "\n");
464464
} catch (err) {
465465
// Full detail (stack, provider payload) → stderr; a friendly classified
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
import { afterEach, describe, expect, expectTypeOf, it, vi } from "vitest";
2+
import type { CoachAgent } from "../src/agent/coach-agent.js";
3+
import type { CoachEngineSeam } from "../src/agent/coach-engine.js";
4+
5+
describe("createCoachEngine delegates verbatim to CoachAgent", () => {
6+
afterEach(() => {
7+
vi.doUnmock("../src/agent/coach-agent.js");
8+
vi.resetModules();
9+
});
10+
11+
async function setup() {
12+
const chat = vi.fn(async () => "canned-reply");
13+
const hasSession = vi.fn(() => true);
14+
const resetSession = vi.fn(async () => ({ memoryFlushed: false }));
15+
const memorySentinel = { sentinel: "memory" };
16+
const ctorArgs: unknown[][] = [];
17+
vi.doMock("../src/agent/coach-agent.js", () => ({
18+
CoachAgent: class {
19+
chat = chat;
20+
hasSession = hasSession;
21+
resetSession = resetSession;
22+
getMemory = () => memorySentinel;
23+
constructor(...args: unknown[]) {
24+
ctorArgs.push(args);
25+
}
26+
},
27+
}));
28+
const { createCoachEngine } = await import("../src/agent/coach-engine.js");
29+
return { createCoachEngine, chat, hasSession, resetSession, memorySentinel, ctorArgs };
30+
}
31+
32+
it("constructs exactly one CoachAgent with verbatim args", async () => {
33+
const { createCoachEngine, ctorArgs } = await setup();
34+
const sportSentinel = { sentinel: "sport" } as never;
35+
const configSentinel = { sentinel: "config" } as never;
36+
createCoachEngine(sportSentinel, configSentinel);
37+
expect(ctorArgs).toHaveLength(1);
38+
expect(ctorArgs[0]).toHaveLength(2);
39+
expect(ctorArgs[0]![0]).toBe(sportSentinel);
40+
expect(ctorArgs[0]![1]).toBe(configSentinel);
41+
});
42+
43+
it("chat forwards all three args and the result", async () => {
44+
const { createCoachEngine, chat } = await setup();
45+
const engine = createCoachEngine({} as never, {} as never);
46+
const turnSentinel = { resolvedCs: null };
47+
const result = await engine.chat("chat-1", "hello", turnSentinel);
48+
expect(chat).toHaveBeenCalledTimes(1);
49+
expect(chat).toHaveBeenCalledWith("chat-1", "hello", turnSentinel);
50+
expect((chat.mock.calls[0] as unknown[])[2]).toBe(turnSentinel);
51+
expect(result).toBe("canned-reply");
52+
});
53+
54+
it("chat with the third arg omitted forwards undefined", async () => {
55+
const { createCoachEngine, chat } = await setup();
56+
const engine = createCoachEngine({} as never, {} as never);
57+
await engine.chat("chat-1", "hello");
58+
expect(chat).toHaveBeenCalledTimes(1);
59+
expect((chat.mock.calls[0] as unknown[])[2]).toBe(undefined);
60+
});
61+
62+
it("hasSession forwards and returns", async () => {
63+
const { createCoachEngine, hasSession } = await setup();
64+
const engine = createCoachEngine({} as never, {} as never);
65+
expect(engine.hasSession("chat-2")).toBe(true);
66+
expect(hasSession).toHaveBeenCalledTimes(1);
67+
expect(hasSession).toHaveBeenCalledWith("chat-2");
68+
});
69+
70+
it("resetSession forwards and returns the delegate's exact object", async () => {
71+
const { createCoachEngine, resetSession } = await setup();
72+
const engine = createCoachEngine({} as never, {} as never);
73+
const result = await engine.resetSession("chat-3");
74+
expect(resetSession).toHaveBeenCalledTimes(1);
75+
expect(resetSession).toHaveBeenCalledWith("chat-3");
76+
const delegateResult = await resetSession.mock.results[0]!.value;
77+
expect(result).toBe(delegateResult);
78+
});
79+
80+
it("getMemory returns the delegate's Memory instance", async () => {
81+
const { createCoachEngine, memorySentinel } = await setup();
82+
const engine = createCoachEngine({} as never, {} as never);
83+
expect(engine.getMemory()).toBe(memorySentinel);
84+
});
85+
86+
it("CoachAgent members exactly equal the seam members (compile-time)", () => {
87+
expectTypeOf<CoachAgent["chat"]>().toEqualTypeOf<CoachEngineSeam["chat"]>();
88+
expectTypeOf<CoachAgent["hasSession"]>().toEqualTypeOf<CoachEngineSeam["hasSession"]>();
89+
expectTypeOf<CoachAgent["resetSession"]>().toEqualTypeOf<CoachEngineSeam["resetSession"]>();
90+
const toSeam = (a: CoachAgent): CoachEngineSeam => a;
91+
expect(typeof toSeam).toBe("function");
92+
});
93+
});

packages/core/tests/run-binary-init-order.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import { describe, expect, it } from "vitest";
88
* verified behaviorally in `reference-runtime.test.ts`. This file guards
99
* only the outer sequence the binary orchestrates around it:
1010
*
11-
* 1. Memory (CoachAgent constructor)
11+
* 1. Memory (engine construction — the CoachAgent constructor runs inside createCoachEngine)
1212
* 2. Startup hook (binary-specific; cycling-coach's runs the legacy migrate)
1313
* 3. Reference bootstrap
1414
* 4. Telegram bot
@@ -23,7 +23,7 @@ describe("run-binary outer init order", () => {
2323
const src = readFileSync(SOURCE_PATH, "utf-8");
2424

2525
const STEPS: ReadonlyArray<readonly [string, string]> = [
26-
["1: Memory (CoachAgent constructor)", "new CoachAgent("],
26+
["1: Memory (engine construction)", "createCoachEngine("],
2727
["2: Startup hook", "await runStartupHook("],
2828
["3: Reference bootstrap", "await bootstrapReference("],
2929
["4: Telegram bot constructed", "createTelegramBot("],

0 commit comments

Comments
 (0)