Files
stack/packages/harness/tests/helpers.mjs
jason.woltje a8d9eecf65 organization: move packages/business to packages/organization (rename r2 stage B1)
git mv packages/business -> packages/organization (@mosaic/organization). The
old package stays as one-line re-export shims of each moved module, with
src/business.mjs re-exporting src/organization-config.mjs and src/cli.mjs
forwarding to the moved executable. BusinessError is OrganizationConfigError
(one constructor, name kept BusinessError); OrganizationError stays distinct.
The bus adapter gains organization-context.mjs (busOrganization), with
business.mjs (busBusiness) as the same function.

Import consumers, manifests, the lockfile, scripts/mosaic, stack-copy.sh
COPY_PATHS (both roots), seat sandbox RUNNER_FILES and the copy closure
walker (organization-imports.mjs, both roots required) move to the new path.
v1 serialization, v1 writers and the stage A fixtures are unchanged; the stage
A contract tests stay where they were and pass as before.
2026-11-09 11:47:48 -06:00

125 lines
6.8 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 "../../organization/src/index.mjs";
import { businessDoc, REPO_ROLES, systemFor } from "../../organization/tests/helpers.mjs";
import { createLoader } from "../../plugins/src/loader.mjs";
export { REPO } from "../../organization/tests/helpers.mjs";
import { REPO } from "../../organization/tests/helpers.mjs";
// Row 64: the repository's skill directories, as <dataRoot>/skills-enabled holds them once activated.
export const REPO_SKILLS = join(REPO, "skills");
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 };
}