auto-model-router 0.2.25 → 0.2.27
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/omp-extension/router-embed.ts +123 -47
- package/package.json +1 -1
- package/src/cli/config-cmd.ts +0 -38
- package/test/embed-lifecycle.test.ts +170 -0
|
@@ -19,11 +19,11 @@
|
|
|
19
19
|
* - /path/to/auto-model-router/omp-extension/router-toast.ts
|
|
20
20
|
*/
|
|
21
21
|
|
|
22
|
-
import { readFileSync } from "node:fs";
|
|
22
|
+
import { appendFileSync, readFileSync } from "node:fs";
|
|
23
23
|
import { homedir } from "node:os";
|
|
24
24
|
import { join } from "node:path";
|
|
25
25
|
|
|
26
|
-
import { ompModelsPath
|
|
26
|
+
import { ompModelsPath } from "../src/cli/config-cmd.ts";
|
|
27
27
|
import { loadConfig } from "../src/config/load.ts";
|
|
28
28
|
import { startServer } from "../src/server/http.ts";
|
|
29
29
|
import type { StartedServer } from "../src/server/http.ts";
|
|
@@ -52,6 +52,39 @@ function readModelsYml(): string {
|
|
|
52
52
|
}
|
|
53
53
|
}
|
|
54
54
|
|
|
55
|
+
/**
|
|
56
|
+
* Appends one line to `$AUTO_MODEL_ROUTER_HOME/embed.log`.
|
|
57
|
+
*
|
|
58
|
+
* A FILE, deliberately: `console.*` from an extension does not reach omp's
|
|
59
|
+
* session log, so the first attempt at this diagnostic left no trace anywhere
|
|
60
|
+
* and the port lifecycle had to be reconstructed from netstat and mtimes. Best
|
|
61
|
+
* effort — a logging failure must never break a session.
|
|
62
|
+
*/
|
|
63
|
+
function writeEmbedLog(line: string): void {
|
|
64
|
+
try {
|
|
65
|
+
appendFileSync(join(routerHome(), "embed.log"), `${new Date().toISOString()} ${line}\n`, "utf8");
|
|
66
|
+
} catch {
|
|
67
|
+
// Unwritable home: the router still works, we just lose the breadcrumb.
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Stops the router when the PROCESS ends — never when a session does.
|
|
73
|
+
* Idempotent: registered once, however many sessions this process hosts.
|
|
74
|
+
*/
|
|
75
|
+
let exitHooked = false;
|
|
76
|
+
function trackProcessExit(): void {
|
|
77
|
+
if (exitHooked) return;
|
|
78
|
+
exitHooked = true;
|
|
79
|
+
// `exit` cannot await, and does not need to: the OS reclaims the socket.
|
|
80
|
+
// The signal hooks exist so a Ctrl-C releases the port promptly.
|
|
81
|
+
for (const signal of ["SIGINT", "SIGTERM"] as const) {
|
|
82
|
+
process.once(signal, () => {
|
|
83
|
+
void app?.stop().catch(() => {});
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
55
88
|
/**
|
|
56
89
|
* Registers the auto-model-router provider (and its virtual models) into omp's model
|
|
57
90
|
* registry at a specific bound port.
|
|
@@ -94,16 +127,29 @@ function registerRouterProvider(pi: ExtensionAPI, port: number, cfg: RouterConfi
|
|
|
94
127
|
});
|
|
95
128
|
}
|
|
96
129
|
|
|
130
|
+
/**
|
|
131
|
+
* The router bound by THIS PROCESS, and the port it serves.
|
|
132
|
+
*
|
|
133
|
+
* Module scope on purpose: Bun caches the module per process, so when omp loads
|
|
134
|
+
* the extension into a second host (its provider-refresh / reload path does
|
|
135
|
+
* exactly that) these stay visible. A second host then REUSES this router
|
|
136
|
+
* instead of binding another port and orphaning every model handle omp already
|
|
137
|
+
* resolved against the first one.
|
|
138
|
+
*/
|
|
139
|
+
let app: StartedServer | null = null;
|
|
140
|
+
let boundPort: number | null = null;
|
|
141
|
+
|
|
142
|
+
/** `$AUTO_MODEL_ROUTER_HOME`, tilde-expanded, defaulting to ~/.auto-model-router. */
|
|
143
|
+
function routerHome(): string {
|
|
144
|
+
const raw = process.env.AUTO_MODEL_ROUTER_HOME ?? join(homedir(), ".auto-model-router");
|
|
145
|
+
return raw === "~" || raw.startsWith("~/") || raw.startsWith("~\\") ? join(homedir(), raw.slice(1)) : raw;
|
|
146
|
+
}
|
|
147
|
+
|
|
97
148
|
export default function (pi: ExtensionAPI): void {
|
|
98
149
|
pi.setLabel("auto-model-router embed");
|
|
99
150
|
|
|
100
151
|
// Shared port file, written only by the main session's router.
|
|
101
|
-
const
|
|
102
|
-
const home =
|
|
103
|
-
homeRaw === "~" || homeRaw.startsWith("~/") || homeRaw.startsWith("~\\")
|
|
104
|
-
? join(homedir(), homeRaw.slice(1))
|
|
105
|
-
: homeRaw;
|
|
106
|
-
const portFile = embedPortPath(home);
|
|
152
|
+
const portFile = embedPortPath(routerHome());
|
|
107
153
|
// Each interactive session binds its OWN router on an ephemeral port, so
|
|
108
154
|
// sessions stay independent: no shared process to contend over, and no
|
|
109
155
|
// session left broken because another one exited. `server.port` from
|
|
@@ -113,12 +159,22 @@ export default function (pi: ExtensionAPI): void {
|
|
|
113
159
|
const requestedPort = resolveEmbedPort(process.env.AUTO_MODEL_ROUTER_PORT);
|
|
114
160
|
const cfg = loadConfig({ overrides: { server: { host: "127.0.0.1", port: requestedPort } } });
|
|
115
161
|
|
|
116
|
-
let app: StartedServer | null = null;
|
|
117
|
-
|
|
118
162
|
pi.on("session_start", async (_event, ctx) => {
|
|
119
163
|
// The omp UI session id tags every request so the toast can scope its
|
|
120
164
|
// notifications to that exact session (see router-toast.ts).
|
|
121
165
|
const sessionId = ctx.sessionManager.getSessionId();
|
|
166
|
+
|
|
167
|
+
// This module is cached per PROCESS, so `app` and `boundPort` are
|
|
168
|
+
// process-global even when omp loads the extension into more than one
|
|
169
|
+
// host. A router already bound in this process is therefore reusable:
|
|
170
|
+
// re-register it for the new session id and return. Never rebind — a
|
|
171
|
+
// second bind would take a different port and orphan every model handle
|
|
172
|
+
// omp already resolved against the first one.
|
|
173
|
+
if (app !== null && boundPort !== null) {
|
|
174
|
+
registerRouterProvider(pi, boundPort, cfg, sessionId);
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
|
|
122
178
|
if (!ctx.hasUI) {
|
|
123
179
|
// Subagents and headless (-p) sessions prefer the main session's
|
|
124
180
|
// shared router: one process, one ledger, one place to inspect.
|
|
@@ -136,11 +192,8 @@ export default function (pi: ExtensionAPI): void {
|
|
|
136
192
|
const started = startServer(cfg);
|
|
137
193
|
if (started.server.port === undefined) return;
|
|
138
194
|
app = started;
|
|
139
|
-
|
|
140
|
-
pi
|
|
141
|
-
void app?.stop().catch(() => {});
|
|
142
|
-
app = null;
|
|
143
|
-
});
|
|
195
|
+
boundPort = started.server.port;
|
|
196
|
+
registerRouterProvider(pi, boundPort, cfg, sessionId);
|
|
144
197
|
return;
|
|
145
198
|
}
|
|
146
199
|
|
|
@@ -148,68 +201,91 @@ export default function (pi: ExtensionAPI): void {
|
|
|
148
201
|
// the provider against the exact bound port. Registration happens only
|
|
149
202
|
// here — never at factory load, where a stale shared port would be
|
|
150
203
|
// captured into omp's model registry and defeat the correct bound URL.
|
|
151
|
-
if (app) return;
|
|
152
204
|
|
|
153
205
|
// A fixed port was asked for (env var) and a live router already answers
|
|
154
206
|
// there: share it rather than failing to bind. Ephemeral ports — the
|
|
155
207
|
// default — never take this path, so sessions stay independent.
|
|
156
208
|
if (requestedPort !== 0 && (await probeEmbed(requestedPort))) {
|
|
157
209
|
writeEmbedPort(portFile, requestedPort);
|
|
158
|
-
syncModelsYml(cfg, requestedPort);
|
|
159
210
|
registerRouterProvider(pi, requestedPort, cfg, sessionId);
|
|
160
211
|
pi.setLabel(`auto-model-router embed (shared :${requestedPort})`);
|
|
161
212
|
return;
|
|
162
213
|
}
|
|
163
214
|
|
|
164
|
-
//
|
|
165
|
-
//
|
|
166
|
-
//
|
|
215
|
+
// ADOPT THE ADVERTISED PORT. This is the fix for "provider error: Unable
|
|
216
|
+
// to connect" on every real turn while utility calls kept working.
|
|
217
|
+
//
|
|
218
|
+
// omp resolves `modelRoles.default` (auto-model-router/auto) from
|
|
219
|
+
// models.yml during STARTUP — before this extension loads, so before we
|
|
220
|
+
// can bind or register anything. That resolved handle is a SNAPSHOT: a
|
|
221
|
+
// later registerProvider replaces the registry entry but cannot rewrite
|
|
222
|
+
// a handle omp already built. models.yml names the port of the LAST
|
|
223
|
+
// session that wrote it, which after a normal restart is the session the
|
|
224
|
+
// user just closed — a dead socket. Utility calls (title generation,
|
|
225
|
+
// auto-thinking) resolve AFTER our registration and so hit the live port,
|
|
226
|
+
// which is exactly the asymmetry that made this look like a router fault.
|
|
227
|
+
// Measured in the field via embed.log:
|
|
228
|
+
// embed ready pid=61872 port=54985 models.yml-advertised=50596
|
|
229
|
+
//
|
|
230
|
+
// So bind the port omp already resolved against, whenever nothing holds
|
|
231
|
+
// it. Each session still runs its OWN router — this only chooses which
|
|
232
|
+
// port that router listens on. A live peer holding it means the handle
|
|
233
|
+
// works anyway (that peer serves it), and we fall back to ephemeral.
|
|
234
|
+
const advertised = modelsYmlPort(readModelsYml());
|
|
235
|
+
if (requestedPort === 0 && advertised !== null) cfg.server.port = advertised;
|
|
236
|
+
|
|
237
|
+
// Fall back to an ephemeral port when the preferred one is taken, rather
|
|
238
|
+
// than leaving this session with no provider at all.
|
|
167
239
|
let started: StartedServer;
|
|
168
240
|
try {
|
|
169
241
|
started = startServer(cfg);
|
|
170
|
-
} catch
|
|
171
|
-
if (requestedPort === 0) throw err;
|
|
242
|
+
} catch {
|
|
172
243
|
cfg.server.port = 0;
|
|
173
244
|
started = startServer(cfg);
|
|
174
245
|
}
|
|
175
246
|
const actualPort = started.server.port;
|
|
176
247
|
if (actualPort === undefined) return;
|
|
177
248
|
app = started;
|
|
249
|
+
boundPort = actualPort;
|
|
178
250
|
|
|
179
251
|
// Publish the port; subagents and the toast read it from here.
|
|
180
252
|
writeEmbedPort(portFile, actualPort);
|
|
181
253
|
|
|
182
|
-
//
|
|
183
|
-
//
|
|
184
|
-
//
|
|
185
|
-
//
|
|
186
|
-
//
|
|
187
|
-
|
|
188
|
-
|
|
254
|
+
// models.yml is deliberately NOT written. The port belongs to THIS
|
|
255
|
+
// process and changes every launch, so persisting it into a file omp
|
|
256
|
+
// reads at STARTUP — before this extension loads — makes a dead port
|
|
257
|
+
// authoritative for the next session's `modelRoles.default`, and that
|
|
258
|
+
// handle is a snapshot no later registration can repair. That is the
|
|
259
|
+
// regression this whole class of failure came from. The provider is
|
|
260
|
+
// registered dynamically below instead, which is what worked before the
|
|
261
|
+
// file was ever written.
|
|
262
|
+
//
|
|
263
|
+
// `auto-model-router config --write` still exists for anyone who wants a
|
|
264
|
+
// static block on purpose; the adoption above keeps such a block
|
|
265
|
+
// harmless by binding whatever port it names when that port is free.
|
|
189
266
|
|
|
190
267
|
// Register BEFORE any await: everything omp resolves after this point
|
|
191
268
|
// picks up the live URL, so the registration must not sit behind I/O.
|
|
192
269
|
registerRouterProvider(pi, actualPort, cfg, sessionId);
|
|
193
|
-
pi.setLabel(`auto-model-router embed :${actualPort}
|
|
270
|
+
pi.setLabel(`auto-model-router embed :${actualPort}`);
|
|
194
271
|
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
272
|
+
// NO `session_shutdown` teardown. That event is emitted from session
|
|
273
|
+
// DISPOSAL — including omp's provider-refresh / extension-reload path,
|
|
274
|
+
// which runs in a throwaway extension host while the real session keeps
|
|
275
|
+
// going. Because this module is cached per process, such a handler stops
|
|
276
|
+
// the LIVE router: every subsequent turn then fails with Bun's
|
|
277
|
+
// "Unable to connect", while utility calls that resolve after a later
|
|
278
|
+
// rebind still work — the exact asymmetry observed in the field. The
|
|
279
|
+
// router's lifetime is the PROCESS, and the OS reclaims the socket when
|
|
280
|
+
// the process exits.
|
|
281
|
+
trackProcessExit();
|
|
199
282
|
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
const selfProbeOk = await probeEmbed(actualPort);
|
|
207
|
-
console.info(
|
|
208
|
-
`[auto-model-router] embed ready: serving :${actualPort}` +
|
|
209
|
-
` | models.yml advertised :${advertised ?? "none"} at startup` +
|
|
210
|
-
` | self-probe ${selfProbeOk ? "ok" : "FAILED"}` +
|
|
211
|
-
` | session ${sessionId}`,
|
|
283
|
+
writeEmbedLog(
|
|
284
|
+
`embed ready pid=${process.pid} port=${actualPort}` +
|
|
285
|
+
` models.yml-advertised=${advertised ?? "none"}` +
|
|
286
|
+
` models.yml-untouched` +
|
|
287
|
+
` self-probe=${(await probeEmbed(actualPort)) ? "ok" : "FAILED"}` +
|
|
288
|
+
` session=${sessionId}`,
|
|
212
289
|
);
|
|
213
|
-
if (!selfProbeOk) pi.setLabel(`auto-model-router: BOUND :${actualPort} BUT NOT ANSWERING`);
|
|
214
290
|
});
|
|
215
291
|
}
|
package/package.json
CHANGED
package/src/cli/config-cmd.ts
CHANGED
|
@@ -317,44 +317,6 @@ export function ompModelsPath(): string {
|
|
|
317
317
|
if (agentDir !== undefined && agentDir !== "") return join(agentDir, "models.yml");
|
|
318
318
|
return join(homedir(), ".omp", "agent", "models.yml");
|
|
319
319
|
}
|
|
320
|
-
/**
|
|
321
|
-
* Keeps omp's models.yml carrying an up-to-date `auto-model-router` provider
|
|
322
|
-
* block pointing at `port`, silently. The embedded extension calls this every
|
|
323
|
-
* time the main session binds its router, so headless (`-p`) runs, subagent
|
|
324
|
-
* processes, and any other consumer that builds a FRESH model registry (which
|
|
325
|
-
* extension registration does NOT reach) still resolve
|
|
326
|
-
* `auto-model-router/auto` — they read models.yml, not the live registry.
|
|
327
|
-
*
|
|
328
|
-
* Same splice + validation as `config --write`, minus the console output and
|
|
329
|
-
* the backup: this runs on every session start, and a `.bak` per launch would
|
|
330
|
-
* churn the directory. Failure to write is logged by the caller, never thrown —
|
|
331
|
-
* a read-only models.yml must degrade to "registration only", which is the
|
|
332
|
-
* pre-existing behavior.
|
|
333
|
-
*
|
|
334
|
-
* Returns the splice action, or null when nothing was written (already
|
|
335
|
-
* current, or the write failed).
|
|
336
|
-
*/
|
|
337
|
-
export function syncModelsYml(cfg: RouterConfig, port: number, target = ompModelsPath()): SpliceResult["action"] | null {
|
|
338
|
-
try {
|
|
339
|
-
const pointed: RouterConfig = { ...cfg, server: { ...cfg.server, port } };
|
|
340
|
-
const block = renderProviderBlock(pointed, null);
|
|
341
|
-
const existing = existsSync(target) ? readFileSync(target, "utf8") : "";
|
|
342
|
-
const result = spliceProviderBlock(existing, block);
|
|
343
|
-
if (result.action === "replaced") {
|
|
344
|
-
// The guards make "replaced" cheap to detect but the text may still be
|
|
345
|
-
// byte-identical (same port, same costs): skip the write so the file
|
|
346
|
-
// mtime stays stable for tools watching it.
|
|
347
|
-
if (result.text === existing) return null;
|
|
348
|
-
}
|
|
349
|
-
assertUsableModelsYaml(result.text);
|
|
350
|
-
mkdirSync(dirname(target), { recursive: true });
|
|
351
|
-
writeFileSync(target, result.text, "utf8");
|
|
352
|
-
return result.action;
|
|
353
|
-
} catch {
|
|
354
|
-
return null;
|
|
355
|
-
}
|
|
356
|
-
}
|
|
357
|
-
|
|
358
320
|
/** omp's models.yml location: `$PI_CODING_AGENT_DIR` relocates the whole agent dir. */
|
|
359
321
|
|
|
360
322
|
export async function configCommand(args: CliArgs): Promise<void> {
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
2
|
+
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
|
|
6
|
+
import type { ExtensionAPI, ExtensionContext, ProviderRegistration } from "@oh-my-pi/pi-coding-agent";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* How the embedded router must behave, pinned against the two failures that
|
|
10
|
+
* produced "provider error: Unable to connect" on every real turn while utility
|
|
11
|
+
* calls kept working.
|
|
12
|
+
*
|
|
13
|
+
* 1. THE PORT COMES FROM THIS PROCESS, NOT FROM A FILE. omp resolves
|
|
14
|
+
* `modelRoles.default` from models.yml during STARTUP — before this extension
|
|
15
|
+
* loads — and that handle is a snapshot no later registerProvider can
|
|
16
|
+
* rewrite. So the extension must not persist its ephemeral port into
|
|
17
|
+
* models.yml (a dead port then becomes authoritative for the NEXT session),
|
|
18
|
+
* and when a block already exists it adopts the port that block names so the
|
|
19
|
+
* handle omp built is valid. Measured in the field:
|
|
20
|
+
* embed ready pid=61872 port=54985 models.yml-advertised=50596
|
|
21
|
+
*
|
|
22
|
+
* 2. THE ROUTER'S LIFETIME IS THE PROCESS. omp emits `session_shutdown` from
|
|
23
|
+
* session disposal, which includes its provider-refresh / extension-reload
|
|
24
|
+
* path running in a throwaway host while the real session continues. This
|
|
25
|
+
* module is cached per process, so a teardown there stopped the LIVE router.
|
|
26
|
+
*
|
|
27
|
+
* One boot shared by every test, deliberately: the module is cached per process
|
|
28
|
+
* in production too, so this is the real shape.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
const registrations: { id: string; baseUrl: string }[] = [];
|
|
32
|
+
const handlers = new Map<string, ((event: unknown, ctx: ExtensionContext) => void | Promise<void>)[]>();
|
|
33
|
+
|
|
34
|
+
const pi: ExtensionAPI = {
|
|
35
|
+
setLabel: () => {},
|
|
36
|
+
on: (event, handler) => {
|
|
37
|
+
const list = handlers.get(event) ?? [];
|
|
38
|
+
list.push(handler);
|
|
39
|
+
handlers.set(event, list);
|
|
40
|
+
},
|
|
41
|
+
registerProvider: (id: string, registration: ProviderRegistration) => {
|
|
42
|
+
registrations.push({ id, baseUrl: registration.baseUrl });
|
|
43
|
+
},
|
|
44
|
+
unregisterProvider: () => {},
|
|
45
|
+
registerCommand: () => {},
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
async function fire(event: string, sessionId: string): Promise<void> {
|
|
49
|
+
const ctx = { hasUI: true, sessionManager: { getSessionId: () => sessionId } } as ExtensionContext;
|
|
50
|
+
for (const handler of handlers.get(event) ?? []) await handler({ type: event }, ctx);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Health-probes the router: asserts the socket's state, never used as a wait. */
|
|
54
|
+
async function alive(port: number): Promise<boolean> {
|
|
55
|
+
try {
|
|
56
|
+
const res = await fetch(`http://127.0.0.1:${port}/health`, { signal: AbortSignal.timeout(2_000) });
|
|
57
|
+
return res.ok;
|
|
58
|
+
} catch {
|
|
59
|
+
return false;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const portOfLatestRegistration = (): number =>
|
|
64
|
+
Number.parseInt(/:(\d+)\//.exec(registrations.at(-1)?.baseUrl ?? "")?.[1] ?? "0", 10);
|
|
65
|
+
|
|
66
|
+
let home = "";
|
|
67
|
+
let modelsYmlPath = "";
|
|
68
|
+
let advertised = 0;
|
|
69
|
+
let modelsYmlBefore = "";
|
|
70
|
+
|
|
71
|
+
beforeAll(async () => {
|
|
72
|
+
home = mkdtempSync(join(tmpdir(), "embed-life-"));
|
|
73
|
+
const agentDir = join(home, "agent");
|
|
74
|
+
mkdirSync(agentDir, { recursive: true });
|
|
75
|
+
modelsYmlPath = join(agentDir, "models.yml");
|
|
76
|
+
|
|
77
|
+
// A port nothing listens on — the shape a restart leaves behind, since
|
|
78
|
+
// models.yml names the session the user just closed.
|
|
79
|
+
const probe = Bun.serve({ port: 0, hostname: "127.0.0.1", fetch: () => new Response("x") });
|
|
80
|
+
advertised = probe.port as number;
|
|
81
|
+
probe.stop(true);
|
|
82
|
+
|
|
83
|
+
writeFileSync(
|
|
84
|
+
modelsYmlPath,
|
|
85
|
+
`providers:
|
|
86
|
+
# BEGIN auto-model-router
|
|
87
|
+
auto-model-router:
|
|
88
|
+
baseUrl: http://127.0.0.1:${advertised}/v1
|
|
89
|
+
api: openai-completions
|
|
90
|
+
auth: none
|
|
91
|
+
models:
|
|
92
|
+
- id: auto
|
|
93
|
+
name: Auto (auto-model-router)
|
|
94
|
+
# END auto-model-router
|
|
95
|
+
`,
|
|
96
|
+
"utf8",
|
|
97
|
+
);
|
|
98
|
+
modelsYmlBefore = readFileSync(modelsYmlPath, "utf8");
|
|
99
|
+
|
|
100
|
+
process.env.AUTO_MODEL_ROUTER_HOME = home;
|
|
101
|
+
process.env.AUTO_MODEL_ROUTER_DB = join(home, "router.db");
|
|
102
|
+
process.env.PI_CODING_AGENT_DIR = agentDir;
|
|
103
|
+
delete process.env.AUTO_MODEL_ROUTER_PORT;
|
|
104
|
+
|
|
105
|
+
const mod = (await import("../omp-extension/router-embed.ts")) as { default: (api: ExtensionAPI) => void };
|
|
106
|
+
mod.default(pi);
|
|
107
|
+
await fire("session_start", "session-one");
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
afterAll(async () => {
|
|
111
|
+
// Release the socket the way the extension intends: on a process signal.
|
|
112
|
+
process.emit("SIGTERM");
|
|
113
|
+
// Poll the real condition instead of guessing a duration; each probe yields.
|
|
114
|
+
for (let i = 0; i < 50 && (await alive(portOfLatestRegistration())); i++) {
|
|
115
|
+
/* keep probing until the socket stops answering */
|
|
116
|
+
}
|
|
117
|
+
delete process.env.PI_CODING_AGENT_DIR;
|
|
118
|
+
try {
|
|
119
|
+
rmSync(home, { recursive: true, force: true });
|
|
120
|
+
} catch {
|
|
121
|
+
/* SQLite may still hold the file on Windows; the temp dir is disposable */
|
|
122
|
+
}
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
describe("embedded router: port selection", () => {
|
|
126
|
+
test("adopts the port models.yml advertises, so omp's pre-resolved handle is valid", () => {
|
|
127
|
+
expect(portOfLatestRegistration()).toBe(advertised);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test("the adopted port actually serves", async () => {
|
|
131
|
+
expect(await alive(advertised)).toBe(true);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test("does NOT write its port into models.yml", () => {
|
|
135
|
+
// Persisting an ephemeral port makes it authoritative for the NEXT
|
|
136
|
+
// session's startup resolution, which is where the dead handle came from.
|
|
137
|
+
expect(readFileSync(modelsYmlPath, "utf8")).toBe(modelsYmlBefore);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test("publishes the port for subagents and the toast", () => {
|
|
141
|
+
const portFile = join(home, "embed.port");
|
|
142
|
+
expect(existsSync(portFile)).toBe(true);
|
|
143
|
+
expect(readFileSync(portFile, "utf8").trim()).toBe(String(advertised));
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
describe("embedded router: lifetime", () => {
|
|
148
|
+
test("registers NO session_shutdown teardown", () => {
|
|
149
|
+
// omp fires that from a throwaway host during provider refresh, so a
|
|
150
|
+
// teardown there kills a router the live session is still using.
|
|
151
|
+
expect(handlers.get("session_shutdown") ?? []).toHaveLength(0);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
test("a session_shutdown leaves the router running", async () => {
|
|
155
|
+
await fire("session_shutdown", "session-one");
|
|
156
|
+
expect(await alive(advertised)).toBe(true);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
test("a second session reuses the same port instead of rebinding", async () => {
|
|
160
|
+
// Rebinding would take a different port and orphan every handle omp had
|
|
161
|
+
// already resolved against the first one.
|
|
162
|
+
await fire("session_start", "session-two");
|
|
163
|
+
expect(portOfLatestRegistration()).toBe(advertised);
|
|
164
|
+
expect(await alive(advertised)).toBe(true);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
test("each session still gets its own registration, so per-session tagging survives reuse", () => {
|
|
168
|
+
expect(registrations.length).toBeGreaterThanOrEqual(2);
|
|
169
|
+
});
|
|
170
|
+
});
|