Adds the harness plug-in kind (interface 1). packages/plugins/harness/pi and claude-code hold what core branched on: the adapter, thinking levels, preflight, version probe, sandbox mounts, builtins line, typed tool names, gate timeout, bundle files, adapter env, turn end and session-files path. Core reaches a module only through the loader. A session's route is the instance's harness, model and thinking vars, or the three fallback. vars; a missing one refuses no-route, and the system file's execution keys are never read for a session. The launcher checks the primary route, then the fallback: harness-off, harness-refused, thinking-unsupported, then preflight. A failed preflight falls back when a fallback is set, and any refusal of the fallback names the primary's failure too. Account checks run only when the route's module counts an account. The manifest records route, routeReason and harnessVersion, replacing piVersion and claudeVersion. pi gets --thinking, Claude Code --effort, both from MOSAIC_THINKING. The business file refuses a partial fallback. routes and routes.set are human-only bus verbs outside ORGANIZATION_KEYS; checkRoutes is shared by routes.set, instances.set and limits.set and checks only changed routes. The chief of staff changes an instance's model, and the assistant's whole vars (Q169), through limits.set, so its route write runs the same checks. A model outside launch.models refuses unknown-model, primary or fallback, and routes.set passes that code through. A new run binds only on a switched-on harness module name and records its transcript path, which the usage reader uses. Settings gains a Routing table with six selects per instance and loses the Role models fieldset; it posts changed rows to /api/routes. webui/src/bus.mjs is unchanged. Until Jason's `mosaic plugins set` adds "harness": ["claude-code"], a new launch refuses harness-off.
122 lines
6.6 KiB
JavaScript
122 lines
6.6 KiB
JavaScript
// Shared fixtures: resolved role instances from the business package's own
|
|
// test business, scratch directories, a fake tool socket and a scripted
|
|
// stand-in for the Anthropic Messages API (row S0's probe mock, reduced).
|
|
// Nothing here reads the real configuration or calls a real model.
|
|
|
|
import { createServer as createHttp } from "node:http";
|
|
import { createServer } from "node:net";
|
|
import { mkdtempSync, realpathSync, rmSync } from "node:fs";
|
|
import { tmpdir } from "node:os";
|
|
import { join } from "node:path";
|
|
import { resolveInstance, validateBusinessDocument } from "../../business/src/index.mjs";
|
|
import { businessDoc, REPO_ROLES, systemFor } from "../../business/tests/helpers.mjs";
|
|
import { createLoader } from "../../plugins/src/loader.mjs";
|
|
|
|
export { REPO } from "../../business/tests/helpers.mjs";
|
|
|
|
export function scratch(t, prefix = "mosaic-harness-") {
|
|
const dir = realpathSync(mkdtempSync(join(tmpdir(), prefix)));
|
|
t.after(() => rmSync(dir, { recursive: true, force: true }));
|
|
return dir;
|
|
}
|
|
|
|
// Row 65: the two harness modules, each through its own loader as the launcher loads them.
|
|
async function harnessModule(name) {
|
|
const loader = createLoader({ log() {} });
|
|
const { refused } = await loader.load({ harness: [name] });
|
|
if (refused.length) throw new Error(`harness ${name} refused: ${JSON.stringify(refused)}`);
|
|
return loader.modules("harness")[0];
|
|
}
|
|
export const HARNESS = Object.freeze({ pi: await harnessModule("pi"), "claude-code": await harnessModule("claude-code") });
|
|
|
|
// A session's route is its own three vars (row 65); a role the test doesn't route runs pi on the
|
|
// test system's old execution.model at thinking high, so the bundle tests keep their models.
|
|
export const ROUTE_DEFAULTS = Object.freeze({ harness: "pi", model: "glm-5.3-flash", thinking: "high" });
|
|
|
|
// resolved("pm" | "coder" | ..., { mutate }) in a scratch root. routes: false leaves a role's
|
|
// route vars as the business file has them.
|
|
export function resolvedFor(t, instance, mutate, { routes = true } = {}) {
|
|
const root = scratch(t, "mosaic-harness-biz-");
|
|
const doc = businessDoc(root);
|
|
mutate?.(doc);
|
|
if (routes) for (const entry of Object.values(doc.roles)) entry.vars = { ...ROUTE_DEFAULTS, ...(entry.vars ?? {}) };
|
|
const business = validateBusinessDocument(doc, join(root, "businesses", "acme.json"), { rolesDir: REPO_ROLES });
|
|
return resolveInstance({ system: systemFor(root), business, instance });
|
|
}
|
|
|
|
// A tool socket that answers with `handler(tool, args)`; a throw becomes a refusal.
|
|
export async function fakeToolSocket(t, dir, handler) {
|
|
const path = join(dir, "tools.sock");
|
|
const calls = [];
|
|
const server = createServer((s) => {
|
|
let input = "";
|
|
s.on("data", async (b) => {
|
|
input += b;
|
|
if (!input.includes("\n")) return;
|
|
const { tool, args } = JSON.parse(input);
|
|
calls.push({ tool, args });
|
|
try {
|
|
s.end(JSON.stringify({ ok: true, result: await handler(tool, args) }) + "\n");
|
|
} catch (e) {
|
|
s.end(JSON.stringify({ ok: false, error: e.message }) + "\n");
|
|
}
|
|
});
|
|
});
|
|
await new Promise((r) => server.listen(path, r));
|
|
t.after(() => server.close());
|
|
return { path, calls };
|
|
}
|
|
|
|
const textOf = (c) => (typeof c === "string" ? c : Array.isArray(c) ? c.map((b) => b.text ?? "").join("") : "");
|
|
function toolResults(messages) {
|
|
const out = [];
|
|
for (const m of messages ?? []) {
|
|
if (m.role !== "user" || !Array.isArray(m.content)) continue;
|
|
for (const b of m.content) if (b.type === "tool_result") out.push({ id: b.tool_use_id, isError: b.is_error ?? false, content: textOf(b.content) });
|
|
}
|
|
return out;
|
|
}
|
|
|
|
// A scripted Anthropic Messages API. Each request that offers tools gets
|
|
// the next scripted tool call; once every call has a result it answers
|
|
// `done(results)` as text. Returns { url, requests, close }.
|
|
export async function mockAnthropic(t, { script = [], done = (r) => `DONE ${JSON.stringify(r)}` } = {}) {
|
|
const requests = [];
|
|
const server = createHttp((req, res) => {
|
|
let raw = "";
|
|
req.on("data", (c) => (raw += c));
|
|
req.on("end", () => {
|
|
if (req.method !== "POST" || !/\/messages(\?|$)/.test(req.url)) {
|
|
res.writeHead(req.url.includes("count_tokens") ? 200 : 404, { "content-type": "application/json" });
|
|
return res.end(JSON.stringify(req.url.includes("count_tokens") ? { input_tokens: 10 } : { type: "error", error: { type: "not_found_error", message: "mock" } }));
|
|
}
|
|
const body = JSON.parse(raw || "{}");
|
|
const tools = Array.isArray(body.tools) ? body.tools.map((x) => x.name) : [];
|
|
const results = toolResults(body.messages);
|
|
const step = tools.length ? script[results.length] : null;
|
|
const r = step ? { kind: "tool", id: `toolu_${results.length + 1}`, name: step.name, input: step.input } : { kind: "text", text: tools.length ? done(results) : "mock" };
|
|
requests.push({ tools, results, system: body.system, answer: r, raw });
|
|
const content = r.kind === "tool" ? { type: "tool_use", id: r.id, name: r.name, input: {} } : { type: "text", text: "" };
|
|
const delta = r.kind === "tool" ? { type: "input_json_delta", partial_json: JSON.stringify(r.input) } : { type: "text_delta", text: r.text };
|
|
const stop = r.kind === "tool" ? "tool_use" : "end_turn";
|
|
if (!body.stream) {
|
|
res.writeHead(200, { "content-type": "application/json" });
|
|
const c = r.kind === "tool" ? { type: "tool_use", id: r.id, name: r.name, input: r.input } : { type: "text", text: r.text };
|
|
return res.end(JSON.stringify({ id: "msg_mock", type: "message", role: "assistant", model: body.model, content: [c], stop_reason: stop, stop_sequence: null, usage: { input_tokens: 10, output_tokens: 5 } }));
|
|
}
|
|
const send = (event, data) => res.write(`event: ${event}\ndata: ${JSON.stringify({ type: event, ...data })}\n\n`);
|
|
res.writeHead(200, { "content-type": "text/event-stream", "cache-control": "no-cache" });
|
|
send("message_start", { message: { id: "msg_mock", type: "message", role: "assistant", model: body.model, content: [], stop_reason: null, stop_sequence: null, usage: { input_tokens: 10, output_tokens: 1, cache_creation_input_tokens: 0, cache_read_input_tokens: 0 } } });
|
|
send("content_block_start", { index: 0, content_block: content });
|
|
send("content_block_delta", { index: 0, delta });
|
|
send("content_block_stop", { index: 0 });
|
|
send("message_delta", { delta: { stop_reason: stop, stop_sequence: null }, usage: { output_tokens: 5 } });
|
|
send("message_stop", {});
|
|
res.end();
|
|
});
|
|
});
|
|
await new Promise((r) => server.listen(0, "127.0.0.1", r));
|
|
t.after(() => server.close());
|
|
return { url: `http://127.0.0.1:${server.address().port}`, requests };
|
|
}
|